Compare commits
143 Commits
Author | SHA1 | Date | |
---|---|---|---|
4f2bbba782 | |||
9eb25f6c9e | |||
f74b00446c | |||
beb7e6ec34 | |||
2eafc042ad | |||
74954bc51d | |||
6a03120225 | |||
21504573b4 | |||
fabd912fba | |||
00b42855b6 | |||
ef272360fb | |||
026a5011d4 | |||
aa4206af0e | |||
7788465272 | |||
3066dfd805 | |||
34303163bc | |||
e7fbcd4fa0 | |||
7c22969de1 | |||
6623bc0113 | |||
146b5201b5 | |||
b021fbde59 | |||
ec046b81a7 | |||
aea497154a | |||
dc736d53b4 | |||
5957b33779 | |||
bafdce56ad | |||
42a2d404e4 | |||
11b2379d98 | |||
c0657a2e9e | |||
646dcb91c5 | |||
ad961f3039 | |||
c16f743b07 | |||
8e13f6ef9b | |||
95bcdea69b | |||
0d6fe4a232 | |||
ced4519412 | |||
ef8b7718b1 | |||
b4762dc463 | |||
9851cce382 | |||
a1460a98fd | |||
1a553a296f | |||
f5bd6b0d58 | |||
78a4946e8b | |||
702ee956a2 | |||
200a7d2d65 | |||
79edc09710 | |||
77255df4be | |||
277133fa1a | |||
abd0e08566 | |||
561d8dbc70 | |||
c973ffd3ba | |||
368de7dedc | |||
e56514629f | |||
7a8a25c4c0 | |||
5d36d3a6bb | |||
0ef35fd31f | |||
c1c22c195d | |||
111d8d8e3c | |||
b0a24e4fc0 | |||
694b9b8991 | |||
fada347aa5 | |||
f37ea9f0e7 | |||
59911925c2 | |||
58fd59beb1 | |||
eb09d77251 | |||
42b9178d96 | |||
45516311f5 | |||
04cfa7366f | |||
4234ab84a9 | |||
b8c05d1014 | |||
91ec9aa0a4 | |||
565e920f1b | |||
5d24adfa75 | |||
1dc94c0027 | |||
ebae2f4ec9 | |||
7099edc591 | |||
de973d6bda | |||
993a491d24 | |||
631efff7ae | |||
a3d1ac2ce4 | |||
4484c21757 | |||
87cd3ef33f | |||
163f4a3d3f | |||
a84256dc03 | |||
cf82066976 | |||
e248c9cedd | |||
90a2fea7d6 | |||
d17b02092e | |||
c70eb7793e | |||
e3a1d2b9cf | |||
e68ebdf76d | |||
949cf58eef | |||
0816defb95 | |||
a076a1c933 | |||
40c95a541d | |||
2d04d7b8d5 | |||
94e0feac68 | |||
cd9e974c40 | |||
11700f75d9 | |||
0439a7beaa | |||
608b195ba9 | |||
8cb5fd64dd | |||
ef317a86ed | |||
19ada83d0b | |||
6ecf66ea2a | |||
65ba16c07c | |||
aee7c12c44 | |||
125956eb3e | |||
954224401d | |||
d268de3e12 | |||
39cfb4603b | |||
15a70abf67 | |||
5cb69291f5 | |||
cf582b2e98 | |||
82e159a083 | |||
2aff139982 | |||
6c7adb6193 | |||
bb2cd1c85a | |||
9d92c48ab7 | |||
dbed4ea527 | |||
8d149b5e2b | |||
27f1ec5d8a | |||
c361143ba2 | |||
069006145a | |||
3e3395aff9 | |||
a2687eacbc | |||
0f2c8c856c | |||
ec4dbf2a51 | |||
3d34aa5e80 | |||
c45bd70711 | |||
9f876e6020 | |||
b200138a9e | |||
53b163e739 | |||
1de8c08c39 | |||
358f0d7eaf | |||
3558f52b84 | |||
5d1f1b452a | |||
fed10c7110 | |||
5e0d207d84 | |||
59bd699727 | |||
c063ae498c | |||
abe6bb1cbb | |||
2ee4d65c8d |
@ -1,8 +0,0 @@
|
||||
.git/
|
||||
.vscode/
|
||||
node_modules/
|
||||
|
||||
.dockerignore
|
||||
.editorconfig
|
||||
.gitignore
|
||||
Dockerfile
|
11
.env
11
.env
@ -1,23 +1,16 @@
|
||||
COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||
|
||||
# CACHE
|
||||
CACHE_TTL=1
|
||||
MAX_ITEM_IN_CACHE=9999
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# POSTGRES
|
||||
POSTGRES_DB=ghostfolio-db
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_DB=ghostfolio-db
|
||||
|
||||
ACCESS_TOKEN_SALT=GHOSTFOLIO
|
||||
ALPHA_VANTAGE_API_KEY=
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/ghostfolio-db?sslmode=prefer
|
||||
GOOGLE_CLIENT_ID=test
|
||||
GOOGLE_SECRET=test
|
||||
IS_DEVELOPMENT_MODE=true
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||
JWT_SECRET_KEY=123456
|
||||
PORT=3333
|
||||
RAKUTEN_RAPID_API_KEY=
|
||||
ROOT_URL=http://localhost:4200
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,11 +1,8 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
/dist/apps/api/data/*.json
|
||||
/docker/**/*.*.*
|
||||
/docker/**/*.*.*.zip
|
||||
/tmp
|
||||
/out-tsc
|
||||
/tmp
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
@ -24,7 +21,6 @@
|
||||
!.vscode/extensions.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
|
11
.travis.yml
Normal file
11
.travis.yml
Normal file
@ -0,0 +1,11 @@
|
||||
language: node_js
|
||||
git:
|
||||
depth: false
|
||||
node_js:
|
||||
- 14
|
||||
before_script:
|
||||
- yarn
|
||||
script:
|
||||
- yarn format:check
|
||||
- yarn test
|
||||
- yarn build:all
|
334
CHANGELOG.md
334
CHANGELOG.md
@ -5,6 +5,324 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.14.0 - 09.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a connect or create symbol profile model logic on creating a new transaction
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the global heat map to visualize investments by country
|
||||
|
||||
## 1.13.0 - 08.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a global heat map to visualize investments by country
|
||||
|
||||
## 1.12.0 - 06.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a symbol profile model with additional data
|
||||
- Added new pie charts: Investments by continent and country
|
||||
|
||||
## 1.11.0 - 05.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a dedicated page for the account registration
|
||||
- Rendered the average buy prices in the position detail chart (useful for recurring transactions)
|
||||
- Introduced the initial prisma migration
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the buttons to links (`<a>`) on the tools page
|
||||
- Upgraded `prisma` from version `2.20.1` to `2.24.1`
|
||||
|
||||
## 1.10.1 - 02.06.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an optional type in the user interface
|
||||
|
||||
## 1.10.0 - 02.06.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the tools to a sub path (`/tools`)
|
||||
- Extended the pricing page and aligned with the subscription model
|
||||
|
||||
## 1.9.0 - 01.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the year labels to the investment chart on the x-axis
|
||||
|
||||
### Changed
|
||||
|
||||
- Respected the data source attribute of the transactions model in the data management for historical data
|
||||
- Prettified the generic scraper symbols in the transaction filtering component
|
||||
- Changed to the strict mode of distance formatting between two given dates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sorting in various tables
|
||||
- Made the order of the rules in the _X-ray_ section consistent
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
- Added the symbol logo to the position detail dialog
|
||||
- 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
|
||||
|
||||
### Added
|
||||
|
||||
- Added a prettifier (pipe) for generic scraper symbols
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the text truncation in buttons of the admin control panel
|
||||
|
||||
## 0.88.0 - 20.04.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Reverted the restoring of the scroll position when opening a new page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the frozen screen if the token has expired
|
||||
- Fixed some issues in the generic scraper
|
||||
|
||||
## 0.87.0 - 19.04.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a generic scraper
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the user table of the admin control panel with missing data
|
||||
|
||||
## 0.86.1 - 18.04.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the license to the about page
|
||||
- Added a validation for environment variables
|
||||
- Added support for feature flags to simplify the initial project setup
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the about page for the new license
|
||||
- Optimized the data management for historical data
|
||||
- Optimized the exchange rate service
|
||||
- Improved the user table of the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restored the scroll position when opening a new page
|
||||
|
||||
## 0.85.0 - 16.04.2021
|
||||
|
||||
### Changed
|
||||
@ -34,7 +352,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
|
||||
- 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
|
||||
|
||||
@ -107,7 +425,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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
|
||||
|
||||
@ -119,7 +437,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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
|
||||
|
||||
@ -132,7 +450,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- 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
|
||||
|
||||
@ -148,7 +466,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the intro text in the _X-Ray_ section
|
||||
- Improved the intro text in the _X-ray_ section
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -164,7 +482,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Added an intro text to the _X-Ray_ section
|
||||
- Added an intro text to the _X-ray_ section
|
||||
|
||||
### Changed
|
||||
|
||||
@ -183,7 +501,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling in the _X-Ray_ section
|
||||
- Improved the styling in the _X-ray_ section
|
||||
|
||||
## 0.70.0 - 27.03.2021
|
||||
|
||||
@ -191,7 +509,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Added the current _Fear & Greed Index_ as text
|
||||
- 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
|
||||
|
||||
|
617
LICENSE
Normal file
617
LICENSE
Normal file
@ -0,0 +1,617 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
84
README.md
84
README.md
@ -1,37 +1,91 @@
|
||||
<div align="center">
|
||||
<h1>Ghostfolio</h1>
|
||||
<p>
|
||||
<strong>Privacy-first Portfolio Tracker</strong>
|
||||
</p>
|
||||
<h1>Ghostfolio</h1>
|
||||
<p>
|
||||
<a href="https://www.ghostfol.io"><strong>Ghostfolio</strong></a>
|
||||
<strong>Open Source Portfolio Tracker</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
|
||||
<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">
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
|
||||
|
||||
## Why Ghostfolio?
|
||||
|
||||
Ghostfolio is for you if you are...
|
||||
|
||||
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
|
||||
|
||||
- 🏦 pursuing a buy & hold strategy
|
||||
|
||||
- 🎯 interested in getting insights of your portfolio composition
|
||||
|
||||
- 👻 valuing privacy and data ownership
|
||||
|
||||
- 🧘 into minimalism
|
||||
|
||||
- 🧺 caring about diversifying your financial resources
|
||||
|
||||
- 🆓 interested in financial independence
|
||||
|
||||
- 🙅 saying no to spreadsheets in 2021
|
||||
|
||||
- 😎 still reading this list
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Dark Mode
|
||||
- ✅ Zen Mode
|
||||
- ✅ Mobile-first design
|
||||
|
||||
## Technology Stack
|
||||
|
||||
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
|
||||
|
||||
### Backend
|
||||
|
||||
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database together with [Prisma](https://www.prisma.io) and [Redis](https://redis.io) for caching.
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
2. Run `cd docker`
|
||||
3. Run `docker compose build`
|
||||
4. Run `docker compose up -d`
|
||||
5. Run `yarn setup:database`
|
||||
6. Start server and client (see _Development_)
|
||||
7. Login as _Admin_ with the `Security Token`: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
1. Run `cd docker`
|
||||
1. 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
|
||||
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
||||
1. Start server and client (see [_Development_](#Development))
|
||||
1. 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
|
||||
1. Press _Sign out_ and check out the _Live Demo_
|
||||
|
||||
## Development
|
||||
|
||||
Please make sure you have completed the instructions from [_Setup_](#Setup)
|
||||
|
||||
### Start server
|
||||
|
||||
- Debug: Run `yarn watch:server` and click "Launch Program" in _Visual Studio Code_
|
||||
@ -44,3 +98,9 @@
|
||||
## Testing
|
||||
|
||||
Run `yarn test`
|
||||
|
||||
## License
|
||||
|
||||
© 2021 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
17
angular.json
17
angular.json
@ -99,6 +99,11 @@
|
||||
"input": "",
|
||||
"output": "./"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
@ -203,22 +208,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"helper": {
|
||||
"root": "libs/helper",
|
||||
"sourceRoot": "libs/helper/src",
|
||||
"common": {
|
||||
"root": "libs/common",
|
||||
"sourceRoot": "libs/common/src",
|
||||
"projectType": "library",
|
||||
"architect": {
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/helper/**/*.ts"]
|
||||
"lintFilePatterns": ["libs/common/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/helper"],
|
||||
"outputs": ["coverage/libs/common"],
|
||||
"options": {
|
||||
"jestConfig": "libs/helper/jest.config.js",
|
||||
"jestConfig": "libs/common/jest.config.js",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
|
||||
|
||||
import { AccessService } from './access.service';
|
||||
import { Access } from './interfaces/access.interface';
|
||||
|
||||
@Controller('access')
|
||||
export class AccessController {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { AccessController } from './access.controller';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { AccessWithGranteeUser } from './interfaces/access-with-grantee-user.type';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
|
241
apps/api/src/app/account/account.controller.ts
Normal file
241
apps/api/src/app/account/account.controller.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
30
apps/api/src/app/account/account.module.ts
Normal file
30
apps/api/src/app/account/account.module.ts
Normal 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 {}
|
89
apps/api/src/app/account/account.service.ts
Normal file
89
apps/api/src/app/account/account.service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
14
apps/api/src/app/account/create-account.dto.ts
Normal file
14
apps/api/src/app/account/create-account.dto.ts
Normal 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;
|
||||
}
|
17
apps/api/src/app/account/update-account.dto.ts
Normal file
17
apps/api/src/app/account/update-account.dto.ts
Normal 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;
|
||||
}
|
@ -1,3 +1,11 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -8,13 +16,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
|
||||
|
||||
import { DataGatheringService } from '../../services/data-gathering.service';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminData } from './interfaces/admin-data.interface';
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DataGatheringService } from '../../services/data-gathering.service';
|
||||
import { DataProviderService } from '../../services/data-provider.service';
|
||||
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.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 { PrismaService } from '../../services/prisma.service';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@ -16,9 +18,11 @@ import { AdminService } from './admin.service';
|
||||
providers: [
|
||||
AdminService,
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { AdminData } from './interfaces/admin-data.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
public constructor(
|
||||
@ -14,7 +13,6 @@ export class AdminService {
|
||||
|
||||
public async get(): Promise<AdminData> {
|
||||
return {
|
||||
analytics: await this.getUserAnalytics(),
|
||||
exchangeRates: [
|
||||
{
|
||||
label1: Currency.EUR,
|
||||
@ -64,7 +62,8 @@ export class AdminService {
|
||||
],
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
transactionCount: await this.prisma.order.count(),
|
||||
userCount: await this.prisma.user.count()
|
||||
userCount: await this.prisma.user.count(),
|
||||
users: await this.getUsersWithAnalytics()
|
||||
};
|
||||
}
|
||||
|
||||
@ -88,21 +87,33 @@ export class AdminService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getUserAnalytics() {
|
||||
return await this.prisma.analytics.findMany({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: {
|
||||
activityCount: true,
|
||||
updatedAt: true,
|
||||
User: {
|
||||
select: {
|
||||
alias: true,
|
||||
createdAt: true,
|
||||
id: true
|
||||
}
|
||||
private async getUsersWithAnalytics() {
|
||||
return await this.prisma.user.findMany({
|
||||
orderBy: {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
},
|
||||
take: 20
|
||||
select: {
|
||||
_count: {
|
||||
select: { Account: true, Order: true }
|
||||
},
|
||||
alias: true,
|
||||
Analytics: {
|
||||
select: {
|
||||
activityCount: true,
|
||||
updatedAt: true
|
||||
}
|
||||
},
|
||||
createdAt: true,
|
||||
id: true
|
||||
},
|
||||
take: 30,
|
||||
where: {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -5,15 +5,18 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
|
||||
import { ConfigurationService } from '../services/configuration.service';
|
||||
import { CronService } from '../services/cron.service';
|
||||
import { DataGatheringService } from '../services/data-gathering.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 { PrismaService } from '../services/prisma.service';
|
||||
import { AccessModule } from './access/access.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
@ -30,6 +33,7 @@ import { UserModule } from './user/user.module';
|
||||
imports: [
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
AccountModule,
|
||||
AuthModule,
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
@ -59,10 +63,12 @@ import { UserModule } from './user/user.module';
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
CronService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -14,7 +15,10 @@ import { AuthService } from './auth.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
public constructor(private readonly authService: AuthService) {}
|
||||
public constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
@Get('anonymous/:accessToken')
|
||||
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
|
||||
@ -44,9 +48,9 @@ export class AuthController {
|
||||
const jwt: string = req.user.jwt;
|
||||
|
||||
if (jwt) {
|
||||
res.redirect(`${process.env.ROOT_URL}/auth/${jwt}`);
|
||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`);
|
||||
} else {
|
||||
res.redirect(`${process.env.ROOT_URL}/auth`);
|
||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
@ -18,6 +19,7 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
ConfigurationService,
|
||||
GoogleStrategy,
|
||||
JwtStrategy,
|
||||
PrismaService,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
@ -7,7 +8,8 @@ import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
public constructor(
|
||||
private jwtService: JwtService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@ -16,7 +18,7 @@ export class AuthService {
|
||||
try {
|
||||
const hashedAccessToken = this.userService.createAccessToken(
|
||||
accessToken,
|
||||
process.env.ACCESS_TOKEN_SALT
|
||||
this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
);
|
||||
|
||||
const [user] = await this.userService.users({
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
@ -7,11 +8,16 @@ import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
public constructor(private readonly authService: AuthService) {
|
||||
public constructor(
|
||||
private readonly authService: AuthService,
|
||||
readonly configurationService: ConfigurationService
|
||||
) {
|
||||
super({
|
||||
callbackURL: `${process.env.ROOT_URL}/api/auth/google/callback`,
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_SECRET,
|
||||
callbackURL: `${configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/api/auth/google/callback`,
|
||||
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
||||
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
||||
passReqToCallback: true,
|
||||
scope: ['email', 'profile']
|
||||
});
|
||||
|
@ -1,19 +1,21 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
public constructor(
|
||||
readonly configurationService: ConfigurationService,
|
||||
private prisma: PrismaService,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: process.env.JWT_SECRET_KEY
|
||||
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
||||
});
|
||||
}
|
||||
|
||||
|
4
apps/api/src/app/cache/cache.controller.ts
vendored
4
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,7 +1,7 @@
|
||||
import { Controller, Inject, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
|
||||
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
import { CacheService } from './cache.service';
|
||||
|
2
apps/api/src/app/cache/cache.module.ts
vendored
2
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,6 +1,6 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { CacheController } from './cache.controller';
|
||||
import { CacheService } from './cache.service';
|
||||
|
4
apps/api/src/app/cache/cache.service.ts
vendored
4
apps/api/src/app/cache/cache.service.ts
vendored
@ -1,7 +1,5 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, User } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
|
@ -1,3 +1,6 @@
|
||||
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
||||
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -9,11 +12,8 @@ import {
|
||||
Post
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
|
||||
import { parse } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { baseCurrency, benchmarks } from 'libs/helper/src';
|
||||
import { isApiTokenAuthorized } from 'libs/helper/src';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { ExperimentalService } from './experimental.service';
|
||||
@ -37,7 +37,9 @@ export class ExperimentalController {
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
return benchmarks.map(({ symbol }) => {
|
||||
return symbol;
|
||||
});
|
||||
}
|
||||
|
||||
@Get('benchmarks/:symbol')
|
||||
|
@ -1,12 +1,14 @@
|
||||
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DataProviderService } from '../../services/data-provider.service';
|
||||
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.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 { PrismaService } from '../../services/prisma.service';
|
||||
import { RulesService } from '../../services/rules.service';
|
||||
import { ExperimentalController } from './experimental.controller';
|
||||
import { ExperimentalService } from './experimental.service';
|
||||
|
||||
@ -15,9 +17,11 @@ import { ExperimentalService } from './experimental.service';
|
||||
controllers: [ExperimentalController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
ExperimentalService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
RulesService,
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { Portfolio } from '@ghostfolio/api/models/portfolio';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, Type } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
import { Portfolio } from '../../models/portfolio';
|
||||
import { DataProviderService } from '../../services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { RulesService } from '../../services/rules.service';
|
||||
import { OrderWithPlatform } from '../order/interfaces/order-with-platform.type';
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Data } from './interfaces/data.interface';
|
||||
|
||||
@ -33,14 +33,18 @@ export class ExperimentalService {
|
||||
aDate: Date,
|
||||
aBaseCurrency: Currency
|
||||
): Promise<Data> {
|
||||
const ordersWithPlatform: OrderWithPlatform[] = aOrders.map((order) => {
|
||||
const ordersWithPlatform: OrderWithAccount[] = aOrders.map((order) => {
|
||||
return {
|
||||
...order,
|
||||
accountId: undefined,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
dataSource: undefined,
|
||||
date: parseISO(order.date),
|
||||
fee: 0,
|
||||
id: undefined,
|
||||
platformId: undefined,
|
||||
symbolProfileId: undefined,
|
||||
type: Type.BUY,
|
||||
updatedAt: undefined,
|
||||
userId: undefined
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
import { InfoService } from './info.service';
|
||||
import { InfoItem } from './interfaces/info-item.interface';
|
||||
|
||||
@Controller('info')
|
||||
export class InfoController {
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { InfoController } from './info.controller';
|
||||
import { InfoService } from './info.service';
|
||||
|
||||
@ -13,6 +14,6 @@ import { InfoService } from './info.service';
|
||||
})
|
||||
],
|
||||
controllers: [InfoController],
|
||||
providers: [InfoService, PrismaService]
|
||||
providers: [ConfigurationService, InfoService, PrismaService]
|
||||
})
|
||||
export class InfoModule {}
|
||||
|
@ -1,15 +1,17 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { InfoItem } from './interfaces/info-item.interface';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private jwtService: JwtService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
@ -20,7 +22,18 @@ export class InfoService {
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
|
||||
const globalPermissions: string[] = [];
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
||||
globalPermissions.push(permissions.enableSocialLogin);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
globalPermissions.push(permissions.enableSubscription);
|
||||
}
|
||||
|
||||
return {
|
||||
globalPermissions,
|
||||
platforms,
|
||||
currencies: Object.values(Currency),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
|
@ -1,3 +0,0 @@
|
||||
import { Settings, User } from '@prisma/client';
|
||||
|
||||
export type UserWithSettings = User & { Settings: Settings };
|
@ -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';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
|
||||
@IsString()
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
|
||||
@IsNumber()
|
||||
fee: number;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
|
||||
@IsNumber()
|
||||
quantity: number;
|
||||
|
||||
|
@ -1,3 +0,0 @@
|
||||
import { Order, Platform } from '@prisma/client';
|
||||
|
||||
export type OrderWithPlatform = Order & { Platform?: Platform };
|
@ -1,3 +1,11 @@
|
||||
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,
|
||||
@ -14,13 +22,9 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Order as OrderModel } from '@prisma/client';
|
||||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
|
||||
|
||||
import { nullifyValuesInObjects } from '../../helper/object.helper';
|
||||
import { ImpersonationService } from '../../services/impersonation.service';
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { OrderService } from './order.service';
|
||||
import { UpdateOrderDto } from './update-order.dto';
|
||||
@ -71,7 +75,11 @@ export class OrderController {
|
||||
|
||||
let orders = await this.orderService.orders({
|
||||
include: {
|
||||
Platform: true
|
||||
Account: {
|
||||
include: {
|
||||
Platform: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId: impersonationUserId || this.request.user.id }
|
||||
@ -118,31 +126,36 @@ export class OrderController {
|
||||
|
||||
const date = parseISO(data.date);
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
const accountId = data.accountId;
|
||||
delete data.accountId;
|
||||
|
||||
return this.orderService.createOrder(
|
||||
{
|
||||
...data,
|
||||
date,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
return this.orderService.createOrder(
|
||||
{
|
||||
...data,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
} else {
|
||||
delete data.platformId;
|
||||
|
||||
return this.orderService.createOrder(
|
||||
{
|
||||
...data,
|
||||
date,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
date,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
},
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ -167,52 +180,38 @@ export class OrderController {
|
||||
}
|
||||
});
|
||||
|
||||
const date = parseISO(data.date);
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
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
|
||||
if (!originalOrder) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { DataGatheringService } from '../../services/data-gathering.service';
|
||||
import { DataProviderService } from '../../services/data-provider.service';
|
||||
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.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 { ImpersonationService } from '../../services/impersonation.service';
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { OrderController } from './order.controller';
|
||||
@ -18,8 +20,10 @@ import { OrderService } from './order.service';
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
OrderService,
|
||||
PrismaService,
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Order, Prisma } from '@prisma/client';
|
||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
||||
|
||||
import { DataGatheringService } from '../../services/data-gathering.service';
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
import { OrderWithPlatform } from './interfaces/order-with-platform.type';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
@ -31,7 +31,7 @@ export class OrderService {
|
||||
cursor?: Prisma.OrderWhereUniqueInput;
|
||||
where?: Prisma.OrderWhereInput;
|
||||
orderBy?: Prisma.OrderOrderByInput;
|
||||
}): Promise<OrderWithPlatform[]> {
|
||||
}): Promise<OrderWithAccount[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prisma.order.findMany({
|
||||
@ -53,6 +53,7 @@ export class OrderService {
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.symbol
|
||||
}
|
||||
@ -90,6 +91,7 @@ export class OrderService {
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: <DataSource>data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: <string>data.symbol
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
export class UpdateOrderDto {
|
||||
@IsString()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
|
||||
@IsString()
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
|
||||
@IsNumber()
|
||||
fee: number;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
currency: string;
|
||||
currency: Currency;
|
||||
firstBuyDate: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
@ -11,6 +13,7 @@ export interface PortfolioPositionDetail {
|
||||
minPrice: number;
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
transactionCount: number;
|
||||
}
|
||||
|
||||
export interface HistoricalDataItem {
|
||||
|
@ -1,3 +1,22 @@
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
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 {
|
||||
Controller,
|
||||
Get,
|
||||
@ -13,24 +32,11 @@ import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
|
||||
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
} from '../../helper/object.helper';
|
||||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '../../services/impersonation.service';
|
||||
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 {
|
||||
HistoricalDataItem,
|
||||
PortfolioPositionDetail
|
||||
} 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';
|
||||
|
||||
@Controller('portfolio')
|
||||
@ -185,11 +191,11 @@ export class PortfolioController {
|
||||
portfolioPosition.investment =
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
|
||||
for (const [platform, { current, original }] of Object.entries(
|
||||
portfolioPosition.platforms
|
||||
for (const [account, { current, original }] of Object.entries(
|
||||
portfolioPosition.accounts
|
||||
)) {
|
||||
portfolioPosition.platforms[platform].current = current / totalValue;
|
||||
portfolioPosition.platforms[platform].original =
|
||||
portfolioPosition.accounts[account].current = current / totalValue;
|
||||
portfolioPosition.accounts[account].original =
|
||||
original / totalInvestment;
|
||||
}
|
||||
|
||||
@ -309,18 +315,6 @@ export class PortfolioController {
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
let report = await portfolio.getReport();
|
||||
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
) {
|
||||
// TODO: Filter out absolute numbers
|
||||
}
|
||||
|
||||
return report;
|
||||
return await portfolio.getReport();
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DataGatheringService } from '../../services/data-gathering.service';
|
||||
import { DataProviderService } from '../../services/data-provider.service';
|
||||
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.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 { ImpersonationService } from '../../services/impersonation.service';
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { RulesService } from '../../services/rules.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { OrderService } from '../order/order.service';
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
@ -22,9 +24,11 @@ import { PortfolioService } from './portfolio.service';
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
OrderService,
|
||||
PortfolioService,
|
||||
|
@ -1,6 +1,17 @@
|
||||
import { Portfolio } from '@ghostfolio/api/models/portfolio';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
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 { REQUEST } from '@nestjs/core';
|
||||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
add,
|
||||
format,
|
||||
@ -18,18 +29,9 @@ import {
|
||||
import { isEmpty } from 'lodash';
|
||||
import * as roundTo from 'round-to';
|
||||
|
||||
import { Portfolio } from '../../models/portfolio';
|
||||
import { DataProviderService } from '../../services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '../../services/impersonation.service';
|
||||
import { IOrder } from '../../services/interfaces/interfaces';
|
||||
import { RulesService } from '../../services/rules.service';
|
||||
import { OrderService } from '../order/order.service';
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.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 {
|
||||
HistoricalDataItem,
|
||||
PortfolioPositionDetail
|
||||
@ -48,53 +50,6 @@ export class PortfolioService {
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
|
||||
let currentDate = new Date();
|
||||
|
||||
const normalizedMinDate =
|
||||
getDate(aMinDate) === 1
|
||||
? aMinDate
|
||||
: add(setDate(aMinDate, 1), { months: 1 });
|
||||
|
||||
const year = getYear(currentDate);
|
||||
const month = getMonth(currentDate);
|
||||
const day = getDate(currentDate);
|
||||
|
||||
currentDate = new Date(Date.UTC(year, month, day, 0));
|
||||
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
return sub(currentDate, {
|
||||
days: 1
|
||||
});
|
||||
case 'ytd':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = setMonth(currentDate, 0);
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
case '1y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 1
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
case '5y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 5
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
default:
|
||||
// Gets handled as all data
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public async createPortfolio(aUserId: string): Promise<Portfolio> {
|
||||
let portfolio: Portfolio;
|
||||
let stringifiedPortfolio = await this.redisCacheService.get(
|
||||
@ -121,7 +76,8 @@ export class PortfolioService {
|
||||
// Get portfolio from database
|
||||
const orders = await this.orderService.orders({
|
||||
include: {
|
||||
Platform: true
|
||||
Account: true,
|
||||
SymbolProfile: true
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
where: { userId: aUserId }
|
||||
@ -205,8 +161,8 @@ export class PortfolioService {
|
||||
return {
|
||||
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
|
||||
grossPerformancePercent: portfolioItem.grossPerformancePercent,
|
||||
marketPrice: portfolioItem.value || null,
|
||||
value: portfolioItem.value || null
|
||||
marketPrice: portfolioItem.value ?? null,
|
||||
value: portfolioItem.value - portfolioItem.investment ?? null
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -257,9 +213,12 @@ export class PortfolioService {
|
||||
firstBuyDate,
|
||||
investment,
|
||||
marketPrice,
|
||||
quantity
|
||||
quantity,
|
||||
transactionCount
|
||||
} = portfolio.getPositions(new Date())[aSymbol];
|
||||
|
||||
const orders = portfolio.getOrders(aSymbol);
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistorical(
|
||||
[aSymbol],
|
||||
'day',
|
||||
@ -272,6 +231,7 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const historicalDataArray: HistoricalDataItem[] = [];
|
||||
let currentAveragePrice: number;
|
||||
let maxPrice = marketPrice;
|
||||
let minPrice = marketPrice;
|
||||
|
||||
@ -279,9 +239,24 @@ export class PortfolioService {
|
||||
for (const [date, { marketPrice }] of Object.entries(
|
||||
historicalData[aSymbol]
|
||||
)) {
|
||||
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
|
||||
if (
|
||||
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
|
||||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
|
||||
) {
|
||||
// Get snapshot of first day of month
|
||||
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
|
||||
.positions[aSymbol];
|
||||
orders.shift();
|
||||
|
||||
if (snapshot?.averagePrice) {
|
||||
currentAveragePrice = snapshot?.averagePrice;
|
||||
}
|
||||
}
|
||||
|
||||
historicalDataArray.push({
|
||||
averagePrice,
|
||||
date,
|
||||
averagePrice: currentAveragePrice,
|
||||
value: marketPrice
|
||||
});
|
||||
|
||||
@ -310,6 +285,7 @@ export class PortfolioService {
|
||||
maxPrice,
|
||||
minPrice,
|
||||
quantity,
|
||||
transactionCount,
|
||||
grossPerformance: this.exchangeRateDataService.toCurrency(
|
||||
marketPrice - averagePrice,
|
||||
currency,
|
||||
@ -334,7 +310,7 @@ export class PortfolioService {
|
||||
|
||||
if (isEmpty(historicalData)) {
|
||||
historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[aSymbol],
|
||||
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
||||
portfolio.getMinDate(),
|
||||
new Date()
|
||||
);
|
||||
@ -363,7 +339,8 @@ export class PortfolioService {
|
||||
maxPrice: undefined,
|
||||
minPrice: undefined,
|
||||
quantity: undefined,
|
||||
symbol: aSymbol
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined
|
||||
};
|
||||
}
|
||||
|
||||
@ -379,7 +356,55 @@ export class PortfolioService {
|
||||
maxPrice: undefined,
|
||||
minPrice: undefined,
|
||||
quantity: undefined,
|
||||
symbol: aSymbol
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined
|
||||
};
|
||||
}
|
||||
|
||||
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
|
||||
let currentDate = new Date();
|
||||
|
||||
const normalizedMinDate =
|
||||
getDate(aMinDate) === 1
|
||||
? aMinDate
|
||||
: add(setDate(aMinDate, 1), { months: 1 });
|
||||
|
||||
const year = getYear(currentDate);
|
||||
const month = getMonth(currentDate);
|
||||
const day = getDate(currentDate);
|
||||
|
||||
currentDate = new Date(Date.UTC(year, month, day, 0));
|
||||
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
return sub(currentDate, {
|
||||
days: 1
|
||||
});
|
||||
case 'ytd':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = setMonth(currentDate, 0);
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
case '1y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 1
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
case '5y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 5
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
default:
|
||||
// Gets handled as all data
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { CacheModule, Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import * as redisStore from 'cache-manager-redis-store';
|
||||
@ -9,16 +10,16 @@ import { RedisCacheService } from './redis-cache.service';
|
||||
CacheModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
host: configService.get('REDIS_HOST'),
|
||||
max: configService.get('MAX_ITEM_IN_CACHE'),
|
||||
port: configService.get('REDIS_PORT'),
|
||||
useFactory: async (configurationService: ConfigurationService) => ({
|
||||
host: configurationService.get('REDIS_HOST'),
|
||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||
port: configurationService.get('REDIS_PORT'),
|
||||
store: redisStore,
|
||||
ttl: configService.get('CACHE_TTL')
|
||||
ttl: configurationService.get('CACHE_TTL')
|
||||
})
|
||||
})
|
||||
],
|
||||
providers: [RedisCacheService],
|
||||
providers: [ConfigurationService, RedisCacheService],
|
||||
exports: [RedisCacheService]
|
||||
})
|
||||
export class RedisCacheModule {}
|
||||
|
@ -1,9 +1,13 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
||||
import { Cache } from 'cache-manager';
|
||||
|
||||
@Injectable()
|
||||
export class RedisCacheService {
|
||||
public constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
|
||||
public constructor(
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public async get(key: string): Promise<string> {
|
||||
return await this.cache.get(key);
|
||||
@ -18,6 +22,8 @@ export class RedisCacheService {
|
||||
}
|
||||
|
||||
public async set(key: string, value: string) {
|
||||
await this.cache.set(key, value, { ttl: Number(process.env.CACHE_TTL) });
|
||||
await this.cache.set(key, value, {
|
||||
ttl: this.configurationService.get('CACHE_TTL')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface LookupItem {
|
||||
dataSource: DataSource;
|
||||
name: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
|
||||
export interface SymbolItem {
|
||||
currency: Currency;
|
||||
dataSource: DataSource;
|
||||
marketPrice: number;
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -9,7 +10,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
@ -28,9 +28,12 @@ export class SymbolController {
|
||||
*/
|
||||
@Get('lookup')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async lookupSymbol(@Query() { query }): Promise<LookupItem[]> {
|
||||
public async lookupSymbol(
|
||||
@Query() { query = '' }
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
try {
|
||||
return this.symbolService.lookup(query);
|
||||
const encodedQuery = encodeURIComponent(query.toLowerCase());
|
||||
return this.symbolService.lookup(encodedQuery);
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
|
@ -1,10 +1,12 @@
|
||||
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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DataProviderService } from '../../services/data-provider.service';
|
||||
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.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 { PrismaService } from '../../services/prisma.service';
|
||||
import { SymbolController } from './symbol.controller';
|
||||
import { SymbolService } from './symbol.service';
|
||||
|
||||
@ -13,7 +15,9 @@ import { SymbolService } from './symbol.service';
|
||||
controllers: [SymbolController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
SymbolService,
|
||||
|
@ -1,64 +1,54 @@
|
||||
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 { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { convertFromYahooSymbol } from 'apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import * as bent from 'bent';
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
|
||||
import { DataProviderService } from '../../services/data-provider.service';
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SymbolService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService
|
||||
) {}
|
||||
|
||||
public async get(aSymbol: string): Promise<SymbolItem> {
|
||||
const response = await this.dataProviderService.get([aSymbol]);
|
||||
const { currency, marketPrice } = response[aSymbol];
|
||||
const { currency, dataSource, marketPrice } = response[aSymbol];
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
marketPrice,
|
||||
currency: <Currency>(<unknown>currency)
|
||||
};
|
||||
}
|
||||
|
||||
public async lookup(aQuery: string): Promise<LookupItem[]> {
|
||||
const get = bent(
|
||||
`https://query1.finance.yahoo.com/v1/finance/search?q=${aQuery}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const results: { items: LookupItem[] } = { items: [] };
|
||||
|
||||
if (!aQuery) {
|
||||
return results;
|
||||
}
|
||||
|
||||
try {
|
||||
const { quotes } = await get();
|
||||
const { items } = await this.dataProviderService.search(aQuery);
|
||||
results.items = items;
|
||||
|
||||
return quotes
|
||||
.filter(({ isYahooFinance }) => {
|
||||
return 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');
|
||||
}
|
||||
// Add custom symbols
|
||||
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
|
||||
scraperConfigurations.forEach((scraperConfiguration) => {
|
||||
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
|
||||
results.items.push({
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
name: scraperConfiguration.name,
|
||||
symbol: scraperConfiguration.symbol
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(({ longname, shortname, symbol }) => {
|
||||
return {
|
||||
name: longname || shortname,
|
||||
symbol: convertFromYahooSymbol(symbol)
|
||||
};
|
||||
});
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
|
@ -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: 'Diamond';
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
baseCurrency: Currency;
|
||||
locale: string;
|
||||
}
|
@ -1,7 +1,10 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingsDto {
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
baseCurrency: Currency;
|
||||
|
||||
@IsString()
|
||||
viewMode: ViewMode;
|
||||
}
|
||||
|
@ -1,6 +1,14 @@
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
@ -13,12 +21,10 @@ import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
|
||||
import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
|
||||
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
import { User } from './interfaces/user.interface';
|
||||
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ -30,6 +36,27 @@ export class UserController {
|
||||
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()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getUser(@Param('id') id: string): Promise<User> {
|
||||
@ -66,8 +93,9 @@ export class UserController {
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSettings({
|
||||
currency: data.currency,
|
||||
userId: this.request.user.id
|
||||
currency: data.baseCurrency,
|
||||
userId: this.request.user.id,
|
||||
viewMode: data.viewMode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ -13,6 +14,6 @@ import { UserService } from './user.service';
|
||||
})
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [PrismaService, UserService]
|
||||
providers: [ConfigurationService, PrismaService, UserService]
|
||||
})
|
||||
export class UserModule {}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { locale } from '@ghostfolio/common/config';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, Prisma, Provider, User } from '@prisma/client';
|
||||
import { add } from 'date-fns';
|
||||
import { locale, resetHours } from 'libs/helper/src';
|
||||
import { getPermissions } from 'libs/helper/src';
|
||||
|
||||
import { PrismaService } from '../../services/prisma.service';
|
||||
import { UserWithSettings } from '../interfaces/user-with-settings';
|
||||
import { User as IUser } from './interfaces/user.interface';
|
||||
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
import { add, isBefore } from 'date-fns';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@ -14,13 +15,18 @@ const crypto = require('crypto');
|
||||
export class UserService {
|
||||
public static DEFAULT_CURRENCY = Currency.USD;
|
||||
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
public async getUser({
|
||||
Account,
|
||||
alias,
|
||||
id,
|
||||
role,
|
||||
Settings
|
||||
Settings,
|
||||
subscription
|
||||
}: UserWithSettings): Promise<IUser> {
|
||||
const access = await this.prisma.access.findMany({
|
||||
include: {
|
||||
@ -30,23 +36,28 @@ export class UserService {
|
||||
where: { GranteeUser: { id } }
|
||||
});
|
||||
|
||||
const currentPermissions = getPermissions(role);
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
||||
}
|
||||
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
subscription,
|
||||
access: access.map((accessItem) => {
|
||||
return {
|
||||
alias: accessItem.User.alias,
|
||||
id: accessItem.id
|
||||
};
|
||||
}),
|
||||
permissions: getPermissions(role),
|
||||
accounts: Account,
|
||||
permissions: currentPermissions,
|
||||
settings: {
|
||||
baseCurrency: Settings?.currency || UserService.DEFAULT_CURRENCY,
|
||||
locale
|
||||
},
|
||||
subscription: {
|
||||
expiresAt: resetHours(add(new Date(), { days: 7 })),
|
||||
type: 'Diamond'
|
||||
locale,
|
||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -54,25 +65,49 @@ export class UserService {
|
||||
public async user(
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||
): Promise<UserWithSettings | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
include: { Settings: true },
|
||||
const userFromDatabase = await this.prisma.user.findUnique({
|
||||
include: { Account: true, Settings: true, Subscription: true },
|
||||
where: userWhereUniqueInput
|
||||
});
|
||||
|
||||
if (user?.Settings) {
|
||||
if (!user.Settings.currency) {
|
||||
const user: UserWithSettings = userFromDatabase;
|
||||
|
||||
if (userFromDatabase?.Settings) {
|
||||
if (!userFromDatabase.Settings.currency) {
|
||||
// Set default currency if needed
|
||||
user.Settings.currency = UserService.DEFAULT_CURRENCY;
|
||||
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
|
||||
}
|
||||
} else if (user) {
|
||||
} else if (userFromDatabase) {
|
||||
// Set default settings if needed
|
||||
user.Settings = {
|
||||
userFromDatabase.Settings = {
|
||||
currency: UserService.DEFAULT_CURRENCY,
|
||||
updatedAt: new Date(),
|
||||
userId: user?.id
|
||||
userId: userFromDatabase?.id,
|
||||
viewMode: ViewMode.DEFAULT
|
||||
};
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (userFromDatabase?.Subscription?.length > 0) {
|
||||
const latestSubscription = userFromDatabase.Subscription.reduce(
|
||||
(a, b) => {
|
||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||
}
|
||||
);
|
||||
|
||||
user.subscription = {
|
||||
expiresAt: latestSubscription.expiresAt,
|
||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||
? SubscriptionType.Premium
|
||||
: SubscriptionType.Basic
|
||||
};
|
||||
} else {
|
||||
user.subscription = {
|
||||
type: SubscriptionType.Basic
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@ -102,7 +137,15 @@ export class UserService {
|
||||
|
||||
public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
|
||||
let user = await this.prisma.user.create({
|
||||
data
|
||||
data: {
|
||||
...data,
|
||||
Account: {
|
||||
create: {
|
||||
isDefault: true,
|
||||
name: 'Default Account'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (data.provider === Provider.ANONYMOUS) {
|
||||
@ -139,11 +182,64 @@ export class UserService {
|
||||
}
|
||||
|
||||
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({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async updateUserSettings({
|
||||
currency,
|
||||
userId,
|
||||
viewMode
|
||||
}: {
|
||||
currency?: Currency;
|
||||
userId: string;
|
||||
viewMode?: ViewMode;
|
||||
}) {
|
||||
await this.prisma.settings.upsert({
|
||||
create: {
|
||||
currency,
|
||||
User: {
|
||||
connect: {
|
||||
id: userId
|
||||
}
|
||||
},
|
||||
viewMode
|
||||
},
|
||||
update: {
|
||||
currency,
|
||||
viewMode
|
||||
},
|
||||
where: {
|
||||
userId: userId
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private getRandomString(length: number) {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const result = [];
|
||||
@ -155,31 +251,4 @@ export class UserService {
|
||||
}
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
public async updateUserSettings({
|
||||
currency,
|
||||
userId
|
||||
}: {
|
||||
currency: Currency;
|
||||
userId: string;
|
||||
}) {
|
||||
await this.prisma.settings.upsert({
|
||||
create: {
|
||||
currency,
|
||||
User: {
|
||||
connect: {
|
||||
id: userId
|
||||
}
|
||||
}
|
||||
},
|
||||
update: {
|
||||
currency
|
||||
},
|
||||
where: {
|
||||
userId: userId
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,4 @@
|
||||
import {
|
||||
PortfolioItem,
|
||||
Position
|
||||
} from 'apps/api/src/app/portfolio/interfaces/portfolio-item.interface';
|
||||
import { PortfolioItem, Position } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Order } from '../order';
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { PortfolioPosition } from '../../app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { EvaluationResult } from './evaluation-result.interface';
|
||||
|
||||
export interface RuleInterface {
|
||||
|
@ -1,35 +1,41 @@
|
||||
import { Currency, Platform } from '@prisma/client';
|
||||
import { Account, Currency, Platform, SymbolProfile } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
import { OrderType } from './order-type';
|
||||
|
||||
export class Order {
|
||||
private account: Account;
|
||||
private currency: Currency;
|
||||
private fee: number;
|
||||
private date: string;
|
||||
private id: string;
|
||||
private quantity: number;
|
||||
private platform: Platform;
|
||||
private symbol: string;
|
||||
private symbolProfile: SymbolProfile;
|
||||
private total: number;
|
||||
private type: OrderType;
|
||||
private unitPrice: number;
|
||||
|
||||
public constructor(data: IOrder) {
|
||||
this.account = data.account;
|
||||
this.currency = data.currency;
|
||||
this.fee = data.fee;
|
||||
this.date = data.date;
|
||||
this.id = data.id || uuidv4();
|
||||
this.platform = data.platform;
|
||||
this.quantity = data.quantity;
|
||||
this.symbol = data.symbol;
|
||||
this.symbolProfile = data.symbolProfile;
|
||||
this.type = data.type;
|
||||
this.unitPrice = data.unitPrice;
|
||||
|
||||
this.total = this.quantity * data.unitPrice;
|
||||
}
|
||||
|
||||
public getAccount() {
|
||||
return this.account;
|
||||
}
|
||||
|
||||
public getCurrency() {
|
||||
return this.currency;
|
||||
}
|
||||
@ -46,10 +52,6 @@ export class Order {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public getPlatform() {
|
||||
return this.platform;
|
||||
}
|
||||
|
||||
public getQuantity() {
|
||||
return this.quantity;
|
||||
}
|
||||
@ -58,6 +60,10 @@ export class Order {
|
||||
return this.symbol;
|
||||
}
|
||||
|
||||
getSymbolProfile() {
|
||||
return this.symbolProfile;
|
||||
}
|
||||
|
||||
public getTotal() {
|
||||
return this.total;
|
||||
}
|
||||
|
@ -1,53 +1,102 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Currency, Role, Type } from '@prisma/client';
|
||||
import { baseCurrency } from 'libs/helper/src';
|
||||
import { getYesterday } from 'libs/helper/src';
|
||||
import { getUtc } from 'libs/helper/src';
|
||||
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AccountType,
|
||||
Currency,
|
||||
DataSource,
|
||||
Role,
|
||||
Type,
|
||||
ViewMode
|
||||
} from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { DataProviderService } from '../services/data-provider.service';
|
||||
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.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 { PrismaService } from '../services/prisma.service';
|
||||
import { MarketState } from '../services/interfaces/interfaces';
|
||||
import { RulesService } from '../services/rules.service';
|
||||
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', () => {
|
||||
let alphaVantageService: AlphaVantageService;
|
||||
let dataProviderService: DataProviderService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let portfolio: Portfolio;
|
||||
let prismaService: PrismaService;
|
||||
let rakutenRapidApiService: RakutenRapidApiService;
|
||||
let rulesService: RulesService;
|
||||
let yahooFinanceService: YahooFinanceService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = await Test.createTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
RulesService,
|
||||
YahooFinanceService
|
||||
]
|
||||
}).compile();
|
||||
|
||||
alphaVantageService = app.get<AlphaVantageService>(AlphaVantageService);
|
||||
dataProviderService = app.get<DataProviderService>(DataProviderService);
|
||||
exchangeRateDataService = app.get<ExchangeRateDataService>(
|
||||
ExchangeRateDataService
|
||||
dataProviderService = new DataProviderService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
prismaService = app.get<PrismaService>(PrismaService);
|
||||
rakutenRapidApiService = app.get<RakutenRapidApiService>(
|
||||
RakutenRapidApiService
|
||||
);
|
||||
rulesService = app.get<RulesService>(RulesService);
|
||||
yahooFinanceService = app.get<YahooFinanceService>(YahooFinanceService);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null);
|
||||
rulesService = new RulesService();
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
|
||||
@ -58,15 +107,28 @@ describe('Portfolio', () => {
|
||||
);
|
||||
portfolio.setUser({
|
||||
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',
|
||||
createdAt: new Date(),
|
||||
id: '',
|
||||
id: USER_ID,
|
||||
provider: null,
|
||||
role: Role.USER,
|
||||
Settings: {
|
||||
currency: Currency.CHF,
|
||||
updatedAt: new Date(),
|
||||
userId: ''
|
||||
userId: USER_ID,
|
||||
viewMode: ViewMode.DEFAULT
|
||||
},
|
||||
thirdPartyId: null,
|
||||
updatedAt: new Date()
|
||||
@ -117,18 +179,21 @@ describe('Portfolio', () => {
|
||||
it('should return ["BTC"]', async () => {
|
||||
await portfolio.setOrders([
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 0,
|
||||
date: new Date(),
|
||||
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
|
||||
platformId: null,
|
||||
quantity: 1,
|
||||
symbol: 'BTCUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 49631.24,
|
||||
updatedAt: null,
|
||||
userId: null
|
||||
userId: USER_ID
|
||||
}
|
||||
]);
|
||||
|
||||
@ -143,20 +208,8 @@ describe('Portfolio', () => {
|
||||
const details = await portfolio.getDetails('1d');
|
||||
expect(details).toMatchObject({
|
||||
BTCUSD: {
|
||||
currency: Currency.USD,
|
||||
exchange: 'Other',
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
isMarketOpen: true,
|
||||
// marketPrice: 57973.008,
|
||||
name: 'Bitcoin USD',
|
||||
platforms: {
|
||||
Other: {
|
||||
accounts: {
|
||||
[UNKNOWN_KEY]: {
|
||||
/*current: exchangeRateDataService.toCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
@ -169,10 +222,24 @@ describe('Portfolio', () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
allocationCurrent: 1,
|
||||
allocationInvestment: 1,
|
||||
countries: [],
|
||||
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,
|
||||
// shareCurrent: 0.9999999559148652,
|
||||
shareInvestment: 1,
|
||||
symbol: 'BTCUSD',
|
||||
transactionCount: 1,
|
||||
type: 'Cryptocurrency'
|
||||
}
|
||||
});
|
||||
@ -215,18 +282,21 @@ describe('Portfolio', () => {
|
||||
it('should return ["ETHUSD"]', async () => {
|
||||
await portfolio.setOrders([
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 0,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
platformId: null,
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
userId: null
|
||||
userId: USER_ID
|
||||
}
|
||||
]);
|
||||
|
||||
@ -241,19 +311,8 @@ describe('Portfolio', () => {
|
||||
const details = await portfolio.getDetails('1d');
|
||||
expect(details).toMatchObject({
|
||||
ETHUSD: {
|
||||
currency: Currency.USD,
|
||||
exchange: 'Other',
|
||||
// grossPerformance: 0,
|
||||
// grossPerformancePercent: 0,
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
// marketPrice: 57973.008,
|
||||
name: 'Ethereum USD',
|
||||
platforms: {
|
||||
Other: {
|
||||
accounts: {
|
||||
[UNKNOWN_KEY]: {
|
||||
/*current: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
@ -266,9 +325,22 @@ describe('Portfolio', () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
// allocationCurrent: 1,
|
||||
allocationInvestment: 1,
|
||||
countries: [],
|
||||
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,
|
||||
shareCurrent: 1,
|
||||
shareInvestment: 1,
|
||||
transactionCount: 1,
|
||||
symbol: 'ETHUSD',
|
||||
type: 'Cryptocurrency'
|
||||
}
|
||||
@ -296,7 +368,7 @@ describe('Portfolio', () => {
|
||||
baseCurrency
|
||||
),
|
||||
investmentInOriginalCurrency: 0.2 * 991.49,
|
||||
// marketPrice: 0,
|
||||
// marketPrice: 3915.337,
|
||||
quantity: 0.2
|
||||
}
|
||||
});
|
||||
@ -307,32 +379,38 @@ describe('Portfolio', () => {
|
||||
it('should return ["ETHUSD"]', async () => {
|
||||
await portfolio.setOrders([
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 0,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
platformId: null,
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
userId: null
|
||||
userId: USER_ID
|
||||
},
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 0,
|
||||
date: new Date(getUtc('2018-01-28')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
platformId: null,
|
||||
quantity: 0.3,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
userId: null
|
||||
userId: USER_ID
|
||||
}
|
||||
]);
|
||||
|
||||
@ -368,7 +446,7 @@ describe('Portfolio', () => {
|
||||
baseCurrency
|
||||
),
|
||||
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
|
||||
// marketPrice: 0,
|
||||
// marketPrice: 3641.984,
|
||||
quantity: 0.5
|
||||
}
|
||||
});
|
||||
@ -379,32 +457,38 @@ describe('Portfolio', () => {
|
||||
it('should return ["BTCUSD", "ETHUSD"]', async () => {
|
||||
await portfolio.setOrders([
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.EUR,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: new Date(getUtc('2017-08-16')),
|
||||
fee: 2.99,
|
||||
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
|
||||
platformId: null,
|
||||
quantity: 0.05614682,
|
||||
symbol: 'BTCUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 3562.089535970158,
|
||||
updatedAt: null,
|
||||
userId: null
|
||||
userId: USER_ID
|
||||
},
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 2.99,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
platformId: null,
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
userId: null
|
||||
userId: USER_ID
|
||||
}
|
||||
]);
|
||||
|
||||
@ -464,51 +548,59 @@ describe('Portfolio', () => {
|
||||
it('should work with buy and sell', async () => {
|
||||
await portfolio.setOrders([
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 1.0,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
platformId: null,
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
userId: null
|
||||
userId: USER_ID
|
||||
},
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 1.0,
|
||||
date: new Date(getUtc('2018-01-28')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
platformId: null,
|
||||
quantity: 0.1,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.SELL,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
userId: null
|
||||
userId: USER_ID
|
||||
},
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 1.0,
|
||||
date: new Date(getUtc('2018-01-31')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
platformId: null,
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
userId: null
|
||||
userId: USER_ID
|
||||
}
|
||||
]);
|
||||
|
||||
// TODO: Fix
|
||||
/*expect(portfolio.getCommittedFunds()).toEqual(
|
||||
expect(portfolio.getCommittedFunds()).toEqual(
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
@ -524,7 +616,7 @@ describe('Portfolio', () => {
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
);*/
|
||||
);
|
||||
|
||||
expect(portfolio.getFees()).toEqual(
|
||||
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
|
||||
@ -536,12 +628,11 @@ describe('Portfolio', () => {
|
||||
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
|
||||
currency: Currency.USD,
|
||||
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,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),*/
|
||||
),
|
||||
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
||||
// marketPrice: 0,
|
||||
quantity: 0.2 - 0.1 + 0.2
|
||||
@ -551,8 +642,4 @@ describe('Portfolio', () => {
|
||||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
prismaService.$disconnect();
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,17 @@
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioItem,
|
||||
Position
|
||||
} from 'apps/api/src/app/portfolio/interfaces/portfolio-item.interface';
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
Position,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
import {
|
||||
add,
|
||||
format,
|
||||
@ -18,30 +28,24 @@ import {
|
||||
setMonth,
|
||||
sub
|
||||
} from 'date-fns';
|
||||
import { getToday, getYesterday, resetHours } from 'libs/helper/src';
|
||||
import { cloneDeep, isEmpty } from 'lodash';
|
||||
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 { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
import { RulesService } from '../services/rules.service';
|
||||
import { PortfolioInterface } from './interfaces/portfolio.interface';
|
||||
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 { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment';
|
||||
import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment';
|
||||
import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/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 {
|
||||
private orders: Order[] = [];
|
||||
@ -57,7 +61,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
public async addCurrentPortfolioItems() {
|
||||
const currentData = await this.dataProviderService.get(this.getSymbols());
|
||||
|
||||
let currentDate = new Date();
|
||||
const currentDate = new Date();
|
||||
|
||||
const year = getYear(currentDate);
|
||||
const month = getMonth(currentDate);
|
||||
@ -79,8 +83,12 @@ export class Portfolio implements PortfolioInterface {
|
||||
investmentInOriginalCurrency:
|
||||
portfolioItemsYesterday?.positions[symbol]
|
||||
?.investmentInOriginalCurrency,
|
||||
marketPrice: currentData[symbol]?.marketPrice,
|
||||
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity
|
||||
marketPrice:
|
||||
currentData[symbol]?.marketPrice ??
|
||||
portfolioItemsYesterday.positions[symbol]?.marketPrice,
|
||||
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity,
|
||||
transactionCount:
|
||||
portfolioItemsYesterday?.positions[symbol]?.transactionCount
|
||||
};
|
||||
});
|
||||
|
||||
@ -115,25 +123,27 @@ export class Portfolio implements PortfolioInterface {
|
||||
}): Portfolio {
|
||||
orders.forEach(
|
||||
({
|
||||
account,
|
||||
currency,
|
||||
fee,
|
||||
date,
|
||||
id,
|
||||
platform,
|
||||
quantity,
|
||||
symbol,
|
||||
symbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
this.orders.push(
|
||||
new Order({
|
||||
account,
|
||||
currency,
|
||||
fee,
|
||||
date,
|
||||
id,
|
||||
platform,
|
||||
quantity,
|
||||
symbol,
|
||||
symbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
})
|
||||
@ -158,53 +168,6 @@ export class Portfolio implements PortfolioInterface {
|
||||
return this;
|
||||
}
|
||||
|
||||
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
|
||||
let currentDate = new Date();
|
||||
|
||||
const normalizedMinDate =
|
||||
getDate(aMinDate) === 1
|
||||
? aMinDate
|
||||
: add(setDate(aMinDate, 1), { months: 1 });
|
||||
|
||||
const year = getYear(currentDate);
|
||||
const month = getMonth(currentDate);
|
||||
const day = getDate(currentDate);
|
||||
|
||||
currentDate = new Date(Date.UTC(year, month, day, 0));
|
||||
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
return sub(currentDate, {
|
||||
days: 1
|
||||
});
|
||||
case 'ytd':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = setMonth(currentDate, 0);
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
case '1y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 1
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
case '5y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 5
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
default:
|
||||
// Gets handled as all data
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public get(aDate?: Date): PortfolioItem[] {
|
||||
if (aDate) {
|
||||
const filteredPortfolio = this.portfolioItems.find((item) => {
|
||||
@ -245,7 +208,8 @@ export class Portfolio implements PortfolioInterface {
|
||||
const data = await this.dataProviderService.get(symbols);
|
||||
|
||||
symbols.forEach((symbol) => {
|
||||
const platforms: PortfolioPosition['platforms'] = {};
|
||||
const accounts: PortfolioPosition['accounts'] = {};
|
||||
let countriesOfSymbol: Country[];
|
||||
const [portfolioItem] = portfolioItems;
|
||||
|
||||
const ordersBySymbol = this.getOrders().filter((order) => {
|
||||
@ -270,19 +234,36 @@ export class Portfolio implements PortfolioInterface {
|
||||
originalValueOfSymbol *= -1;
|
||||
}
|
||||
|
||||
if (platforms[orderOfSymbol.getPlatform()?.name || 'Other']?.current) {
|
||||
platforms[
|
||||
orderOfSymbol.getPlatform()?.name || 'Other'
|
||||
if (
|
||||
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
|
||||
) {
|
||||
accounts[
|
||||
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
|
||||
].current += currentValueOfSymbol;
|
||||
platforms[
|
||||
orderOfSymbol.getPlatform()?.name || 'Other'
|
||||
accounts[
|
||||
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
|
||||
].original += originalValueOfSymbol;
|
||||
} else {
|
||||
platforms[orderOfSymbol.getPlatform()?.name || 'Other'] = {
|
||||
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
|
||||
current: currentValueOfSymbol,
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
}
|
||||
|
||||
countriesOfSymbol = (
|
||||
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
|
||||
[]
|
||||
).map((country) => {
|
||||
const { code, weight } = country as Prisma.JsonObject;
|
||||
|
||||
return {
|
||||
code: code as string,
|
||||
continent:
|
||||
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||
name: countries[code as string]?.name ?? UNKNOWN_KEY,
|
||||
weight: weight as number
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
let now = portfolioItemsNow.positions[symbol].marketPrice;
|
||||
@ -319,8 +300,17 @@ export class Portfolio implements PortfolioInterface {
|
||||
|
||||
details[symbol] = {
|
||||
...data[symbol],
|
||||
platforms,
|
||||
accounts,
|
||||
symbol,
|
||||
allocationCurrent:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol].quantity * now,
|
||||
data[symbol]?.currency,
|
||||
this.user.Settings.currency
|
||||
) / value,
|
||||
allocationInvestment:
|
||||
portfolioItem.positions[symbol].investment / investment,
|
||||
countries: countriesOfSymbol,
|
||||
grossPerformance: roundTo(
|
||||
portfolioItemsNow.positions[symbol].quantity * (now - before),
|
||||
2
|
||||
@ -328,13 +318,12 @@ export class Portfolio implements PortfolioInterface {
|
||||
grossPerformancePercent: roundTo((now - before) / before, 4),
|
||||
investment: portfolioItem.positions[symbol].investment,
|
||||
quantity: portfolioItem.positions[symbol].quantity,
|
||||
shareCurrent:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol].quantity * now,
|
||||
data[symbol]?.currency,
|
||||
this.user.Settings.currency
|
||||
) / value,
|
||||
shareInvestment: portfolioItem.positions[symbol].investment / investment
|
||||
transactionCount: portfolioItem.positions[symbol].transactionCount,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol].quantity * now,
|
||||
data[symbol]?.currency,
|
||||
this.user.Settings.currency
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
@ -437,6 +426,19 @@ export class Portfolio implements PortfolioInterface {
|
||||
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
this,
|
||||
[
|
||||
new AccountClusterRiskInitialInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
|
||||
],
|
||||
{ baseCurrency: this.user.Settings.currency }
|
||||
),
|
||||
currencyClusterRisk: await this.rulesService.evaluate(
|
||||
this,
|
||||
[
|
||||
@ -455,19 +457,6 @@ export class Portfolio implements PortfolioInterface {
|
||||
],
|
||||
{ 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(
|
||||
this,
|
||||
[new FeeRatioInitialInvestment(this.exchangeRateDataService)],
|
||||
@ -524,14 +513,14 @@ export class Portfolio implements PortfolioInterface {
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
|
||||
public getOrders() {
|
||||
return this.orders;
|
||||
}
|
||||
public getOrders(aSymbol?: string) {
|
||||
if (aSymbol) {
|
||||
return this.orders.filter((order) => {
|
||||
return order.getSymbol() === aSymbol;
|
||||
});
|
||||
}
|
||||
|
||||
private getOrdersByType(aFilter: string[]) {
|
||||
return this.orders.filter((order) => {
|
||||
return aFilter.includes(order.getType());
|
||||
});
|
||||
return this.orders;
|
||||
}
|
||||
|
||||
public getValue(aDate = getToday()) {
|
||||
@ -569,20 +558,21 @@ export class Portfolio implements PortfolioInterface {
|
||||
return isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
public async setOrders(aOrders: OrderWithPlatform[]) {
|
||||
public async setOrders(aOrders: OrderWithAccount[]) {
|
||||
this.orders = [];
|
||||
|
||||
// Map data
|
||||
aOrders.forEach((order) => {
|
||||
this.orders.push(
|
||||
new Order({
|
||||
currency: <any>order.currency,
|
||||
account: order.Account,
|
||||
currency: order.currency,
|
||||
date: order.date.toISOString(),
|
||||
fee: order.fee,
|
||||
platform: order.Platform,
|
||||
quantity: order.quantity,
|
||||
symbol: order.symbol,
|
||||
type: <any>order.type,
|
||||
symbolProfile: order.SymbolProfile,
|
||||
type: <OrderType>order.type,
|
||||
unitPrice: order.unitPrice
|
||||
})
|
||||
);
|
||||
@ -633,7 +623,8 @@ export class Portfolio implements PortfolioInterface {
|
||||
marketPrice:
|
||||
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
|
||||
?.marketPrice || 0,
|
||||
quantity: 0
|
||||
quantity: 0,
|
||||
transactionCount: 0
|
||||
};
|
||||
});
|
||||
|
||||
@ -674,7 +665,8 @@ export class Portfolio implements PortfolioInterface {
|
||||
marketPrice:
|
||||
historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')]
|
||||
?.marketPrice || 0,
|
||||
quantity: 0
|
||||
quantity: 0,
|
||||
transactionCount: 0
|
||||
};
|
||||
});
|
||||
|
||||
@ -692,6 +684,53 @@ export class Portfolio implements PortfolioInterface {
|
||||
this.updatePortfolioItems();
|
||||
}
|
||||
|
||||
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
|
||||
let currentDate = new Date();
|
||||
|
||||
const normalizedMinDate =
|
||||
getDate(aMinDate) === 1
|
||||
? aMinDate
|
||||
: add(setDate(aMinDate, 1), { months: 1 });
|
||||
|
||||
const year = getYear(currentDate);
|
||||
const month = getMonth(currentDate);
|
||||
const day = getDate(currentDate);
|
||||
|
||||
currentDate = new Date(Date.UTC(year, month, day, 0));
|
||||
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
return sub(currentDate, {
|
||||
days: 1
|
||||
});
|
||||
case 'ytd':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = setMonth(currentDate, 0);
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
case '1y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 1
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
case '5y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 5
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
default:
|
||||
// Gets handled as all data
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private updatePortfolioItems() {
|
||||
// console.time('update-portfolio-items');
|
||||
|
||||
@ -734,6 +773,10 @@ export class Portfolio implements PortfolioInterface {
|
||||
order.getSymbol()
|
||||
].currency = order.getCurrency();
|
||||
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].transactionCount += 1;
|
||||
|
||||
if (order.getType() === 'BUY') {
|
||||
if (
|
||||
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { groupBy } from '@ghostfolio/common/helper';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { groupBy } from 'libs/helper/src';
|
||||
|
||||
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||
import { RuleInterface } from './interfaces/rule.interface';
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/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';
|
||||
|
||||
export class PlatformClusterRiskCurrentInvestment extends Rule {
|
||||
export class AccountClusterRiskCurrentInvestment extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment'
|
||||
@ -18,24 +18,22 @@ export class PlatformClusterRiskCurrentInvestment extends Rule {
|
||||
}
|
||||
) {
|
||||
const ruleSettings =
|
||||
aRuleSettingsMap[PlatformClusterRiskCurrentInvestment.name];
|
||||
aRuleSettingsMap[AccountClusterRiskCurrentInvestment.name];
|
||||
|
||||
const platforms: {
|
||||
const accounts: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||
investment: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
Object.values(aPositions).forEach((position) => {
|
||||
for (const [platform, { current }] of Object.entries(
|
||||
position.platforms
|
||||
)) {
|
||||
if (platforms[platform]?.investment) {
|
||||
platforms[platform].investment += current;
|
||||
for (const [account, { current }] of Object.entries(position.accounts)) {
|
||||
if (accounts[account]?.investment) {
|
||||
accounts[account].investment += current;
|
||||
} else {
|
||||
platforms[platform] = {
|
||||
accounts[account] = {
|
||||
investment: current,
|
||||
name: platform
|
||||
name: account
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -44,17 +42,17 @@ export class PlatformClusterRiskCurrentInvestment extends Rule {
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
|
||||
Object.values(platforms).forEach((platform) => {
|
||||
Object.values(accounts).forEach((account) => {
|
||||
if (!maxItem) {
|
||||
maxItem = platform;
|
||||
maxItem = account;
|
||||
}
|
||||
|
||||
// Calculate total investment
|
||||
totalInvestment += platform.investment;
|
||||
totalInvestment += account.investment;
|
||||
|
||||
// Find maximum
|
||||
if (platform.investment > maxItem?.investment) {
|
||||
maxItem = platform;
|
||||
if (account.investment > maxItem?.investment) {
|
||||
maxItem = account;
|
||||
}
|
||||
});
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class PlatformClusterRiskInitialInvestment extends Rule {
|
||||
export class AccountClusterRiskInitialInvestment extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment'
|
||||
@ -18,7 +18,7 @@ export class PlatformClusterRiskInitialInvestment extends Rule {
|
||||
}
|
||||
) {
|
||||
const ruleSettings =
|
||||
aRuleSettingsMap[PlatformClusterRiskInitialInvestment.name];
|
||||
aRuleSettingsMap[AccountClusterRiskInitialInvestment.name];
|
||||
|
||||
const platforms: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||
@ -27,15 +27,13 @@ export class PlatformClusterRiskInitialInvestment extends Rule {
|
||||
} = {};
|
||||
|
||||
Object.values(aPositions).forEach((position) => {
|
||||
for (const [platform, { original }] of Object.entries(
|
||||
position.platforms
|
||||
)) {
|
||||
if (platforms[platform]?.investment) {
|
||||
platforms[platform].investment += original;
|
||||
for (const [account, { original }] of Object.entries(position.accounts)) {
|
||||
if (platforms[account]?.investment) {
|
||||
platforms[account].investment += original;
|
||||
} else {
|
||||
platforms[platform] = {
|
||||
platforms[account] = {
|
||||
investment: original,
|
||||
name: platform
|
||||
name: account
|
||||
};
|
||||
}
|
||||
}
|
@ -1,35 +1,35 @@
|
||||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class PlatformClusterRiskSinglePlatform extends Rule {
|
||||
export class AccountClusterRiskSingleAccount extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Single Platform'
|
||||
name: 'Single Account'
|
||||
});
|
||||
}
|
||||
|
||||
public evaluate(positions: { [symbol: string]: PortfolioPosition }) {
|
||||
const platforms: string[] = [];
|
||||
const accounts: string[] = [];
|
||||
|
||||
Object.values(positions).forEach((position) => {
|
||||
for (const [platform] of Object.entries(position.platforms)) {
|
||||
if (!platforms.includes(platform)) {
|
||||
platforms.push(platform);
|
||||
for (const [account] of Object.entries(position.accounts)) {
|
||||
if (!accounts.includes(account)) {
|
||||
accounts.push(account);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (platforms.length === 1) {
|
||||
if (accounts.length === 1) {
|
||||
return {
|
||||
evaluation: `All your investment is managed by a single platform`,
|
||||
evaluation: `All your investment is managed by a single account`,
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `Your investment is managed by ${platforms.length} platforms`,
|
||||
evaluation: `Your investment is managed by ${accounts.length} accounts`,
|
||||
value: true
|
||||
};
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/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';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
36
apps/api/src/services/configuration.service.ts
Normal file
36
apps/api/src/services/configuration.service.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { bool, cleanEnv, json, num, port, str } from 'envalid';
|
||||
|
||||
import { Environment } from './interfaces/environment.interface';
|
||||
|
||||
@Injectable()
|
||||
export class ConfigurationService {
|
||||
private readonly environmentConfiguration: Environment;
|
||||
|
||||
public constructor() {
|
||||
this.environmentConfiguration = cleanEnv(process.env, {
|
||||
ACCESS_TOKEN_SALT: str(),
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
|
||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||
JWT_SECRET_KEY: str({}),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
PORT: port({ default: 3333 }),
|
||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
ROOT_URL: str({ default: 'http://localhost:4200' })
|
||||
});
|
||||
}
|
||||
|
||||
public get<K extends keyof Environment>(key: K): Environment[K] {
|
||||
return this.environmentConfiguration[key];
|
||||
}
|
||||
}
|
@ -1,4 +1,11 @@
|
||||
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
|
||||
import {
|
||||
getUtc,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
format,
|
||||
@ -8,16 +15,19 @@ import {
|
||||
isBefore,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import { benchmarks, currencyPairs } from 'libs/helper/src';
|
||||
import { getUtc, resetHours } from 'libs/helper/src';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider.service';
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DataGatheringService {
|
||||
public constructor(
|
||||
private dataProviderService: DataProviderService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
@ -64,9 +74,11 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
public async gatherMax() {
|
||||
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
||||
const isDataGatheringLocked = await this.prisma.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
});
|
||||
|
||||
if (isDataGatheringNeeded) {
|
||||
if (!isDataGatheringLocked) {
|
||||
console.log('Max data gathering has been started.');
|
||||
console.time('data-gathering');
|
||||
|
||||
@ -105,15 +117,13 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherSymbols(
|
||||
aSymbolsWithStartDate: { date: Date; symbol: string }[]
|
||||
) {
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
let hasError = false;
|
||||
|
||||
for (const { date, symbol } of aSymbolsWithStartDate) {
|
||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||
try {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[symbol],
|
||||
[{ dataSource, symbol }],
|
||||
date,
|
||||
new Date()
|
||||
);
|
||||
@ -174,67 +184,111 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> {
|
||||
public async getCustomSymbolsToGather(
|
||||
startDate?: Date
|
||||
): Promise<IDataGatheringItem[]> {
|
||||
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
|
||||
|
||||
return scraperConfigurations.map((scraperConfiguration) => {
|
||||
return {
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
date: startDate,
|
||||
symbol: scraperConfiguration.symbol
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
||||
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
benchmarksToGather.push({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
date: startDate,
|
||||
symbol: 'GF.FEAR_AND_GREED_INDEX'
|
||||
});
|
||||
}
|
||||
|
||||
return benchmarksToGather;
|
||||
}
|
||||
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
let distinctOrders = await this.prisma.order.findMany({
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { symbol: true }
|
||||
select: { dataSource: true, symbol: true }
|
||||
});
|
||||
|
||||
const distinctOrdersWithDate = distinctOrders.map((distinctOrder) => {
|
||||
return {
|
||||
...distinctOrder,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
|
||||
.filter((distinctOrder) => {
|
||||
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
|
||||
})
|
||||
.map((distinctOrder) => {
|
||||
return {
|
||||
...distinctOrder,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
const benchmarksToGather = benchmarks.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||
startDate
|
||||
);
|
||||
|
||||
return [
|
||||
...benchmarksToGather,
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...customSymbolsToGather,
|
||||
...currencyPairsToGather,
|
||||
...distinctOrdersWithDate
|
||||
];
|
||||
}
|
||||
|
||||
private async getSymbolsMax() {
|
||||
const startDate = new Date(getUtc('2000-01-01'));
|
||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = new Date(getUtc('2015-01-01'));
|
||||
|
||||
let distinctOrders = await this.prisma.order.findMany({
|
||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||
startDate
|
||||
);
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true, symbol: true }
|
||||
select: { dataSource: true, date: true, symbol: true }
|
||||
});
|
||||
|
||||
const benchmarksToGather = benchmarks.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
return [...benchmarksToGather, ...currencyPairsToGather, ...distinctOrders];
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...customSymbolsToGather,
|
||||
...currencyPairsToGather,
|
||||
...distinctOrders
|
||||
];
|
||||
}
|
||||
|
||||
private async isDataGatheringNeeded() {
|
||||
|
@ -1,26 +1,33 @@
|
||||
import {
|
||||
isGhostfolioScraperApiSymbol,
|
||||
isRakutenRapidApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MarketData } from '@prisma/client';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { isCrypto, isRakutenRapidApi } from 'libs/helper/src';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-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 { DataProviderInterface } from './interfaces/data-provider.interface';
|
||||
import { Granularity } from './interfaces/granularity.type';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DataProviderService implements DataProviderInterface {
|
||||
export class DataProviderService {
|
||||
public constructor(
|
||||
private alphaVantageService: AlphaVantageService,
|
||||
private readonly alphaVantageService: AlphaVantageService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
|
||||
private prisma: PrismaService,
|
||||
private rakutenRapidApiService: RakutenRapidApiService,
|
||||
private yahooFinanceService: YahooFinanceService
|
||||
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
||||
private readonly yahooFinanceService: YahooFinanceService
|
||||
) {
|
||||
this.rakutenRapidApiService.setPrisma(this.prisma);
|
||||
}
|
||||
@ -31,12 +38,33 @@ export class DataProviderService implements DataProviderInterface {
|
||||
if (aSymbols.length === 1) {
|
||||
const symbol = aSymbols[0];
|
||||
|
||||
if (isRakutenRapidApi(symbol)) {
|
||||
if (isGhostfolioScraperApiSymbol(symbol)) {
|
||||
return this.ghostfolioScraperApiService.get(aSymbols);
|
||||
} else if (isRakutenRapidApiSymbol(symbol)) {
|
||||
return this.rakutenRapidApiService.get(aSymbols);
|
||||
}
|
||||
}
|
||||
|
||||
return this.yahooFinanceService.get(aSymbols);
|
||||
const yahooFinanceSymbols = aSymbols.filter((symbol) => {
|
||||
return !isGhostfolioScraperApiSymbol(symbol);
|
||||
});
|
||||
|
||||
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
|
||||
|
||||
const ghostfolioScraperApiSymbols = aSymbols.filter((symbol) => {
|
||||
return isGhostfolioScraperApiSymbol(symbol);
|
||||
});
|
||||
|
||||
for (const symbol of ghostfolioScraperApiSymbols) {
|
||||
if (symbol) {
|
||||
const ghostfolioScraperApiResult = await this.ghostfolioScraperApiService.get(
|
||||
[symbol]
|
||||
);
|
||||
response[symbol] = ghostfolioScraperApiResult[symbol];
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
@ -51,12 +79,12 @@ export class DataProviderService implements DataProviderInterface {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
let granularityQuery =
|
||||
const granularityQuery =
|
||||
aGranularity === 'month'
|
||||
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
|
||||
: '';
|
||||
|
||||
let rangeQuery =
|
||||
const rangeQuery =
|
||||
from && to
|
||||
? `AND date >= '${format(from, 'yyyy-MM-dd')}' AND date <= '${format(
|
||||
to,
|
||||
@ -91,49 +119,57 @@ export class DataProviderService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getHistoricalRaw(
|
||||
aSymbols: string[],
|
||||
aDataGatheringItems: IDataGatheringItem[],
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
const dataOfYahoo = await this.yahooFinanceService.getHistorical(
|
||||
aSymbols,
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
);
|
||||
const result: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
if (aSymbols.length === 1) {
|
||||
const symbol = aSymbols[0];
|
||||
|
||||
if (isCrypto(symbol)) {
|
||||
// Merge data from Yahoo with data from Alpha Vantage
|
||||
const dataOfAlphaVantage = await this.alphaVantageService.getHistorical(
|
||||
[symbol],
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
const promises: Promise<{
|
||||
data: { [date: string]: IDataProviderHistoricalResponse };
|
||||
symbol: string;
|
||||
}>[] = [];
|
||||
for (const { dataSource, symbol } of aDataGatheringItems) {
|
||||
const dataProvider = this.getDataProvider(dataSource);
|
||||
if (dataProvider.canHandle(symbol)) {
|
||||
promises.push(
|
||||
dataProvider
|
||||
.getHistorical([symbol], undefined, from, to)
|
||||
.then((data) => ({ data: data?.[symbol], symbol }))
|
||||
);
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
...dataOfYahoo[symbol],
|
||||
...dataOfAlphaVantage[symbol]
|
||||
}
|
||||
};
|
||||
} else if (isRakutenRapidApi(symbol)) {
|
||||
const dataOfRakutenRapidApi = await this.rakutenRapidApiService.getHistorical(
|
||||
[symbol],
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
);
|
||||
|
||||
return dataOfRakutenRapidApi;
|
||||
}
|
||||
}
|
||||
|
||||
return dataOfYahoo;
|
||||
const allData = await Promise.all(promises);
|
||||
for (const { data, symbol } of allData) {
|
||||
result[symbol] = data;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string) {
|
||||
return this.getDataProvider(
|
||||
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
|
||||
).search(aSymbol);
|
||||
}
|
||||
|
||||
private getDataProvider(providerName: DataSource) {
|
||||
switch (providerName) {
|
||||
case DataSource.ALPHA_VANTAGE:
|
||||
return this.alphaVantageService;
|
||||
case DataSource.GHOSTFOLIO:
|
||||
return this.ghostfolioScraperApiService;
|
||||
case DataSource.RAKUTEN:
|
||||
return this.rakutenRapidApiService;
|
||||
case DataSource.YAHOO:
|
||||
return this.yahooFinanceService;
|
||||
default:
|
||||
throw new Error('No data provider has been found.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,32 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { isAfter, isBefore, parse } from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from '../../configuration.service';
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import { Granularity } from '../../interfaces/granularity.type';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '../../interfaces/interfaces';
|
||||
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
||||
|
||||
const alphaVantage = require('alphavantage')({
|
||||
key: process.env.ALPHA_VANTAGE_API_KEY
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class AlphaVantageService implements DataProviderInterface {
|
||||
public constructor() {}
|
||||
public alphaVantage;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
this.alphaVantage = require('alphavantage')({
|
||||
key: this.configurationService.get('ALPHA_VANTAGE_API_KEY')
|
||||
});
|
||||
}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
@ -40,7 +51,7 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
try {
|
||||
const historicalData: {
|
||||
[symbol: string]: IAlphaVantageHistoricalResponse[];
|
||||
} = await alphaVantage.crypto.daily(
|
||||
} = await this.alphaVantage.crypto.daily(
|
||||
symbol.substring(0, symbol.length - 3).toLowerCase(),
|
||||
'usd'
|
||||
);
|
||||
@ -72,7 +83,17 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
}
|
||||
}
|
||||
|
||||
public search(aSymbol: string) {
|
||||
return alphaVantage.data.search(aSymbol);
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
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']
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,148 @@
|
||||
import {
|
||||
getYesterday,
|
||||
isGhostfolioScraperApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import { PrismaService } from '../../prisma.service';
|
||||
import { ScraperConfig } from './interfaces/scraper-config.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
||||
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return isGhostfolioScraperApiSymbol(symbol);
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const symbol = aSymbols[0];
|
||||
|
||||
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
|
||||
|
||||
const { marketPrice } = await this.prisma.marketData.findFirst({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
where: {
|
||||
symbol
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
marketPrice,
|
||||
currency: scraperConfig?.currency,
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
marketState: MarketState.delayed,
|
||||
name: scraperConfig?.name
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbols: string[],
|
||||
aGranularity: Granularity = 'day',
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const symbol = aSymbols[0];
|
||||
|
||||
const scraperConfiguration = await this.getScraperConfigurationBySymbol(
|
||||
symbol
|
||||
);
|
||||
|
||||
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
|
||||
|
||||
const html = await get();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const value = this.extractNumberFromString(
|
||||
$(scraperConfiguration?.selector).text()
|
||||
);
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
[format(getYesterday(), 'yyyy-MM-dd')]: {
|
||||
marketPrice: value
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
|
||||
try {
|
||||
const {
|
||||
value: scraperConfigString
|
||||
} = await this.prisma.property.findFirst({
|
||||
select: {
|
||||
value: true
|
||||
},
|
||||
where: { key: 'SCRAPER_CONFIG' }
|
||||
});
|
||||
|
||||
return JSON.parse(scraperConfigString);
|
||||
} catch {}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface ScraperConfig {
|
||||
currency: Currency;
|
||||
name: string;
|
||||
selector: string;
|
||||
symbol: string;
|
||||
url: string;
|
||||
}
|
@ -1,13 +1,20 @@
|
||||
import {
|
||||
getToday,
|
||||
getYesterday,
|
||||
isRakutenRapidApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
import { getToday, getYesterday } from 'libs/helper/src';
|
||||
|
||||
import { ConfigurationService } from '../../configuration.service';
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import { Granularity } from '../../interfaces/granularity.type';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import { PrismaService } from '../../prisma.service';
|
||||
|
||||
@ -17,7 +24,16 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
|
||||
private prisma: PrismaService;
|
||||
|
||||
public constructor() {}
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return (
|
||||
isRakutenRapidApiSymbol(symbol) &&
|
||||
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
|
||||
);
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
@ -35,8 +51,9 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return {
|
||||
'GF.FEAR_AND_GREED_INDEX': {
|
||||
currency: undefined,
|
||||
isMarketOpen: true,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
marketPrice: fgi.now.value,
|
||||
marketState: MarketState.open,
|
||||
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
|
||||
}
|
||||
};
|
||||
@ -111,6 +128,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async search(aSymbol: string) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
public setPrisma(aPrismaService: PrismaService) {
|
||||
this.prisma = aPrismaService;
|
||||
}
|
||||
|
||||
private async getFearAndGreedIndex(): Promise<{
|
||||
now: { value: number; valueText: string };
|
||||
previousClose: { value: number; valueText: string };
|
||||
@ -127,7 +152,9 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
{
|
||||
useQueryString: true,
|
||||
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
|
||||
'x-rapidapi-key': process.env.RAKUTEN_RAPID_API_KEY
|
||||
'x-rapidapi-key': this.configurationService.get(
|
||||
'RAKUTEN_RAPID_API_KEY'
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
@ -139,8 +166,4 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public setPrisma(aPrismaService: PrismaService) {
|
||||
this.prisma = aPrismaService;
|
||||
}
|
||||
}
|
||||
|
@ -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('{}');
|
||||
});
|
||||
});
|
||||
});
|
||||
*/
|
@ -1,14 +1,19 @@
|
||||
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 { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { format } from 'date-fns';
|
||||
import { isCrypto, isCurrency, parseCurrency } from 'libs/helper/src';
|
||||
import * as yahooFinance from 'yahoo-finance';
|
||||
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import { Granularity } from '../../interfaces/granularity.type';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
Industry,
|
||||
MarketState,
|
||||
Sector,
|
||||
Type
|
||||
} from '../../interfaces/interfaces';
|
||||
@ -19,8 +24,14 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class YahooFinanceService implements DataProviderInterface {
|
||||
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
@ -48,9 +59,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
response[symbol] = {
|
||||
currency: parseCurrency(value.price?.currency),
|
||||
dataSource: DataSource.YAHOO,
|
||||
exchange: this.parseExchange(value.price?.exchangeName),
|
||||
isMarketOpen:
|
||||
value.price?.marketState === 'REGULAR' || isCrypto(symbol),
|
||||
marketState:
|
||||
value.price?.marketState === 'REGULAR' || isCrypto(symbol)
|
||||
? MarketState.open
|
||||
: MarketState.closed,
|
||||
marketPrice: value.price?.regularMarketPrice || 0,
|
||||
name: value.price?.longName || value.price?.shortName || symbol,
|
||||
type: this.parseType(this.getType(symbol, value))
|
||||
@ -130,6 +144,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®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=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
|
||||
*
|
||||
@ -165,7 +222,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
private parseExchange(aString: string): string {
|
||||
if (aString?.toLowerCase() === 'ccc') {
|
||||
return 'Other';
|
||||
return UNKNOWN_KEY;
|
||||
}
|
||||
|
||||
return aString;
|
||||
@ -195,7 +252,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return Industry.Software;
|
||||
}
|
||||
|
||||
return Industry.Other;
|
||||
return Industry.Unknown;
|
||||
}
|
||||
|
||||
private parseSector(aString: string): Sector {
|
||||
@ -217,7 +274,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return Sector.Technology;
|
||||
}
|
||||
|
||||
return Sector.Other;
|
||||
return Sector.Unknown;
|
||||
}
|
||||
|
||||
private parseType(aString: string): Type {
|
||||
@ -229,7 +286,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return Type.Stock;
|
||||
}
|
||||
|
||||
return Type.Other;
|
||||
return Type.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { getYesterday } from 'libs/helper/src';
|
||||
|
||||
import { DataProviderService } from './data-provider.service';
|
||||
|
||||
@ -15,6 +15,8 @@ export class ExchangeRateDataService {
|
||||
}
|
||||
|
||||
public async initialize() {
|
||||
this.pairs = [];
|
||||
|
||||
this.addPairs(Currency.CHF, Currency.EUR);
|
||||
this.addPairs(Currency.CHF, Currency.GBP);
|
||||
this.addPairs(Currency.CHF, Currency.USD);
|
||||
@ -25,11 +27,6 @@ export class ExchangeRateDataService {
|
||||
await this.loadCurrencies();
|
||||
}
|
||||
|
||||
private addPairs(aCurrency1: Currency, aCurrency2: Currency) {
|
||||
this.pairs.push(`${aCurrency1}${aCurrency2}`);
|
||||
this.pairs.push(`${aCurrency2}${aCurrency1}`);
|
||||
}
|
||||
|
||||
public async loadCurrencies() {
|
||||
const result = await this.dataProviderService.getHistorical(
|
||||
this.pairs,
|
||||
@ -38,19 +35,34 @@ export class ExchangeRateDataService {
|
||||
getYesterday()
|
||||
);
|
||||
|
||||
const resultExtended = result;
|
||||
|
||||
Object.keys(result).forEach((pair) => {
|
||||
const [currency1, currency2] = pair.match(/.{1,3}/g);
|
||||
const [date] = Object.keys(result[pair]);
|
||||
|
||||
// Calculate the opposite direction
|
||||
resultExtended[`${currency2}${currency1}`] = {
|
||||
[date]: {
|
||||
marketPrice: 1 / result[pair][date].marketPrice
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
this.pairs.forEach((pair) => {
|
||||
this.currencies[pair] =
|
||||
result[pair]?.[format(getYesterday(), 'yyyy-MM-dd')]?.marketPrice || 1;
|
||||
const [currency1, currency2] = pair.match(/.{1,3}/g);
|
||||
const date = format(getYesterday(), 'yyyy-MM-dd');
|
||||
|
||||
if (this.currencies[pair] === 1) {
|
||||
// Calculate the other direction
|
||||
const [currency1, currency2] = pair.match(/.{1,3}/g);
|
||||
this.currencies[pair] = resultExtended[pair]?.[date]?.marketPrice;
|
||||
|
||||
if (!this.currencies[pair]) {
|
||||
// Not found, calculate indirectly via USD
|
||||
this.currencies[pair] =
|
||||
1 /
|
||||
result[`${currency2}${currency1}`]?.[
|
||||
format(getYesterday(), 'yyyy-MM-dd')
|
||||
]?.marketPrice;
|
||||
resultExtended[`${currency1}${Currency.USD}`]?.[date]?.marketPrice *
|
||||
resultExtended[`${Currency.USD}${currency2}`]?.[date]?.marketPrice;
|
||||
|
||||
// Calculate the opposite direction
|
||||
this.currencies[`${currency2}${currency1}`] = 1 / this.currencies[pair];
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -60,6 +72,11 @@ export class ExchangeRateDataService {
|
||||
aFromCurrency: Currency,
|
||||
aToCurrency: Currency
|
||||
) {
|
||||
if (isNaN(this.currencies[`${Currency.USD}${Currency.CHF}`])) {
|
||||
// Reinitialize if data is not loaded correctly
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
let factor = 1;
|
||||
|
||||
if (aFromCurrency !== aToCurrency) {
|
||||
@ -68,4 +85,9 @@ export class ExchangeRateDataService {
|
||||
|
||||
return factor * aValue;
|
||||
}
|
||||
|
||||
private addPairs(aCurrency1: Currency, aCurrency2: Currency) {
|
||||
this.pairs.push(`${aCurrency1}${aCurrency2}`);
|
||||
this.pairs.push(`${aCurrency2}${aCurrency1}`);
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,14 @@
|
||||
import { Granularity } from './granularity.type';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from './interfaces';
|
||||
|
||||
export interface DataProviderInterface {
|
||||
canHandle(symbol: string): boolean;
|
||||
|
||||
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
||||
|
||||
getHistorical(
|
||||
@ -15,4 +19,6 @@ export interface DataProviderInterface {
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}>;
|
||||
|
||||
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
||||
}
|
||||
|
21
apps/api/src/services/interfaces/environment.interface.ts
Normal file
21
apps/api/src/services/interfaces/environment.interface.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { CleanedEnvAccessors } from 'envalid';
|
||||
|
||||
export interface Environment extends CleanedEnvAccessors {
|
||||
ACCESS_TOKEN_SALT: string;
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
CACHE_TTL: number;
|
||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_SECRET: string;
|
||||
JWT_SECRET_KEY: string;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
PORT: number;
|
||||
RAKUTEN_RAPID_API_KEY: string;
|
||||
REDIS_HOST: string;
|
||||
REDIS_PORT: number;
|
||||
ROOT_URL: string;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { Currency, Platform } from '@prisma/client';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
|
||||
|
||||
import { OrderType } from '../../models/order-type';
|
||||
|
||||
@ -7,33 +8,40 @@ export const Industry = {
|
||||
Biotechnology: 'Biotechnology',
|
||||
Food: 'Food',
|
||||
Internet: 'Internet',
|
||||
Other: 'Other',
|
||||
Pharmaceutical: 'Pharmaceutical',
|
||||
Software: 'Software'
|
||||
Software: 'Software',
|
||||
Unknown: UNKNOWN_KEY
|
||||
};
|
||||
|
||||
export const MarketState = {
|
||||
closed: 'closed',
|
||||
delayed: 'delayed',
|
||||
open: 'open'
|
||||
};
|
||||
|
||||
export const Sector = {
|
||||
Consumer: 'Consumer',
|
||||
Healthcare: 'Healthcare',
|
||||
Other: 'Other',
|
||||
Technology: 'Technology'
|
||||
Technology: 'Technology',
|
||||
Unknown: UNKNOWN_KEY
|
||||
};
|
||||
|
||||
export const Type = {
|
||||
Cryptocurrency: 'Cryptocurrency',
|
||||
ETF: 'ETF',
|
||||
Other: 'Other',
|
||||
Stock: 'Stock'
|
||||
Stock: 'Stock',
|
||||
Unknown: UNKNOWN_KEY
|
||||
};
|
||||
|
||||
export interface IOrder {
|
||||
account: Account;
|
||||
currency: Currency;
|
||||
date: string;
|
||||
fee: number;
|
||||
id?: string;
|
||||
platform: Platform;
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
symbolProfile: SymbolProfile;
|
||||
type: OrderType;
|
||||
unitPrice: number;
|
||||
}
|
||||
@ -45,20 +53,29 @@ export interface IDataProviderHistoricalResponse {
|
||||
|
||||
export interface IDataProviderResponse {
|
||||
currency: Currency;
|
||||
dataSource: DataSource;
|
||||
exchange?: string;
|
||||
industry?: Industry;
|
||||
isMarketOpen: boolean;
|
||||
marketChange?: number;
|
||||
marketChangePercent?: number;
|
||||
marketPrice: number;
|
||||
marketState: MarketState;
|
||||
name: string;
|
||||
sector?: Sector;
|
||||
type?: Type;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface IDataGatheringItem {
|
||||
dataSource: DataSource;
|
||||
date?: Date;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
export type Industry = typeof Industry[keyof typeof Industry];
|
||||
|
||||
export type MarketState = typeof MarketState[keyof typeof MarketState];
|
||||
|
||||
export type Sector = typeof Sector[keyof typeof Sector];
|
||||
|
||||
export type Type = typeof Type[keyof typeof Type];
|
||||
|
@ -2,7 +2,8 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
|
@ -2,14 +2,14 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Portfolio } from '../models/portfolio';
|
||||
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 { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '../models/rules/currency-cluster-risk/base-currency-initial-investment';
|
||||
import { CurrencyClusterRiskCurrentInvestment } from '../models/rules/currency-cluster-risk/current-investment';
|
||||
import { CurrencyClusterRiskInitialInvestment } from '../models/rules/currency-cluster-risk/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()
|
||||
export class RulesService {
|
||||
@ -39,6 +39,17 @@ export class RulesService {
|
||||
|
||||
private getDefaultRuleSettings(aUserSettings: { baseCurrency: string }) {
|
||||
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]: {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true
|
||||
@ -61,18 +72,7 @@ export class RulesService {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {
|
||||
DEFAULT_DATE_FORMAT,
|
||||
DEFAULT_DATE_FORMAT_MONTH_YEAR
|
||||
} from 'libs/helper/src';
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
export const DateFormats = {
|
||||
display: {
|
||||
|
@ -9,11 +9,6 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
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',
|
||||
loadChildren: () =>
|
||||
@ -21,28 +16,40 @@ const routes: Routes = [
|
||||
(m) => m.AccountPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'accounts',
|
||||
loadChildren: () =>
|
||||
import('./pages/accounts/accounts-page.module').then(
|
||||
(m) => m.AccountsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
loadChildren: () =>
|
||||
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
loadChildren: () =>
|
||||
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
||||
},
|
||||
{
|
||||
path: 'analysis',
|
||||
loadChildren: () =>
|
||||
import('./pages/analysis/analysis-page.module').then(
|
||||
(m) => m.AnalysisPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||
},
|
||||
{
|
||||
path: 'report',
|
||||
path: 'pricing',
|
||||
loadChildren: () =>
|
||||
import('./pages/report/report-page.module').then(
|
||||
(m) => m.ReportPageModule
|
||||
import('./pages/pricing/pricing-page.module').then(
|
||||
(m) => m.PricingPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadChildren: () =>
|
||||
import('./pages/register/register-page.module').then(
|
||||
(m) => m.RegisterPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
@ -55,7 +62,28 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'start',
|
||||
loadChildren: () =>
|
||||
import('./pages/login/login-page.module').then((m) => m.LoginPageModule)
|
||||
import('./pages/landing/landing-page.module').then(
|
||||
(m) => m.LandingPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'tools',
|
||||
loadChildren: () =>
|
||||
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
|
||||
},
|
||||
{
|
||||
path: 'tools/analysis',
|
||||
loadChildren: () =>
|
||||
import('./pages/tools/analysis/analysis-page.module').then(
|
||||
(m) => m.AnalysisPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'tools/report',
|
||||
loadChildren: () =>
|
||||
import('./pages/tools/report/report-page.module').then(
|
||||
(m) => m.ReportPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'transactions',
|
||||
@ -64,11 +92,16 @@ const routes: Routes = [
|
||||
(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
|
||||
// earlier
|
||||
path: '**',
|
||||
redirectTo: '/home',
|
||||
redirectTo: 'home',
|
||||
pathMatch: 'full'
|
||||
}
|
||||
];
|
||||
|
@ -2,7 +2,9 @@
|
||||
<gf-header
|
||||
class="position-fixed px-2 w-100"
|
||||
[currentRoute]="currentRoute"
|
||||
[info]="info"
|
||||
[user]="user"
|
||||
(signOut)="onSignOut()"
|
||||
></gf-header>
|
||||
</header>
|
||||
|
||||
@ -10,13 +12,15 @@
|
||||
<div *ngIf="canCreateAccount" class="container create-account-container">
|
||||
<div class="row mb-5">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div
|
||||
class="create-account-box p-2 text-center"
|
||||
(click)="onCreateAccount()"
|
||||
<a [routerLink]="['/']">
|
||||
<mat-card
|
||||
class="create-account-box p-2 text-center"
|
||||
(click)="onCreateAccount()"
|
||||
>
|
||||
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
||||
<button mat-button color="primary" i18n>Create Account</button>
|
||||
</mat-card></a
|
||||
>
|
||||
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
||||
<button mat-button color="primary" i18n>Create Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -24,9 +28,15 @@
|
||||
<router-outlet></router-outlet>
|
||||
</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>Ghostfolio {{ version }}</div>
|
||||
<div>
|
||||
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||
{{ version }}
|
||||
</div>
|
||||
<div class="py-2 text-muted">
|
||||
<small i18n
|
||||
>The risk of loss in trading can be substantial. It is not advisable to
|
||||
|
@ -5,8 +5,6 @@
|
||||
padding: 5rem 0;
|
||||
|
||||
.create-account-box {
|
||||
border: 1px solid rgba(var(--palette-primary-500), 1);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 90%;
|
||||
|
||||
|
@ -5,18 +5,19 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
||||
import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
||||
import { User } from 'apps/api/src/app/user/interfaces/user.interface';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { primaryColorHex, secondaryColorHex } from 'libs/helper/src';
|
||||
import { hasPermission, permissions } from 'libs/helper/src';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { filter, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { DataService } from './services/data.service';
|
||||
import { TokenStorageService } from './services/token-storage.service';
|
||||
import { UserService } from './services/user/user.service';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-root',
|
||||
@ -27,59 +28,71 @@ import { TokenStorageService } from './services/token-storage.service';
|
||||
export class AppComponent implements OnDestroy, OnInit {
|
||||
public canCreateAccount: boolean;
|
||||
public currentRoute: string;
|
||||
public isLoggedIn = false;
|
||||
public lastDataGathering: string;
|
||||
public currentYear = new Date().getFullYear();
|
||||
public deviceType: string;
|
||||
public info: InfoItem;
|
||||
public user: User;
|
||||
public version = environment.version;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private cd: ChangeDetectorRef,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private materialCssVarsService: MaterialCssVarsService,
|
||||
private router: Router,
|
||||
private tokenStorageService: TokenStorageService
|
||||
private tokenStorageService: TokenStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.initializeTheme();
|
||||
this.user = undefined;
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.dataService.fetchInfo().subscribe(({ lastDataGathering }) => {
|
||||
this.lastDataGathering = lastDataGathering
|
||||
? formatDistanceToNow(new Date(lastDataGathering), { addSuffix: true })
|
||||
: '';
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.dataService.fetchInfo().subscribe((info) => {
|
||||
this.info = info;
|
||||
});
|
||||
|
||||
this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe((test) => {
|
||||
this.currentRoute = this.router.url.toString().substring(1);
|
||||
// this.initializeTheme();
|
||||
});
|
||||
|
||||
this.tokenStorageService
|
||||
.onChangeHasToken()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.isLoggedIn = !!this.tokenStorageService.getToken();
|
||||
|
||||
if (this.isLoggedIn) {
|
||||
this.dataService.fetchUser().subscribe((user) => {
|
||||
this.user = user;
|
||||
|
||||
this.canCreateAccount = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createAccount
|
||||
);
|
||||
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
} else {
|
||||
this.user = null;
|
||||
}
|
||||
const urlTree = this.router.parseUrl(this.router.url);
|
||||
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
|
||||
const urlSegments = urlSegmentGroup.segments;
|
||||
this.currentRoute = urlSegments[0].path;
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
this.user = state.user;
|
||||
|
||||
this.canCreateAccount = hasPermission(
|
||||
this.user?.permissions,
|
||||
permissions.createUserAccount
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public onCreateAccount() {
|
||||
this.tokenStorageService.signOut();
|
||||
}
|
||||
|
||||
public onSignOut() {
|
||||
this.tokenStorageService.signOut();
|
||||
this.userService.remove();
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private initializeTheme() {
|
||||
@ -94,14 +107,4 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
||||
this.materialCssVarsService.setAccentColor(secondaryColorHex);
|
||||
}
|
||||
|
||||
public onCreateAccount() {
|
||||
this.tokenStorageService.signOut();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { Platform } from '@angular/cdk/platform';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import {
|
||||
DateAdapter,
|
||||
MAT_DATE_FORMATS,
|
||||
@ -22,7 +23,7 @@ import { AppComponent } from './app.component';
|
||||
import { GfHeaderModule } from './components/header/header.module';
|
||||
import { authInterceptorProviders } from './core/auth.interceptor';
|
||||
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
||||
import { LanguageManager } from './core/language-manager.service';
|
||||
import { LanguageService } from './core/language.service';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
@ -34,6 +35,7 @@ import { LanguageManager } from './core/language-manager.service';
|
||||
HttpClientModule,
|
||||
MarkdownModule.forRoot(),
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MaterialCssVarsModule.forRoot({
|
||||
darkThemeClass: 'is-dark-theme',
|
||||
isAutoContrast: true,
|
||||
@ -46,11 +48,11 @@ import { LanguageManager } from './core/language-manager.service';
|
||||
providers: [
|
||||
authInterceptorProviders,
|
||||
httpResponseInterceptorProviders,
|
||||
LanguageManager,
|
||||
LanguageService,
|
||||
{
|
||||
provide: DateAdapter,
|
||||
useClass: CustomDateAdapter,
|
||||
deps: [LanguageManager, MAT_DATE_LOCALE, Platform]
|
||||
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
|
||||
},
|
||||
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
|
||||
],
|
||||
|
@ -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">
|
||||
<th mat-header-cell *matHeaderCellDef i18n>User</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>User</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.granteeAlias }}
|
||||
</td></ng-container
|
||||
>
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th mat-header-cell *matHeaderCellDef i18n>Type</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||
Restricted Access
|
||||
</td></ng-container
|
||||
|
@ -1,3 +1,5 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Access } from 'apps/api/src/app/access/interfaces/access.interface';
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-access-table',
|
||||
|
@ -0,0 +1,70 @@
|
||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>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>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>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>
|
@ -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));
|
||||
}
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
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>();
|
||||
|
||||
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
|
||||
public displayedColumns = [];
|
||||
public isLoading = true;
|
||||
public routeQueryParams: Subscription;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor() {}
|
||||
|
||||
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.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();
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
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 { 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,
|
||||
MatTableModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
RouterModule
|
||||
],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfAccountsTableModule {}
|
@ -1,3 +1,9 @@
|
||||
<gf-symbol-icon
|
||||
*ngIf="symbolUrl"
|
||||
class="mr-1"
|
||||
size="large"
|
||||
[url]="symbolUrl"
|
||||
></gf-symbol-icon>
|
||||
<span class="flex-grow-1 text-truncate">{{ title }}</span>
|
||||
<button
|
||||
*ngIf="deviceType !== 'mobile'"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user