Compare commits
138 Commits
Author | SHA1 | Date | |
---|---|---|---|
3fe8f9c882 | |||
d130efad47 | |||
109f0ebd70 | |||
069ddcc6b2 | |||
f7bf6e652b | |||
eb059a024a | |||
ad88acff1c | |||
1ff736537c | |||
1fa65e1efd | |||
df6bb489c2 | |||
928a13310d | |||
2384861953 | |||
fe90bda6fb | |||
d4b29ff11c | |||
a0a26cfa58 | |||
1610150427 | |||
cff8acd7b1 | |||
0f36d6cbdb | |||
046e28b521 | |||
aba562cb35 | |||
03f2f33344 | |||
a996dd7ed5 | |||
002b883668 | |||
0b06823893 | |||
2dfd779444 | |||
1824413379 | |||
3332ade3d3 | |||
8d2e110e3d | |||
a8fcf09380 | |||
1071f446a8 | |||
03b050d1ac | |||
58eeff7001 | |||
76fb8825e4 | |||
0f9d142afe | |||
bd33855a27 | |||
5329e45e2c | |||
e990ecd12c | |||
a4fcf64f13 | |||
557e3a0676 | |||
2abe399ebd | |||
74fe90906a | |||
4cb9a3b142 | |||
0da9368e0c | |||
d2f8e3d645 | |||
5263fba64e | |||
e3689c48f8 | |||
787efdb33b | |||
e63578d8ce | |||
7cf0cdc4ce | |||
14a0eeab29 | |||
6774c48dff | |||
565947e752 | |||
2cc7c6fa1c | |||
023a7147e2 | |||
a96e89a86e | |||
b9c9443899 | |||
f1e06347d3 | |||
697e92f818 | |||
b678998801 | |||
de53cf1884 | |||
bbe30218bd | |||
15dda886a0 | |||
34d4212f55 | |||
f7060230b7 | |||
0fdafcb7e4 | |||
e79be9f2d6 | |||
69088b93a6 | |||
c3768a882d | |||
3498ed8549 | |||
c07c300fef | |||
c62a5af9eb | |||
0c04f10e19 | |||
2c4c16ec99 | |||
4711b0d1ed | |||
a8521e0ecf | |||
424748ae90 | |||
9c4d8bdf4b | |||
332203b9e2 | |||
f48832c671 | |||
ae8a203526 | |||
d0c1506ded | |||
af0863d193 | |||
f5819cc399 | |||
977c5a9544 | |||
b9cd42cd53 | |||
379977008d | |||
38f9d54705 | |||
5cb6e5dec6 | |||
4a123c38f2 | |||
160335302a | |||
f1483569a2 | |||
5391b88c42 | |||
2b63f7e707 | |||
d5c96d1cb7 | |||
1a4dc51825 | |||
d094bae7de | |||
57bf10e7e7 | |||
c1d460cead | |||
dfa67b275c | |||
80862e5c2a | |||
904d4db219 | |||
10f13eec48 | |||
ea3a9d3b79 | |||
e55b05fe3d | |||
32dd76be5f | |||
ff9b6bb4df | |||
5be95b7b63 | |||
b3e07c8446 | |||
eb9cece4e4 | |||
b331f5f04d | |||
34cbdd7c2a | |||
57314d62ee | |||
40380346e6 | |||
5622c4cf7e | |||
21173bed21 | |||
16dd8f7652 | |||
ce6b5fb7cb | |||
f6f62db830 | |||
01103f3db4 | |||
e9e9f1a124 | |||
751256f158 | |||
c2a1cbd20f | |||
04044f8720 | |||
4dc76817ce | |||
1f0bd5a7db | |||
b6cd007ad4 | |||
b4bc72c6f9 | |||
899fa0370e | |||
da27504aa1 | |||
b7bbc029ac | |||
c61a415fb2 | |||
8ff811ed28 | |||
9a2ea0a4ed | |||
bad9d17c44 | |||
ea89ca5734 | |||
8f61f7c169 | |||
edca05f542 | |||
283f054ee2 |
8
.env
8
.env
@ -3,14 +3,14 @@ COMPOSE_PROJECT_NAME=ghostfolio-development
|
|||||||
# CACHE
|
# CACHE
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||||
|
|
||||||
# POSTGRES
|
# POSTGRES
|
||||||
POSTGRES_DB=ghostfolio-db
|
POSTGRES_DB=ghostfolio-db
|
||||||
POSTGRES_USER=user
|
POSTGRES_USER=user
|
||||||
POSTGRES_PASSWORD=password
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
ACCESS_TOKEN_SALT=GHOSTFOLIO
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
ALPHA_VANTAGE_API_KEY=
|
ALPHA_VANTAGE_API_KEY=
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||||
JWT_SECRET_KEY=123456
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
PORT=3333
|
|
||||||
|
330
CHANGELOG.md
330
CHANGELOG.md
@ -5,6 +5,334 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.165.0 - 25.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an icon and name column to the positions table
|
||||||
|
- Added a reusable premium indicator component
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the positions table to a dedicated section (_Holdings_)
|
||||||
|
- Changed the data gathering by symbol endpoint to delete data first
|
||||||
|
|
||||||
|
## 1.164.0 - 23.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the positions table including performance to the public page
|
||||||
|
|
||||||
|
## 1.163.0 - 22.06.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the onboarding for iOS
|
||||||
|
|
||||||
|
## 1.162.0 - 18.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a _Privacy Policy_ page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified the header
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ILA` to `ILS`)
|
||||||
|
|
||||||
|
## 1.161.1 - 16.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the vertical hover line to inspect data points in the performance chart on the home page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the landing page
|
||||||
|
- Upgraded `angular` from version `13.3.6` to `14.0.2`
|
||||||
|
- Upgraded `Nx` from version `14.1.4` to `14.3.5`
|
||||||
|
- Upgraded `storybook` from version `6.4.22` to `6.5.9`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the error handling of missing market prices
|
||||||
|
|
||||||
|
## 1.160.0 - 15.06.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the `No data provider has been found` error in the search (regression after `envalid` upgrade to `7.3.1` in Ghostfolio `1.157.0`)
|
||||||
|
|
||||||
|
## 1.159.0 - 15.06.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the default `HOST` to `0.0.0.0`
|
||||||
|
- Refactored the endpoint of the public page (filter by equity)
|
||||||
|
|
||||||
|
## 1.158.1 - 12.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the queue jobs view in the admin control panel by a data dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Exposed the environment variable `HOST`
|
||||||
|
- Decreased the number of attempts of queue jobs from `20` to `10` (fail earlier)
|
||||||
|
- Improved the message for data provider errors in the client
|
||||||
|
- Changed the label from _Balance_ to _Cash Balance_ in the account dialog
|
||||||
|
- Restructured the documentation for self-hosting
|
||||||
|
|
||||||
|
## 1.157.0 - 11.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the queue jobs view in the admin control panel by the number of attempts and the status
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Migrated the historical market data gathering to the queue design pattern
|
||||||
|
- Refreshed the cryptocurrencies list to support more coins by default
|
||||||
|
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 180 days
|
||||||
|
- Upgraded `chart.js` from version `3.7.0` to `3.8.0`
|
||||||
|
- Upgraded `envalid` from version `7.2.1` to `7.3.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reloaded the accounts of a user after creating, editing or deleting one
|
||||||
|
- Excluded empty items in the activities filter
|
||||||
|
|
||||||
|
## 1.156.0 - 05.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the user id to the account page
|
||||||
|
- Added a new view with jobs of the queue to the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified the features page
|
||||||
|
- Restructured the _FIRE_ section
|
||||||
|
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `4.1.0` to `5.2.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the `docker-compose` files to resolve variables correctly
|
||||||
|
|
||||||
|
## 1.155.0 - 29.05.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `EOD_HISTORICAL_DATA` as a new data source type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Exposed the environment variable `REDIS_PASSWORD`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the empty state of the portfolio proportion chart component (with 2 levels)
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.154.0 - 28.05.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a vertical hover line to inspect data points in the line chart component
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the tooltips of the chart components (content and style)
|
||||||
|
- Simplified the pricing page
|
||||||
|
- Improved the rounding numbers in the twitter bot service
|
||||||
|
- Removed the dependency `round-to`
|
||||||
|
|
||||||
|
## 1.153.0 - 27.05.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the benchmarks of the markets overview by the current market condition (bear and bull market)
|
||||||
|
- Extended the twitter bot service by benchmarks
|
||||||
|
- Added value redaction for the impersonation mode in the API response as an interceptor
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the twitter bot service to rest on the weekend
|
||||||
|
- Upgraded `prisma` from version `3.12.0` to `3.14.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a styling issue in the benchmark component on mobile
|
||||||
|
|
||||||
|
## 1.152.0 - 26.05.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the _Ghostfolio_ trailer to the landing page
|
||||||
|
- Extended the markets overview by benchmarks (current change to the all time high)
|
||||||
|
|
||||||
|
## 1.151.0 - 24.05.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to set the base currency as an environment variable (`BASE_CURRENCY`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the missing conversion of countries in the symbol profile overrides
|
||||||
|
|
||||||
|
## 1.150.0 - 21.05.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Skipped data enhancer (_Trackinsight_) if data is inaccurate
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the currency conversion in the account calculations
|
||||||
|
- Fixed an issue with countries in the symbol profile overrides
|
||||||
|
|
||||||
|
## 1.149.0 - 16.05.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added groups to the activities filter component
|
||||||
|
- Added support for filtering by asset class on the allocations page
|
||||||
|
|
||||||
|
## 1.148.0 - 14.05.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported enter key press to submit the form of the create or edit transaction dialog
|
||||||
|
- Added a _Report Data Glitch_ button to the position detail dialog
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the date format of the date picker and support manual changes
|
||||||
|
- Fixed the state of the account delete button (disable if account contains activities)
|
||||||
|
- Fixed an issue in the activities filter component (typing a search term)
|
||||||
|
|
||||||
|
## 1.147.0 - 10.05.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the allocations page with no filtering (include cash positions)
|
||||||
|
|
||||||
|
## 1.146.3 - 08.05.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set up a queue for the data gathering jobs
|
||||||
|
- Set up _Nx Cloud_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Migrated the asset profile data gathering to the queue design pattern
|
||||||
|
- Improved the allocations page with no filtering
|
||||||
|
- Harmonized the _No data available_ label in the portfolio proportion chart component
|
||||||
|
- Improved the _FIRE_ calculator for the _Live Demo_
|
||||||
|
- Simplified the about page
|
||||||
|
- Upgraded `angular` from version `13.2.2` to `13.3.6`
|
||||||
|
- Upgraded `Nx` from version `13.8.5` to `14.1.4`
|
||||||
|
- Upgraded `storybook` from version `6.4.18` to `6.4.22`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Eliminated the circular dependencies in the `@ghostfolio/common` library
|
||||||
|
|
||||||
|
## 1.145.0 - 07.05.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for filtering by accounts on the allocations page
|
||||||
|
- Added support for private equity
|
||||||
|
- Extended the form to set the asset and asset sub class for (wealth) items
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the filtering (activities table and allocations page)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the tooltip update in the portfolio proportion chart component
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.144.0 - 30.04.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for commodities (via futures)
|
||||||
|
- Added support for real estate
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the layout of the position detail dialog
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.3.1` to `2.3.2`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the import validation for numbers equal 0
|
||||||
|
- Fixed the color of the spinner in the activities filter component (dark mode)
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.143.0 - 26.04.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the filtering by tags
|
||||||
|
|
||||||
|
## 1.142.0 - 25.04.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the tags to the create or edit transaction dialog
|
||||||
|
- Added the tags to the position detail dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the date to UTC in the data gathering service
|
||||||
|
- Reused the value component in the users table of the admin control panel
|
||||||
|
|
||||||
|
## 1.141.1 - 24.04.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the database migration
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.141.0 - 24.04.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a tagging system for activities
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extracted the activities table filter to a dedicated component
|
||||||
|
- Changed the url of the _Get Started_ link to `https://ghostfol.io` on the public page
|
||||||
|
- Simplified `@@id` using multiple fields with `@id` in the database schema of (`Access`, `Order`, `Subscription`)
|
||||||
|
- Upgraded `prisma` from version `3.11.1` to `3.12.0`
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.140.2 - 22.04.2022
|
## 1.140.2 - 22.04.2022
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -329,7 +657,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Upgraded `angular` from version `13.1.2` to `13.2.3`
|
- Upgraded `angular` from version `13.1.2` to `13.2.2`
|
||||||
- Upgraded `Nx` from version `13.4.1` to `13.8.1`
|
- Upgraded `Nx` from version `13.4.1` to `13.8.1`
|
||||||
- Upgraded `storybook` from version `6.4.9` to `6.4.18`
|
- Upgraded `storybook` from version `6.4.9` to `6.4.18`
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ COPY ./angular.json angular.json
|
|||||||
COPY ./nx.json nx.json
|
COPY ./nx.json nx.json
|
||||||
COPY ./replace.build.js replace.build.js
|
COPY ./replace.build.js replace.build.js
|
||||||
COPY ./jest.preset.js jest.preset.js
|
COPY ./jest.preset.js jest.preset.js
|
||||||
COPY ./jest.config.js jest.config.js
|
COPY ./jest.config.ts jest.config.ts
|
||||||
COPY ./tsconfig.base.json tsconfig.base.json
|
COPY ./tsconfig.base.json tsconfig.base.json
|
||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
COPY ./apps apps
|
COPY ./apps apps
|
||||||
|
62
README.md
62
README.md
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<h1>Ghostfolio</h1>
|
<h1>Ghostfolio</h1>
|
||||||
<p>
|
<p>
|
||||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
<strong>Open Source Wealth Management Software</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||||
@ -24,17 +24,18 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
||||||
|
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -47,7 +48,7 @@ Ghostfolio is for you if you are...
|
|||||||
- 🧘 into minimalism
|
- 🧘 into minimalism
|
||||||
- 🧺 caring about diversifying your financial resources
|
- 🧺 caring about diversifying your financial resources
|
||||||
- 🆓 interested in financial independence
|
- 🆓 interested in financial independence
|
||||||
- 🙅 saying no to spreadsheets in 2021
|
- 🙅 saying no to spreadsheets in 2022
|
||||||
- 😎 still reading this list
|
- 😎 still reading this list
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@ -62,6 +63,10 @@ Ghostfolio is for you if you are...
|
|||||||
- ✅ Zen Mode
|
- ✅ Zen Mode
|
||||||
- ✅ Mobile-first design
|
- ✅ Mobile-first design
|
||||||
|
|
||||||
|
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||||
|
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||||
|
</div>
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
|
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
|
||||||
@ -74,47 +79,50 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
## Run with Docker (self-hosting)
|
## Self-hosting
|
||||||
|
|
||||||
### Prerequisites
|
### Run with Docker Compose
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
#### Prerequisites
|
||||||
- A local copy of this Git repository (clone)
|
|
||||||
|
|
||||||
### a. Run environment
|
- Basic knowledge of Docker
|
||||||
|
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
- Local copy of this Git repository (clone)
|
||||||
|
|
||||||
|
#### a. Run environment
|
||||||
|
|
||||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup Database
|
##### Setup Database
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### b. Build and run environment
|
#### b. Build and run environment
|
||||||
|
|
||||||
Run the following commands to build and start the Docker images:
|
Run the following commands to build and start the Docker images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose.build.yml build
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||||
docker-compose -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup Database
|
##### Setup Database
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fetch Historical Data
|
#### Fetch Historical Data
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
|
|
||||||
@ -122,11 +130,15 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
||||||
|
|
||||||
|
### Run with _Unraid_ (Community)
|
||||||
|
|
||||||
|
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@ -140,7 +152,7 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
@ -174,7 +186,7 @@ yarn database:push
|
|||||||
|
|
||||||
Run `yarn test`
|
Run `yarn test`
|
||||||
|
|
||||||
## Public API (experimental)
|
## Public API
|
||||||
|
|
||||||
### Import Activities
|
### Import Activities
|
||||||
|
|
||||||
|
36
angular.json
36
angular.json
@ -2,6 +2,7 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"projects": {
|
"projects": {
|
||||||
"api": {
|
"api": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/api",
|
"root": "apps/api",
|
||||||
"sourceRoot": "apps/api/src",
|
"sourceRoot": "apps/api/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -47,7 +48,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@nrwl/jest:jest",
|
"builder": "@nrwl/jest:jest",
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "apps/api/jest.config.js",
|
"jestConfig": "apps/api/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/api"]
|
"outputs": ["coverage/apps/api"]
|
||||||
@ -56,6 +57,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
@ -180,7 +182,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@nrwl/jest:jest",
|
"builder": "@nrwl/jest:jest",
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "apps/client/jest.config.js",
|
"jestConfig": "apps/client/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/client"]
|
"outputs": ["coverage/apps/client"]
|
||||||
@ -189,6 +191,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"client-e2e": {
|
"client-e2e": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/client-e2e",
|
"root": "apps/client-e2e",
|
||||||
"sourceRoot": "apps/client-e2e/src",
|
"sourceRoot": "apps/client-e2e/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -211,6 +214,7 @@
|
|||||||
"implicitDependencies": ["client"]
|
"implicitDependencies": ["client"]
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "libs/common",
|
"root": "libs/common",
|
||||||
"sourceRoot": "libs/common/src",
|
"sourceRoot": "libs/common/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
@ -225,7 +229,7 @@
|
|||||||
"builder": "@nrwl/jest:jest",
|
"builder": "@nrwl/jest:jest",
|
||||||
"outputs": ["coverage/libs/common"],
|
"outputs": ["coverage/libs/common"],
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "libs/common/jest.config.js",
|
"jestConfig": "libs/common/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -233,6 +237,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
@ -247,7 +252,7 @@
|
|||||||
"builder": "@nrwl/jest:jest",
|
"builder": "@nrwl/jest:jest",
|
||||||
"outputs": ["coverage/libs/ui"],
|
"outputs": ["coverage/libs/ui"],
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "libs/ui/jest.config.js",
|
"jestConfig": "libs/ui/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -258,14 +263,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storybook": {
|
"storybook": {
|
||||||
"builder": "@nrwl/storybook:storybook",
|
"builder": "@storybook/angular:start-storybook",
|
||||||
"options": {
|
"options": {
|
||||||
"uiFramework": "@storybook/angular",
|
|
||||||
"port": 4400,
|
"port": 4400,
|
||||||
"config": {
|
"configDir": "libs/ui/.storybook",
|
||||||
"configFolder": "libs/ui/.storybook"
|
"browserTarget": "ui:build-storybook",
|
||||||
},
|
"compodoc": false
|
||||||
"projectBuildConfig": "ui:build-storybook"
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"ci": {
|
"ci": {
|
||||||
@ -274,15 +277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"build-storybook": {
|
"build-storybook": {
|
||||||
"builder": "@nrwl/storybook:build",
|
"builder": "@storybook/angular:build-storybook",
|
||||||
"outputs": ["{options.outputPath}"],
|
"outputs": ["{options.outputPath}"],
|
||||||
"options": {
|
"options": {
|
||||||
"uiFramework": "@storybook/angular",
|
"outputDir": "dist/storybook/ui",
|
||||||
"outputPath": "dist/storybook/ui",
|
"configDir": "libs/ui/.storybook",
|
||||||
"config": {
|
"browserTarget": "ui:build-storybook",
|
||||||
"configFolder": "libs/ui/.storybook"
|
"compodoc": false
|
||||||
},
|
|
||||||
"projectBuildConfig": "ui:build-storybook"
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"ci": {
|
"ci": {
|
||||||
@ -294,6 +295,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui-e2e": {
|
"ui-e2e": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/ui-e2e",
|
"root": "apps/ui-e2e",
|
||||||
"sourceRoot": "apps/ui-e2e/src",
|
"sourceRoot": "apps/ui-e2e/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
displayName: 'api',
|
displayName: 'api',
|
||||||
preset: '../../jest.preset.js',
|
|
||||||
globals: {
|
globals: {
|
||||||
'ts-jest': {
|
'ts-jest': {
|
||||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||||
@ -12,5 +12,6 @@ module.exports = {
|
|||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
testTimeout: 10000,
|
testTimeout: 10000,
|
||||||
testEnvironment: 'node'
|
testEnvironment: 'node',
|
||||||
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
@ -78,8 +78,12 @@ export class AccessController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||||
|
const access = await this.accessService.access({ id });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess)
|
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
|
||||||
|
!access ||
|
||||||
|
access.userId !== this.request.user.id
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -88,10 +92,7 @@ export class AccessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.accessService.deleteAccess({
|
return this.accessService.deleteAccess({
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
|
|
||||||
@ -102,22 +104,43 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCashDetails(
|
public async getCashDetails({
|
||||||
aUserId: string,
|
currency,
|
||||||
aCurrency: string
|
filters = [],
|
||||||
): Promise<CashDetails> {
|
userId
|
||||||
|
}: {
|
||||||
|
currency: string;
|
||||||
|
filters?: Filter[];
|
||||||
|
userId: string;
|
||||||
|
}): Promise<CashDetails> {
|
||||||
let totalCashBalanceInBaseCurrency = new Big(0);
|
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||||
|
|
||||||
const accounts = await this.accounts({
|
const where: Prisma.AccountWhereInput = { userId };
|
||||||
where: { userId: aUserId }
|
|
||||||
|
const {
|
||||||
|
ACCOUNT: filtersByAccount,
|
||||||
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
|
TAG: filtersByTag
|
||||||
|
} = groupBy(filters, (filter) => {
|
||||||
|
return filter.type;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (filtersByAccount?.length > 0) {
|
||||||
|
where.id = {
|
||||||
|
in: filtersByAccount.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await this.accounts({ where });
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
|
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
account.balance,
|
account.balance,
|
||||||
account.currency,
|
account.currency,
|
||||||
aCurrency
|
currency
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
|
import {
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -56,6 +60,24 @@ export class AdminController {
|
|||||||
return this.adminService.get();
|
return this.adminService.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gather7Days(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gather7Days();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
@ -71,10 +93,20 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.gatherProfileData();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
this.dataGatheringService.gatherMax();
|
|
||||||
|
|
||||||
return;
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@ -92,9 +124,18 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
return;
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@ -115,9 +156,14 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
return;
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
|
@ -11,6 +11,7 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
import { QueueModule } from './queue/queue.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -21,6 +22,7 @@ import { AdminService } from './admin.service';
|
|||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
QueueModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
|
@ -6,7 +6,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -15,11 +15,13 @@ import {
|
|||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Property } from '@prisma/client';
|
import { Property } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
@ -29,7 +31,9 @@ export class AdminService {
|
|||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
@ -38,25 +42,22 @@ export class AdminService {
|
|||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
return {
|
return {
|
||||||
dataGatheringProgress:
|
|
||||||
await this.dataGatheringService.getDataGatheringProgress(),
|
|
||||||
exchangeRates: this.exchangeRateDataService
|
exchangeRates: this.exchangeRateDataService
|
||||||
.getCurrencies()
|
.getCurrencies()
|
||||||
.filter((currency) => {
|
.filter((currency) => {
|
||||||
return currency !== baseCurrency;
|
return currency !== this.baseCurrency;
|
||||||
})
|
})
|
||||||
.map((currency) => {
|
.map((currency) => {
|
||||||
return {
|
return {
|
||||||
label1: baseCurrency,
|
label1: this.baseCurrency,
|
||||||
label2: currency,
|
label2: currency,
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
1,
|
1,
|
||||||
baseCurrency,
|
this.baseCurrency,
|
||||||
currency
|
currency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
|
||||||
settings: await this.propertyService.get(),
|
settings: await this.propertyService.get(),
|
||||||
transactionCount: await this.prismaService.order.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
userCount: await this.prismaService.user.count(),
|
userCount: await this.prismaService.user.count(),
|
||||||
@ -157,30 +158,11 @@ export class AdminService {
|
|||||||
|
|
||||||
if (key === PROPERTY_CURRENCIES) {
|
if (key === PROPERTY_CURRENCIES) {
|
||||||
await this.exchangeRateDataService.initialize();
|
await this.exchangeRateDataService.initialize();
|
||||||
await this.dataGatheringService.reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
|
||||||
const lastDataGathering =
|
|
||||||
await this.dataGatheringService.getLastDataGathering();
|
|
||||||
|
|
||||||
if (lastDataGathering) {
|
|
||||||
return lastDataGathering;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataGatheringInProgress =
|
|
||||||
await this.dataGatheringService.getIsInProgress();
|
|
||||||
|
|
||||||
if (dataGatheringInProgress) {
|
|
||||||
return 'IN_PROGRESS';
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
|
87
apps/api/src/app/admin/queue/queue.controller.ts
Normal file
87
apps/api/src/app/admin/queue/queue.controller.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { JobStatus } from 'bull';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
|
@Controller('admin/queue')
|
||||||
|
export class QueueController {
|
||||||
|
public constructor(
|
||||||
|
private readonly queueService: QueueService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Delete('job')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteJobs(
|
||||||
|
@Query('status') filterByStatus?: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
|
return this.queueService.deleteJobs({ status });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('job')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getJobs(
|
||||||
|
@Query('status') filterByStatus?: string
|
||||||
|
): Promise<AdminJobs> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
|
return this.queueService.getJobs({ status });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('job/:id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.queueService.deleteJob(id);
|
||||||
|
}
|
||||||
|
}
|
12
apps/api/src/app/admin/queue/queue.module.ts
Normal file
12
apps/api/src/app/admin/queue/queue.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueueController } from './queue.controller';
|
||||||
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [QueueController],
|
||||||
|
imports: [DataGatheringModule],
|
||||||
|
providers: [QueueService]
|
||||||
|
})
|
||||||
|
export class QueueModule {}
|
65
apps/api/src/app/admin/queue/queue.service.ts
Normal file
65
apps/api/src/app/admin/queue/queue.service.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
DATA_GATHERING_QUEUE,
|
||||||
|
QUEUE_JOB_STATUS_LIST
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { JobStatus, Queue } from 'bull';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QueueService {
|
||||||
|
public constructor(
|
||||||
|
@InjectQueue(DATA_GATHERING_QUEUE)
|
||||||
|
private readonly dataGatheringQueue: Queue
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async deleteJob(aId: string) {
|
||||||
|
return (await this.dataGatheringQueue.getJob(aId))?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteJobs({
|
||||||
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
|
}: {
|
||||||
|
status?: JobStatus[];
|
||||||
|
}) {
|
||||||
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
try {
|
||||||
|
await job.remove();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(error, 'QueueService');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getJobs({
|
||||||
|
limit = 1000,
|
||||||
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
|
}: {
|
||||||
|
limit?: number;
|
||||||
|
status?: JobStatus[];
|
||||||
|
}): Promise<AdminJobs> {
|
||||||
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
|
const jobsWithState = await Promise.all(
|
||||||
|
jobs.slice(0, limit).map(async (job) => {
|
||||||
|
return {
|
||||||
|
attemptsMade: job.attemptsMade + 1,
|
||||||
|
data: job.data,
|
||||||
|
finishedOn: job.finishedOn,
|
||||||
|
id: job.id,
|
||||||
|
name: job.name,
|
||||||
|
stacktrace: job.stacktrace,
|
||||||
|
state: await job.getState(),
|
||||||
|
timestamp: job.timestamp
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs: jobsWithState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,26 +1,6 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache/redis-cache.service';
|
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
public constructor(
|
public constructor() {}
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
|
||||||
private readonly redisCacheService: RedisCacheService
|
|
||||||
) {
|
|
||||||
this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initialize() {
|
|
||||||
this.redisCacheService.reset();
|
|
||||||
|
|
||||||
const isDataGatheringInProgress =
|
|
||||||
await this.dataGatheringService.getIsInProgress();
|
|
||||||
|
|
||||||
if (isDataGatheringInProgress) {
|
|
||||||
// Prepare for automatical data gathering, if hung up in progress state
|
|
||||||
await this.dataGatheringService.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
|||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
@ -19,6 +20,7 @@ import { AccountModule } from './account/account.module';
|
|||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
@ -36,6 +38,14 @@ import { UserModule } from './user/user.module';
|
|||||||
AccountModule,
|
AccountModule,
|
||||||
AuthDeviceModule,
|
AuthDeviceModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
BenchmarkModule,
|
||||||
|
BullModule.forRoot({
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST,
|
||||||
|
port: parseInt(process.env.REDIS_PORT, 10),
|
||||||
|
password: process.env.REDIS_PASSWORD
|
||||||
|
}
|
||||||
|
}),
|
||||||
CacheModule,
|
CacheModule,
|
||||||
ConfigModule.forRoot(),
|
ConfigModule.forRoot(),
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
|
32
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
32
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
|
||||||
|
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
|
@Controller('benchmark')
|
||||||
|
export class BenchmarkController {
|
||||||
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
|
private readonly propertyService: PropertyService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||||
|
const benchmarkAssets: UniqueAsset[] =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
)) as UniqueAsset[]) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
25
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
25
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BenchmarkController } from './benchmark.controller';
|
||||||
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BenchmarkController],
|
||||||
|
exports: [BenchmarkService],
|
||||||
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataProviderModule,
|
||||||
|
MarketDataModule,
|
||||||
|
PropertyModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
|
providers: [BenchmarkService]
|
||||||
|
})
|
||||||
|
export class BenchmarkModule {}
|
84
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
84
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BenchmarkService {
|
||||||
|
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly redisCacheService: RedisCacheService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getBenchmarks(
|
||||||
|
benchmarkAssets: UniqueAsset[]
|
||||||
|
): Promise<BenchmarkResponse['benchmarks']> {
|
||||||
|
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
benchmarks = JSON.parse(
|
||||||
|
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (benchmarks) {
|
||||||
|
return benchmarks;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const promises: Promise<number>[] = [];
|
||||||
|
|
||||||
|
const [quotes, assetProfiles] = await Promise.all([
|
||||||
|
this.dataProviderService.getQuotes(benchmarkAssets),
|
||||||
|
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const benchmarkAsset of benchmarkAssets) {
|
||||||
|
promises.push(this.marketDataService.getMax(benchmarkAsset));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTimeHighs = await Promise.all(promises);
|
||||||
|
|
||||||
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
|
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
|
||||||
|
|
||||||
|
const performancePercentFromAllTimeHigh = new Big(marketPrice)
|
||||||
|
.div(allTimeHigh)
|
||||||
|
.minus(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketCondition: this.getMarketCondition(
|
||||||
|
performancePercentFromAllTimeHigh
|
||||||
|
),
|
||||||
|
name: assetProfiles.find(({ dataSource, symbol }) => {
|
||||||
|
return (
|
||||||
|
dataSource === benchmarkAssets[index].dataSource &&
|
||||||
|
symbol === benchmarkAssets[index].symbol
|
||||||
|
);
|
||||||
|
})?.name,
|
||||||
|
performances: {
|
||||||
|
allTimeHigh: {
|
||||||
|
performancePercent: performancePercentFromAllTimeHigh.toNumber()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.redisCacheService.set(
|
||||||
|
this.CACHE_KEY_BENCHMARKS,
|
||||||
|
JSON.stringify(benchmarks)
|
||||||
|
);
|
||||||
|
|
||||||
|
return benchmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMarketCondition(aPerformanceInPercent: Big) {
|
||||||
|
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||||
|
}
|
||||||
|
}
|
30
apps/api/src/app/cache/cache.controller.ts
vendored
30
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,25 +1,39 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Post,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@Controller('cache')
|
@Controller('cache')
|
||||||
export class CacheController {
|
export class CacheController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly cacheService: CacheService,
|
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {
|
) {}
|
||||||
this.redisCacheService.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('flush')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
this.redisCacheService.reset();
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.cacheService.flush();
|
return this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
5
apps/api/src/app/cache/cache.module.ts
vendored
5
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,4 +1,3 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
@ -11,7 +10,6 @@ import { Module } from '@nestjs/common';
|
|||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: [CacheService],
|
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
@ -21,7 +19,6 @@ import { CacheController } from './cache.controller';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
]
|
||||||
providers: [CacheService]
|
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
15
apps/api/src/app/cache/cache.service.ts
vendored
15
apps/api/src/app/cache/cache.service.ts
vendored
@ -1,15 +0,0 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CacheService {
|
|
||||||
public constructor(
|
|
||||||
private readonly dataGaterhingService: DataGatheringService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async flush(): Promise<void> {
|
|
||||||
await this.dataGaterhingService.reset();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -26,7 +27,8 @@ import { InfoService } from './info.service';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule,
|
||||||
|
TagModule
|
||||||
],
|
],
|
||||||
providers: [InfoService]
|
providers: [InfoService]
|
||||||
})
|
})
|
||||||
|
@ -4,6 +4,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEMO_USER_ID,
|
DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
@ -33,7 +34,8 @@ export class InfoService {
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService
|
private readonly redisCacheService: RedisCacheService,
|
||||||
|
private readonly tagService: TagService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
@ -101,11 +103,12 @@ export class InfoService {
|
|||||||
isReadOnlyMode,
|
isReadOnlyMode,
|
||||||
platforms,
|
platforms,
|
||||||
systemMessage,
|
systemMessage,
|
||||||
|
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: this.getDemoAuthToken(),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions()
|
subscriptions: await this.getSubscriptions(),
|
||||||
|
tags: await this.tagService.get()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,13 +214,6 @@ export class InfoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
|
||||||
const lastDataGathering =
|
|
||||||
await this.dataGatheringService.getLastDataGathering();
|
|
||||||
|
|
||||||
return lastDataGathering ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getStatistics() {
|
private async getStatistics() {
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { DataSource, Type } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
@ -10,14 +10,22 @@ import {
|
|||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
accountId: string;
|
accountId?: string;
|
||||||
|
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsEnum(DataSource, { each: true })
|
@IsEnum(DataSource, { each: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
dataSource: DataSource;
|
dataSource?: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
date: string;
|
date: string;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
@ -42,8 +43,12 @@ export class OrderController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||||
|
const order = await this.orderService.order({ id });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
|
||||||
|
!order ||
|
||||||
|
order.userId !== this.request.user.id
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -52,15 +57,13 @@ export class OrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.orderService.deleteOrder({
|
return this.orderService.deleteOrder({
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
@ -135,23 +138,15 @@ export class OrderController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalOrder = await this.orderService.order({
|
const originalOrder = await this.orderService.order({
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!originalOrder) {
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
|
||||||
|
!originalOrder ||
|
||||||
|
originalOrder.userId !== this.request.user.id
|
||||||
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -178,15 +173,17 @@ export class OrderController {
|
|||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
assetClass: data.assetClass,
|
||||||
|
assetSubClass: data.assetSubClass,
|
||||||
|
name: data.symbol
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
User: { connect: { id: this.request.user.id } }
|
User: { connect: { id: this.request.user.id } }
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,26 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import {
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Order,
|
||||||
|
Prisma,
|
||||||
|
Type as TypeOfOrder
|
||||||
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { Activity } from './interfaces/activities.interface';
|
import { Activity } from './interfaces/activities.interface';
|
||||||
@ -17,9 +29,8 @@ import { Activity } from './interfaces/activities.interface';
|
|||||||
export class OrderService {
|
export class OrderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly cacheService: CacheService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
@ -55,6 +66,8 @@ export class OrderService {
|
|||||||
public async createOrder(
|
public async createOrder(
|
||||||
data: Prisma.OrderCreateInput & {
|
data: Prisma.OrderCreateInput & {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
@ -77,6 +90,8 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM') {
|
||||||
|
const assetClass = data.assetClass;
|
||||||
|
const assetSubClass = data.assetSubClass;
|
||||||
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||||
const dataSource: DataSource = 'MANUAL';
|
const dataSource: DataSource = 'MANUAL';
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
@ -84,6 +99,8 @@ export class OrderService {
|
|||||||
|
|
||||||
Account = undefined;
|
Account = undefined;
|
||||||
data.id = id;
|
data.id = id;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||||
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
||||||
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||||
data.SymbolProfile.connectOrCreate.create.name = name;
|
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||||
@ -97,12 +114,14 @@ export class OrderService {
|
|||||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.gatherProfileData([
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
{
|
{
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
}
|
},
|
||||||
]);
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
@ -117,9 +136,9 @@ export class OrderService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cacheService.flush();
|
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
|
delete data.assetClass;
|
||||||
|
delete data.assetSubClass;
|
||||||
delete data.currency;
|
delete data.currency;
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
@ -151,11 +170,13 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
types?: TypeOfOrder[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
@ -163,10 +184,64 @@ export class OrderService {
|
|||||||
}): Promise<Activity[]> {
|
}): Promise<Activity[]> {
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
|
const {
|
||||||
|
ACCOUNT: filtersByAccount,
|
||||||
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
|
TAG: filtersByTag
|
||||||
|
} = groupBy(filters, (filter) => {
|
||||||
|
return filter.type;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtersByAccount?.length > 0) {
|
||||||
|
where.accountId = {
|
||||||
|
in: filtersByAccount.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (includeDrafts === false) {
|
if (includeDrafts === false) {
|
||||||
where.isDraft = false;
|
where.isDraft = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filtersByAssetClass?.length > 0) {
|
||||||
|
where.SymbolProfile = {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: filtersByAssetClass.map(({ id }) => {
|
||||||
|
return { assetClass: AssetClass[id] };
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SymbolProfileOverrides: {
|
||||||
|
is: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SymbolProfileOverrides: {
|
||||||
|
OR: filtersByAssetClass.map(({ id }) => {
|
||||||
|
return { assetClass: AssetClass[id] };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtersByTag?.length > 0) {
|
||||||
|
where.tags = {
|
||||||
|
some: {
|
||||||
|
OR: filtersByTag.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
where.OR = types.map((type) => {
|
where.OR = types.map((type) => {
|
||||||
return {
|
return {
|
||||||
@ -188,7 +263,8 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
SymbolProfile: true
|
SymbolProfile: true,
|
||||||
|
tags: true
|
||||||
},
|
},
|
||||||
orderBy: { date: 'asc' }
|
orderBy: { date: 'asc' }
|
||||||
})
|
})
|
||||||
@ -217,6 +293,8 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
}: {
|
}: {
|
||||||
data: Prisma.OrderUpdateInput & {
|
data: Prisma.OrderUpdateInput & {
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
@ -230,10 +308,10 @@ export class OrderService {
|
|||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM') {
|
||||||
const name = data.SymbolProfile.connect.dataSource_symbol.symbol;
|
delete data.SymbolProfile.connect;
|
||||||
|
|
||||||
data.SymbolProfile = { update: { name } };
|
|
||||||
} else {
|
} else {
|
||||||
|
delete data.SymbolProfile.update;
|
||||||
|
|
||||||
isDraft = isAfter(data.date as Date, endOfToday());
|
isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
@ -248,8 +326,8 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cacheService.flush();
|
delete data.assetClass;
|
||||||
|
delete data.assetSubClass;
|
||||||
delete data.currency;
|
delete data.currency;
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
|
@ -1,10 +1,24 @@
|
|||||||
import { DataSource, Type } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
|
||||||
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator';
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsISO8601,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateOrderDto {
|
export class UpdateOrderDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountId: string;
|
accountId?: string;
|
||||||
|
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
function mockGetValue(symbol: string, date: Date) {
|
function mockGetValue(symbol: string, date: Date) {
|
||||||
@ -33,8 +34,11 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CurrentRateServiceMock = {
|
export const CurrentRateServiceMock = {
|
||||||
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
|
getValues: ({
|
||||||
const result = [];
|
dataGatheringItems,
|
||||||
|
dateQuery
|
||||||
|
}: GetValuesParams): Promise<GetValueObject[]> => {
|
||||||
|
const result: GetValueObject[] = [];
|
||||||
if (dateQuery.lt) {
|
if (dateQuery.lt) {
|
||||||
for (
|
for (
|
||||||
let date = resetHours(dateQuery.gte);
|
let date = resetHours(dateQuery.gte);
|
||||||
@ -44,8 +48,10 @@ export const CurrentRateServiceMock = {
|
|||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
result.push({
|
||||||
date,
|
date,
|
||||||
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
.marketPrice,
|
dataGatheringItem.symbol,
|
||||||
|
date
|
||||||
|
).marketPrice,
|
||||||
symbol: dataGatheringItem.symbol
|
symbol: dataGatheringItem.symbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -55,8 +61,10 @@ export const CurrentRateServiceMock = {
|
|||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
result.push({
|
||||||
date,
|
date,
|
||||||
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
.marketPrice,
|
dataGatheringItem.symbol,
|
||||||
|
date
|
||||||
|
).marketPrice,
|
||||||
symbol: dataGatheringItem.symbol
|
symbol: dataGatheringItem.symbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
|
|||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -73,7 +74,12 @@ describe('CurrentRateService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataProviderService = new DataProviderService(null, [], null);
|
dataProviderService = new DataProviderService(null, [], null);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
marketDataService = new MarketDataService(null);
|
marketDataService = new MarketDataService(null);
|
||||||
|
|
||||||
await exchangeRateDataService.initialize();
|
await exchangeRateDataService.initialize();
|
||||||
@ -96,15 +102,15 @@ describe('CurrentRateService', () => {
|
|||||||
},
|
},
|
||||||
userCurrency: 'CHF'
|
userCurrency: 'CHF'
|
||||||
})
|
})
|
||||||
).toMatchObject([
|
).toMatchObject<GetValueObject[]>([
|
||||||
{
|
{
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPrice: 1841.823902,
|
marketPriceInBaseCurrency: 1841.823902,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPrice: 1847.839966,
|
marketPriceInBaseCurrency: 1847.839966,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
@ -28,13 +28,7 @@ export class CurrentRateService {
|
|||||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||||
|
|
||||||
const promises: Promise<
|
const promises: Promise<GetValueObject[]>[] = [];
|
||||||
{
|
|
||||||
date: Date;
|
|
||||||
marketPrice: number;
|
|
||||||
symbol: string;
|
|
||||||
}[]
|
|
||||||
>[] = [];
|
|
||||||
|
|
||||||
if (includeToday) {
|
if (includeToday) {
|
||||||
const today = resetHours(new Date());
|
const today = resetHours(new Date());
|
||||||
@ -42,16 +36,17 @@ export class CurrentRateService {
|
|||||||
this.dataProviderService
|
this.dataProviderService
|
||||||
.getQuotes(dataGatheringItems)
|
.getQuotes(dataGatheringItems)
|
||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result = [];
|
const result: GetValueObject[] = [];
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
result.push({
|
||||||
date: today,
|
date: today,
|
||||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
marketPriceInBaseCurrency:
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
|
this.exchangeRateDataService.toCurrency(
|
||||||
0,
|
dataResultProvider?.[dataGatheringItem.symbol]
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
?.marketPrice ?? 0,
|
||||||
userCurrency
|
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||||
),
|
userCurrency
|
||||||
|
),
|
||||||
symbol: dataGatheringItem.symbol
|
symbol: dataGatheringItem.symbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -74,11 +69,12 @@ export class CurrentRateService {
|
|||||||
return data.map((marketDataItem) => {
|
return data.map((marketDataItem) => {
|
||||||
return {
|
return {
|
||||||
date: marketDataItem.date,
|
date: marketDataItem.date,
|
||||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
marketPriceInBaseCurrency:
|
||||||
marketDataItem.marketPrice,
|
this.exchangeRateDataService.toCurrency(
|
||||||
currencies[marketDataItem.symbol],
|
marketDataItem.marketPrice,
|
||||||
userCurrency
|
currencies[marketDataItem.symbol],
|
||||||
),
|
userCurrency
|
||||||
|
),
|
||||||
symbol: marketDataItem.symbol
|
symbol: marketDataItem.symbol
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export interface GetValueObject {
|
export interface GetValueObject {
|
||||||
date: Date;
|
date: Date;
|
||||||
marketPrice: number;
|
marketPriceInBaseCurrency: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
import {
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
HistoricalDataItem
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
@ -16,6 +20,7 @@ export interface PortfolioPositionDetail {
|
|||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
SymbolProfile: EnhancedSymbolProfile;
|
SymbolProfile: EnhancedSymbolProfile;
|
||||||
|
tags: Tag[];
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
@ -25,10 +30,3 @@ export interface HistoricalDataContainer {
|
|||||||
isAllTimeLow: boolean;
|
isAllTimeLow: boolean;
|
||||||
items: HistoricalDataItem[];
|
items: HistoricalDataItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoricalDataItem {
|
|
||||||
averagePrice?: number;
|
|
||||||
date: string;
|
|
||||||
grossPerformancePercent?: number;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
@ -56,7 +56,7 @@ export class PortfolioCalculator {
|
|||||||
this.currentRateService = currentRateService;
|
this.currentRateService = currentRateService;
|
||||||
this.orders = orders;
|
this.orders = orders;
|
||||||
|
|
||||||
this.orders.sort((a, b) => a.date.localeCompare(b.date));
|
this.orders.sort((a, b) => a.date?.localeCompare(b.date));
|
||||||
}
|
}
|
||||||
|
|
||||||
public computeTransactionPoints() {
|
public computeTransactionPoints() {
|
||||||
@ -125,7 +125,7 @@ export class PortfolioCalculator {
|
|||||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||||
);
|
);
|
||||||
newItems.push(currentTransactionPointItem);
|
newItems.push(currentTransactionPointItem);
|
||||||
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
newItems.sort((a, b) => a.symbol?.localeCompare(b.symbol));
|
||||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
||||||
lastTransactionPoint = {
|
lastTransactionPoint = {
|
||||||
date: currentDate,
|
date: currentDate,
|
||||||
@ -231,9 +231,9 @@ export class PortfolioCalculator {
|
|||||||
if (!marketSymbolMap[date]) {
|
if (!marketSymbolMap[date]) {
|
||||||
marketSymbolMap[date] = {};
|
marketSymbolMap[date] = {};
|
||||||
}
|
}
|
||||||
if (marketSymbol.marketPrice) {
|
if (marketSymbol.marketPriceInBaseCurrency) {
|
||||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||||
marketSymbol.marketPrice
|
marketSymbol.marketPriceInBaseCurrency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -548,9 +548,9 @@ export class PortfolioCalculator {
|
|||||||
if (!marketSymbolMap[date]) {
|
if (!marketSymbolMap[date]) {
|
||||||
marketSymbolMap[date] = {};
|
marketSymbolMap[date] = {};
|
||||||
}
|
}
|
||||||
if (marketSymbol.marketPrice) {
|
if (marketSymbol.marketPriceInBaseCurrency) {
|
||||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||||
marketSymbol.marketPrice
|
marketSymbol.marketPriceInBaseCurrency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,14 @@ import {
|
|||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
Filter,
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
@ -19,7 +20,7 @@ import {
|
|||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -42,6 +43,8 @@ import { PortfolioService } from './portfolio.service';
|
|||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
@ -49,7 +52,9 @@ export class PortfolioController {
|
|||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
@Get('chart')
|
@Get('chart')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@ -102,18 +107,48 @@ export class PortfolioController {
|
|||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') range?: DateRange,
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
|
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||||
|
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||||
|
const tagIds = filterByTags?.split(',') ?? [];
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
...accountIds.map((accountId) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: accountId,
|
||||||
|
type: 'ACCOUNT'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...assetClasses.map((assetClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetClass,
|
||||||
|
type: 'ASSET_CLASS'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...tagIds.map((tagId) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: tagId,
|
||||||
|
type: 'TAG'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioService.getDetails(
|
await this.portfolioService.getDetails(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id,
|
this.request.user.id,
|
||||||
range
|
range,
|
||||||
|
filters
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
@ -159,7 +194,11 @@ export class PortfolioController {
|
|||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.subscription.type === 'Basic';
|
this.request.user.subscription.type === 'Basic';
|
||||||
|
|
||||||
return { accounts, hasError, holdings: isBasicUser ? {} : holdings };
|
return {
|
||||||
|
accounts,
|
||||||
|
hasError,
|
||||||
|
holdings: isBasicUser ? {} : holdings
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('investments')
|
@Get('investments')
|
||||||
@ -277,7 +316,9 @@ export class PortfolioController {
|
|||||||
|
|
||||||
const { holdings } = await this.portfolioService.getDetails(
|
const { holdings } = await this.portfolioService.getDetails(
|
||||||
access.userId,
|
access.userId,
|
||||||
access.userId
|
access.userId,
|
||||||
|
'max',
|
||||||
|
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
||||||
);
|
);
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
@ -286,30 +327,28 @@ export class PortfolioController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const totalValue = Object.values(holdings)
|
const totalValue = Object.values(holdings)
|
||||||
.filter((holding) => {
|
|
||||||
return holding.assetClass === 'EQUITY';
|
|
||||||
})
|
|
||||||
.map((portfolioPosition) => {
|
.map((portfolioPosition) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
portfolioPosition.currency,
|
portfolioPosition.currency,
|
||||||
this.request.user?.Settings?.currency ?? baseCurrency
|
this.request.user?.Settings?.currency ?? this.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
if (portfolioPosition.assetClass === 'EQUITY') {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
allocationCurrent: portfolioPosition.value / totalValue,
|
||||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
currency: portfolioPosition.currency,
|
||||||
currency: portfolioPosition.currency,
|
markets: portfolioPosition.markets,
|
||||||
markets: portfolioPosition.markets,
|
name: portfolioPosition.name,
|
||||||
name: portfolioPosition.name,
|
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
value: portfolioPosition.value / totalValue
|
symbol: portfolioPosition.symbol,
|
||||||
};
|
url: portfolioPosition.url,
|
||||||
}
|
value: portfolioPosition.value / totalValue
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolioPublicDetails;
|
return portfolioPublicDetails;
|
||||||
|
@ -15,20 +15,21 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap
|
|||||||
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
||||||
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
|
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
|
||||||
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
|
||||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
ASSET_SUB_CLASS_EMERGENCY_FUND,
|
ASSET_SUB_CLASS_EMERGENCY_FUND,
|
||||||
UNKNOWN_KEY,
|
UNKNOWN_KEY
|
||||||
baseCurrency
|
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
Accounts,
|
Accounts,
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
Filter,
|
||||||
|
HistoricalDataItem,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
@ -46,7 +47,12 @@ import type {
|
|||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
import {
|
||||||
|
AssetClass,
|
||||||
|
DataSource,
|
||||||
|
Tag,
|
||||||
|
Type as TypeOfOrder
|
||||||
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
differenceInDays,
|
differenceInDays,
|
||||||
@ -62,11 +68,10 @@ import {
|
|||||||
subDays,
|
subDays,
|
||||||
subYears
|
subYears
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty, sortBy } from 'lodash';
|
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HistoricalDataContainer,
|
HistoricalDataContainer,
|
||||||
HistoricalDataItem,
|
|
||||||
PortfolioPositionDetail
|
PortfolioPositionDetail
|
||||||
} from './interfaces/portfolio-position-detail.interface';
|
} from './interfaces/portfolio-position-detail.interface';
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
@ -77,8 +82,11 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json');
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly currentRateService: CurrentRateService,
|
private readonly currentRateService: CurrentRateService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
@ -88,7 +96,9 @@ export class PortfolioService {
|
|||||||
private readonly rulesService: RulesService,
|
private readonly rulesService: RulesService,
|
||||||
private readonly symbolProfileService: SymbolProfileService,
|
private readonly symbolProfileService: SymbolProfileService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
|
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
|
||||||
const [accounts, details] = await Promise.all([
|
const [accounts, details] = await Promise.all([
|
||||||
@ -263,7 +273,6 @@ export class PortfolioService {
|
|||||||
.filter((timelineItem) => timelineItem !== null)
|
.filter((timelineItem) => timelineItem !== null)
|
||||||
.map((timelineItem) => ({
|
.map((timelineItem) => ({
|
||||||
date: timelineItem.date,
|
date: timelineItem.date,
|
||||||
marketPrice: timelineItem.value,
|
|
||||||
value: timelineItem.netPerformance.toNumber()
|
value: timelineItem.netPerformance.toNumber()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -303,7 +312,8 @@ export class PortfolioService {
|
|||||||
public async getDetails(
|
public async getDetails(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aUserId: string,
|
aUserId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max',
|
||||||
|
aFilters?: Filter[]
|
||||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||||
const userId = await this.getUserId(aImpersonationId, aUserId);
|
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||||
const user = await this.userService.user({ id: userId });
|
const user = await this.userService.user({ id: userId });
|
||||||
@ -312,13 +322,14 @@ export class PortfolioService {
|
|||||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||||
);
|
);
|
||||||
const userCurrency =
|
const userCurrency =
|
||||||
this.request.user?.Settings?.currency ??
|
|
||||||
user.Settings?.currency ??
|
user.Settings?.currency ??
|
||||||
baseCurrency;
|
this.request.user?.Settings?.currency ??
|
||||||
|
this.baseCurrency;
|
||||||
|
|
||||||
const { orders, portfolioOrders, transactionPoints } =
|
const { orders, portfolioOrders, transactionPoints } =
|
||||||
await this.getTransactionPoints({
|
await this.getTransactionPoints({
|
||||||
userId
|
userId,
|
||||||
|
filters: aFilters
|
||||||
});
|
});
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
@ -337,10 +348,11 @@ export class PortfolioService {
|
|||||||
startDate
|
startDate
|
||||||
);
|
);
|
||||||
|
|
||||||
const cashDetails = await this.accountService.getCashDetails(
|
const cashDetails = await this.accountService.getCashDetails({
|
||||||
userId,
|
userId,
|
||||||
userCurrency
|
currency: userCurrency,
|
||||||
);
|
filters: aFilters
|
||||||
|
});
|
||||||
|
|
||||||
const holdings: PortfolioDetails['holdings'] = {};
|
const holdings: PortfolioDetails['holdings'] = {};
|
||||||
const totalInvestment = currentPositions.totalInvestment.plus(
|
const totalInvestment = currentPositions.totalInvestment.plus(
|
||||||
@ -362,7 +374,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.getQuotes(dataGatheringItems),
|
this.dataProviderService.getQuotes(dataGatheringItems),
|
||||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||||
@ -381,7 +393,7 @@ export class PortfolioService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = item.quantity.mul(item.marketPrice);
|
const value = item.quantity.mul(item.marketPrice ?? 0);
|
||||||
const symbolProfile = symbolProfileMap[item.symbol];
|
const symbolProfile = symbolProfileMap[item.symbol];
|
||||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||||
|
|
||||||
@ -429,28 +441,37 @@ export class PortfolioService {
|
|||||||
sectors: symbolProfile.sectors,
|
sectors: symbolProfile.sectors,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
transactionCount: item.transactionCount,
|
transactionCount: item.transactionCount,
|
||||||
|
url: symbolProfile.url,
|
||||||
value: value.toNumber()
|
value: value.toNumber()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cashPositions = await this.getCashPositions({
|
if (
|
||||||
cashDetails,
|
aFilters?.length === 0 ||
|
||||||
emergencyFund,
|
(aFilters?.length === 1 &&
|
||||||
userCurrency,
|
aFilters[0].type === 'ASSET_CLASS' &&
|
||||||
investment: totalInvestment,
|
aFilters[0].id === 'CASH')
|
||||||
value: totalValue
|
) {
|
||||||
});
|
const cashPositions = await this.getCashPositions({
|
||||||
|
cashDetails,
|
||||||
|
emergencyFund,
|
||||||
|
userCurrency,
|
||||||
|
investment: totalInvestment,
|
||||||
|
value: totalValue
|
||||||
|
});
|
||||||
|
|
||||||
for (const symbol of Object.keys(cashPositions)) {
|
for (const symbol of Object.keys(cashPositions)) {
|
||||||
holdings[symbol] = cashPositions[symbol];
|
holdings[symbol] = cashPositions[symbol];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await this.getValueOfAccounts(
|
const accounts = await this.getValueOfAccounts({
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId,
|
||||||
);
|
filters: aFilters
|
||||||
|
});
|
||||||
|
|
||||||
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
|
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
|
||||||
}
|
}
|
||||||
@ -472,8 +493,11 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let tags: Tag[] = [];
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return {
|
return {
|
||||||
|
tags,
|
||||||
averagePrice: undefined,
|
averagePrice: undefined,
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
grossPerformance: undefined,
|
grossPerformance: undefined,
|
||||||
@ -494,12 +518,13 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const positionCurrency = orders[0].SymbolProfile.currency;
|
const positionCurrency = orders[0].SymbolProfile.currency;
|
||||||
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
const [SymbolProfile] =
|
||||||
aSymbol
|
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
|
||||||
]);
|
|
||||||
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders
|
const portfolioOrders: PortfolioOrder[] = orders
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
|
tags = tags.concat(order.tags);
|
||||||
|
|
||||||
return order.type === 'BUY' || order.type === 'SELL';
|
return order.type === 'BUY' || order.type === 'SELL';
|
||||||
})
|
})
|
||||||
.map((order) => ({
|
.map((order) => ({
|
||||||
@ -514,6 +539,8 @@ export class PortfolioService {
|
|||||||
unitPrice: new Big(order.unitPrice)
|
unitPrice: new Big(order.unitPrice)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
tags = uniqBy(tags, 'id');
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currency: positionCurrency,
|
currency: positionCurrency,
|
||||||
currentRateService: this.currentRateService,
|
currentRateService: this.currentRateService,
|
||||||
@ -622,6 +649,7 @@ export class PortfolioService {
|
|||||||
netPerformance,
|
netPerformance,
|
||||||
orders,
|
orders,
|
||||||
SymbolProfile,
|
SymbolProfile,
|
||||||
|
tags,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
averagePrice: averagePrice.toNumber(),
|
averagePrice: averagePrice.toNumber(),
|
||||||
grossPerformancePercent:
|
grossPerformancePercent:
|
||||||
@ -630,7 +658,7 @@ export class PortfolioService {
|
|||||||
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
|
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
|
||||||
quantity: quantity.toNumber(),
|
quantity: quantity.toNumber(),
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
quantity.mul(marketPrice).toNumber(),
|
quantity.mul(marketPrice ?? 0).toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
@ -678,6 +706,7 @@ export class PortfolioService {
|
|||||||
minPrice,
|
minPrice,
|
||||||
orders,
|
orders,
|
||||||
SymbolProfile,
|
SymbolProfile,
|
||||||
|
tags,
|
||||||
averagePrice: 0,
|
averagePrice: 0,
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
grossPerformance: undefined,
|
grossPerformance: undefined,
|
||||||
@ -738,7 +767,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.getQuotes(dataGatheringItem),
|
this.dataProviderService.getQuotes(dataGatheringItem),
|
||||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||||
@ -758,8 +787,7 @@ export class PortfolioService {
|
|||||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||||
investment: new Big(position.investment).toNumber(),
|
investment: new Big(position.investment).toNumber(),
|
||||||
marketState:
|
marketState:
|
||||||
dataProviderResponses[position.symbol]?.marketState ??
|
dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
|
||||||
MarketState.delayed,
|
|
||||||
name: symbolProfileMap[position.symbol].name,
|
name: symbolProfileMap[position.symbol].name,
|
||||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||||
netPerformancePercentage:
|
netPerformancePercentage:
|
||||||
@ -873,12 +901,12 @@ export class PortfolioService {
|
|||||||
for (const position of currentPositions.positions) {
|
for (const position of currentPositions.positions) {
|
||||||
portfolioItemsNow[position.symbol] = position;
|
portfolioItemsNow[position.symbol] = position;
|
||||||
}
|
}
|
||||||
const accounts = await this.getValueOfAccounts(
|
const accounts = await this.getValueOfAccounts({
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
currency,
|
userId,
|
||||||
userId
|
userCurrency: currency
|
||||||
);
|
});
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
accountClusterRisk: await this.rulesService.evaluate(
|
accountClusterRisk: await this.rulesService.evaluate(
|
||||||
@ -940,10 +968,10 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||||
|
|
||||||
const { balanceInBaseCurrency } = await this.accountService.getCashDetails(
|
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
|
||||||
userId,
|
userId,
|
||||||
userCurrency
|
currency: userCurrency
|
||||||
);
|
});
|
||||||
const orders = await this.orderService.getOrders({
|
const orders = await this.orderService.getOrders({
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
@ -1043,7 +1071,7 @@ export class PortfolioService {
|
|||||||
grossPerformancePercent: 0,
|
grossPerformancePercent: 0,
|
||||||
investment: convertedBalance,
|
investment: convertedBalance,
|
||||||
marketPrice: 0,
|
marketPrice: 0,
|
||||||
marketState: MarketState.open,
|
marketState: 'open',
|
||||||
name: account.currency,
|
name: account.currency,
|
||||||
netPerformance: 0,
|
netPerformance: 0,
|
||||||
netPerformancePercent: 0,
|
netPerformancePercent: 0,
|
||||||
@ -1177,9 +1205,11 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getTransactionPoints({
|
private async getTransactionPoints({
|
||||||
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@ -1187,9 +1217,11 @@ export class PortfolioService {
|
|||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
portfolioOrders: PortfolioOrder[];
|
portfolioOrders: PortfolioOrder[];
|
||||||
}> {
|
}> {
|
||||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
const userCurrency =
|
||||||
|
this.request.user?.Settings?.currency ?? this.baseCurrency;
|
||||||
|
|
||||||
const orders = await this.orderService.getOrders({
|
const orders = await this.orderService.getOrders({
|
||||||
|
filters,
|
||||||
includeDrafts,
|
includeDrafts,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
@ -1233,21 +1265,42 @@ export class PortfolioService {
|
|||||||
portfolioCalculator.computeTransactionPoints();
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transactionPoints: portfolioCalculator.getTransactionPoints(),
|
|
||||||
orders,
|
orders,
|
||||||
portfolioOrders
|
portfolioOrders,
|
||||||
|
transactionPoints: portfolioCalculator.getTransactionPoints()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getValueOfAccounts(
|
private async getValueOfAccounts({
|
||||||
orders: OrderWithAccount[],
|
filters = [],
|
||||||
portfolioItemsNow: { [p: string]: TimelinePosition },
|
orders,
|
||||||
userCurrency: string,
|
portfolioItemsNow,
|
||||||
userId: string
|
userCurrency,
|
||||||
) {
|
userId
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
orders: OrderWithAccount[];
|
||||||
|
portfolioItemsNow: { [p: string]: TimelinePosition };
|
||||||
|
userCurrency: string;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
const accounts: PortfolioDetails['accounts'] = {};
|
const accounts: PortfolioDetails['accounts'] = {};
|
||||||
|
|
||||||
const currentAccounts = await this.accountService.getAccounts(userId);
|
let currentAccounts = [];
|
||||||
|
|
||||||
|
if (filters.length === 0) {
|
||||||
|
currentAccounts = await this.accountService.getAccounts(userId);
|
||||||
|
} else {
|
||||||
|
const accountIds = uniq(
|
||||||
|
orders.map(({ accountId }) => {
|
||||||
|
return accountId;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
currentAccounts = await this.accountService.accounts({
|
||||||
|
where: { id: { in: accountIds } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const account of currentAccounts) {
|
for (const account of currentAccounts) {
|
||||||
const ordersByAccount = orders.filter(({ accountId }) => {
|
const ordersByAccount = orders.filter(({ accountId }) => {
|
||||||
@ -1257,34 +1310,47 @@ export class PortfolioService {
|
|||||||
accounts[account.id] = {
|
accounts[account.id] = {
|
||||||
balance: account.balance,
|
balance: account.balance,
|
||||||
currency: account.currency,
|
currency: account.currency,
|
||||||
current: account.balance,
|
current: this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
name: account.name,
|
name: account.name,
|
||||||
original: account.balance
|
original: this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const order of ordersByAccount) {
|
for (const order of ordersByAccount) {
|
||||||
let currentValueOfSymbol =
|
let currentValueOfSymbolInBaseCurrency =
|
||||||
order.quantity *
|
order.quantity *
|
||||||
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
|
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
|
||||||
let originalValueOfSymbol = order.quantity * order.unitPrice;
|
let originalValueOfSymbolInBaseCurrency =
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
|
order.quantity * order.unitPrice,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
);
|
||||||
|
|
||||||
if (order.type === 'SELL') {
|
if (order.type === 'SELL') {
|
||||||
currentValueOfSymbol *= -1;
|
currentValueOfSymbolInBaseCurrency *= -1;
|
||||||
originalValueOfSymbol *= -1;
|
originalValueOfSymbolInBaseCurrency *= -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
||||||
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
||||||
currentValueOfSymbol;
|
currentValueOfSymbolInBaseCurrency;
|
||||||
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
||||||
originalValueOfSymbol;
|
originalValueOfSymbolInBaseCurrency;
|
||||||
} else {
|
} else {
|
||||||
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||||
balance: 0,
|
balance: 0,
|
||||||
currency: order.Account?.currency,
|
currency: order.Account?.currency,
|
||||||
current: currentValueOfSymbol,
|
current: currentValueOfSymbolInBaseCurrency,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
original: originalValueOfSymbol
|
original: originalValueOfSymbolInBaseCurrency
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
useFactory: async (configurationService: ConfigurationService) => ({
|
useFactory: async (configurationService: ConfigurationService) => ({
|
||||||
host: configurationService.get('REDIS_HOST'),
|
host: configurationService.get('REDIS_HOST'),
|
||||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||||
|
password: configurationService.get('REDIS_PASSWORD'),
|
||||||
port: configurationService.get('REDIS_PORT'),
|
port: configurationService.get('REDIS_PORT'),
|
||||||
store: redisStore,
|
store: redisStore,
|
||||||
ttl: configurationService.get('CACHE_TTL')
|
ttl: configurationService.get('CACHE_TTL')
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface SymbolItem {
|
export interface SymbolItem extends UniqueAsset {
|
||||||
currency: string;
|
currency: string;
|
||||||
dataSource: DataSource;
|
|
||||||
historicalData: HistoricalDataItem[];
|
historicalData: HistoricalDataItem[];
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import {
|
import {
|
||||||
IDataGatheringItem,
|
IDataGatheringItem,
|
||||||
@ -6,6 +5,7 @@ import {
|
|||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
@ -55,7 +55,8 @@ export class SymbolService {
|
|||||||
currency,
|
currency,
|
||||||
historicalData,
|
historicalData,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
dataSource: dataGatheringItem.dataSource
|
dataSource: dataGatheringItem.dataSource,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
export interface Access {
|
|
||||||
alias?: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
@ -34,7 +34,7 @@ import { UserService } from './user.service';
|
|||||||
export class UserController {
|
export class UserController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
|
@ -2,6 +2,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -19,7 +20,8 @@ import { UserService } from './user.service';
|
|||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
SubscriptionModule
|
SubscriptionModule,
|
||||||
|
TagModule
|
||||||
],
|
],
|
||||||
providers: [UserService]
|
providers: [UserService]
|
||||||
})
|
})
|
||||||
|
@ -2,20 +2,17 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
||||||
baseCurrency,
|
|
||||||
locale
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasRole,
|
hasRole,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, Role, User, ViewMode } from '@prisma/client';
|
import { Prisma, Role, User, ViewMode } from '@prisma/client';
|
||||||
|
import { sortBy } from 'lodash';
|
||||||
|
|
||||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||||
import { UserSettings } from './interfaces/user-settings.interface';
|
import { UserSettings } from './interfaces/user-settings.interface';
|
||||||
@ -26,12 +23,17 @@ const crypto = require('crypto');
|
|||||||
export class UserService {
|
export class UserService {
|
||||||
public static DEFAULT_CURRENCY = 'USD';
|
public static DEFAULT_CURRENCY = 'USD';
|
||||||
|
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService,
|
||||||
) {}
|
private readonly tagService: TagService
|
||||||
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
public async getUser(
|
public async getUser(
|
||||||
{
|
{
|
||||||
@ -51,12 +53,21 @@ export class UserService {
|
|||||||
orderBy: { User: { alias: 'asc' } },
|
orderBy: { User: { alias: 'asc' } },
|
||||||
where: { GranteeUser: { id } }
|
where: { GranteeUser: { id } }
|
||||||
});
|
});
|
||||||
|
let tags = await this.tagService.getByUser(id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
tags = [];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
alias,
|
||||||
id,
|
id,
|
||||||
permissions,
|
permissions,
|
||||||
subscription,
|
subscription,
|
||||||
|
tags,
|
||||||
access: access.map((accessItem) => {
|
access: access.map((accessItem) => {
|
||||||
return {
|
return {
|
||||||
alias: accessItem.User.alias,
|
alias: accessItem.User.alias,
|
||||||
@ -92,19 +103,69 @@ export class UserService {
|
|||||||
public async user(
|
public async user(
|
||||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||||
): Promise<UserWithSettings | null> {
|
): Promise<UserWithSettings | null> {
|
||||||
const userFromDatabase = await this.prismaService.user.findUnique({
|
const {
|
||||||
|
accessToken,
|
||||||
|
Account,
|
||||||
|
alias,
|
||||||
|
authChallenge,
|
||||||
|
createdAt,
|
||||||
|
id,
|
||||||
|
provider,
|
||||||
|
role,
|
||||||
|
Settings,
|
||||||
|
Subscription,
|
||||||
|
thirdPartyId,
|
||||||
|
updatedAt
|
||||||
|
} = await this.prismaService.user.findUnique({
|
||||||
include: { Account: true, Settings: true, Subscription: true },
|
include: { Account: true, Settings: true, Subscription: true },
|
||||||
where: userWhereUniqueInput
|
where: userWhereUniqueInput
|
||||||
});
|
});
|
||||||
|
|
||||||
const user: UserWithSettings = userFromDatabase;
|
const user: UserWithSettings = {
|
||||||
|
accessToken,
|
||||||
|
Account,
|
||||||
|
alias,
|
||||||
|
authChallenge,
|
||||||
|
createdAt,
|
||||||
|
id,
|
||||||
|
provider,
|
||||||
|
role,
|
||||||
|
Settings,
|
||||||
|
thirdPartyId,
|
||||||
|
updatedAt
|
||||||
|
};
|
||||||
|
|
||||||
let currentPermissions = getPermissions(userFromDatabase.role);
|
if (user?.Settings) {
|
||||||
|
if (!user.Settings.currency) {
|
||||||
|
// Set default currency if needed
|
||||||
|
user.Settings.currency = UserService.DEFAULT_CURRENCY;
|
||||||
|
}
|
||||||
|
} else if (user) {
|
||||||
|
// Set default settings if needed
|
||||||
|
user.Settings = {
|
||||||
|
currency: UserService.DEFAULT_CURRENCY,
|
||||||
|
settings: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
userId: user?.id,
|
||||||
|
viewMode: ViewMode.DEFAULT
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
user.subscription =
|
||||||
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.subscription?.type === 'Premium') {
|
||||||
|
currentPermissions.push(permissions.reportDataGlitch);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
if (hasRole(user, Role.ADMIN)) {
|
if (hasRole(user, Role.ADMIN)) {
|
||||||
currentPermissions.push(permissions.toggleReadOnlyMode);
|
currentPermissions.push(permissions.toggleReadOnlyMode);
|
||||||
@ -125,29 +186,10 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
user.permissions = currentPermissions;
|
user.Account = sortBy(user.Account, (account) => {
|
||||||
|
return account.name;
|
||||||
if (userFromDatabase?.Settings) {
|
});
|
||||||
if (!userFromDatabase.Settings.currency) {
|
user.permissions = currentPermissions.sort();
|
||||||
// Set default currency if needed
|
|
||||||
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
|
|
||||||
}
|
|
||||||
} else if (userFromDatabase) {
|
|
||||||
// Set default settings if needed
|
|
||||||
userFromDatabase.Settings = {
|
|
||||||
currency: UserService.DEFAULT_CURRENCY,
|
|
||||||
settings: null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
userId: userFromDatabase?.id,
|
|
||||||
viewMode: ViewMode.DEFAULT
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
||||||
user.subscription = this.subscriptionService.getSubscription(
|
|
||||||
userFromDatabase?.Subscription
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@ -186,14 +228,14 @@ export class UserService {
|
|||||||
...data,
|
...data,
|
||||||
Account: {
|
Account: {
|
||||||
create: {
|
create: {
|
||||||
currency: baseCurrency,
|
currency: this.baseCurrency,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
name: 'Default Account'
|
name: 'Default Account'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Settings: {
|
Settings: {
|
||||||
create: {
|
create: {
|
||||||
currency: baseCurrency
|
currency: this.baseCurrency
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
8460
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
Normal file
8460
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
Normal file
File diff suppressed because it is too large
Load Diff
4
apps/api/src/assets/cryptocurrencies/custom.json
Normal file
4
apps/api/src/assets/cryptocurrencies/custom.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"LUNA1": "Terra",
|
||||||
|
"UNI1": "Uniswap"
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedactValuesInResponseInterceptor<T>
|
||||||
|
implements NestInterceptor<T, any>
|
||||||
|
{
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler<T>
|
||||||
|
): Observable<any> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data: any) => {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
||||||
|
|
||||||
|
if (hasImpersonationId) {
|
||||||
|
if (data.accounts) {
|
||||||
|
for (const accountId of Object.keys(data.accounts)) {
|
||||||
|
if (data.accounts[accountId]?.balance !== undefined) {
|
||||||
|
data.accounts[accountId].balance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.activities) {
|
||||||
|
data.activities = data.activities.map((activity: Activity) => {
|
||||||
|
if (activity.Account?.balance !== undefined) {
|
||||||
|
activity.Account.balance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return activity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -20,10 +20,11 @@ async function bootstrap() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const host = process.env.HOST || '0.0.0.0';
|
||||||
const port = process.env.PORT || 3333;
|
const port = process.env.PORT || 3333;
|
||||||
await app.listen(port, () => {
|
await app.listen(port, host, () => {
|
||||||
logLogo();
|
logLogo();
|
||||||
Logger.log(`Listening at http://localhost:${port}`);
|
Logger.log(`Listening at http://${host}:${port}`);
|
||||||
Logger.log('');
|
Logger.log('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -12,9 +12,10 @@ export class ConfigurationService {
|
|||||||
this.environmentConfiguration = cleanEnv(process.env, {
|
this.environmentConfiguration = cleanEnv(process.env, {
|
||||||
ACCESS_TOKEN_SALT: str(),
|
ACCESS_TOKEN_SALT: str(),
|
||||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||||
|
BASE_CURRENCY: str({ default: 'USD' }),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
DATA_SOURCES: json({ default: [DataSource.YAHOO] }),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||||
@ -24,17 +25,20 @@ export class ConfigurationService {
|
|||||||
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
||||||
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
|
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
|
||||||
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
||||||
|
EOD_HISTORICAL_DATA_API_KEY: str({ default: '' }),
|
||||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||||
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||||
GOOGLE_SHEETS_ID: str({ default: '' }),
|
GOOGLE_SHEETS_ID: str({ default: '' }),
|
||||||
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||||
|
HOST: host({ default: '0.0.0.0' }),
|
||||||
JWT_SECRET_KEY: str({}),
|
JWT_SECRET_KEY: str({}),
|
||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||||
PORT: port({ default: 3333 }),
|
PORT: port({ default: 3333 }),
|
||||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||||
REDIS_HOST: str({ default: 'localhost' }),
|
REDIS_HOST: host({ default: 'localhost' }),
|
||||||
|
REDIS_PASSWORD: str({ default: '' }),
|
||||||
REDIS_PORT: port({ default: 6379 }),
|
REDIS_PORT: port({ default: 6379 }),
|
||||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||||
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
|
||||||
@ -13,8 +17,8 @@ export class CronService {
|
|||||||
private readonly twitterBotService: TwitterBotService
|
private readonly twitterBotService: TwitterBotService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_HOUR)
|
||||||
public async runEveryMinute() {
|
public async runEveryHour() {
|
||||||
await this.dataGatheringService.gather7Days();
|
await this.dataGatheringService.gather7Days();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +34,17 @@ export class CronService {
|
|||||||
|
|
||||||
@Cron(CronExpression.EVERY_WEEKEND)
|
@Cron(CronExpression.EVERY_WEEKEND)
|
||||||
public async runEveryWeekend() {
|
public async runEveryWeekend() {
|
||||||
await this.dataGatheringService.gatherProfileData();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
const cryptocurrencies = require('cryptocurrencies');
|
const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json');
|
||||||
|
const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json');
|
||||||
const customCryptocurrencies = require('./custom-cryptocurrencies.json');
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CryptocurrencyService {
|
export class CryptocurrencyService {
|
||||||
@ -18,7 +17,7 @@ export class CryptocurrencyService {
|
|||||||
private getCryptocurrencies() {
|
private getCryptocurrencies() {
|
||||||
if (!this.combinedCryptocurrencies) {
|
if (!this.combinedCryptocurrencies) {
|
||||||
this.combinedCryptocurrencies = [
|
this.combinedCryptocurrencies = [
|
||||||
...cryptocurrencies.symbols(),
|
...Object.keys(cryptocurrencies),
|
||||||
...Object.keys(customCryptocurrencies)
|
...Object.keys(customCryptocurrencies)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"1INCH": "1inch",
|
|
||||||
"ALGO": "Algorand",
|
|
||||||
"ATOM": "Cosmos",
|
|
||||||
"AVAX": "Avalanche",
|
|
||||||
"DOT": "Polkadot",
|
|
||||||
"LUNA1": "Terra",
|
|
||||||
"MATIC": "Polygon",
|
|
||||||
"MINA": "Mina Protocol",
|
|
||||||
"RUNE": "THORChain",
|
|
||||||
"SHIB": "Shiba Inu",
|
|
||||||
"SOL": "Solana",
|
|
||||||
"UNI3": "Uniswap"
|
|
||||||
}
|
|
@ -3,21 +3,34 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
|||||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
|
import { DataGatheringProcessor } from './data-gathering.processor';
|
||||||
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
||||||
|
import { MarketDataModule } from './market-data.module';
|
||||||
import { SymbolProfileModule } from './symbol-profile.module';
|
import { SymbolProfileModule } from './symbol-profile.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
BullModule.registerQueue({
|
||||||
|
limiter: {
|
||||||
|
duration: ms('5 seconds'),
|
||||||
|
max: 1
|
||||||
|
},
|
||||||
|
name: DATA_GATHERING_QUEUE
|
||||||
|
}),
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataEnhancerModule,
|
DataEnhancerModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [DataGatheringService],
|
providers: [DataGatheringProcessor, DataGatheringService],
|
||||||
exports: [DataEnhancerModule, DataGatheringService]
|
exports: [BullModule, DataEnhancerModule, DataGatheringService]
|
||||||
})
|
})
|
||||||
export class DataGatheringModule {}
|
export class DataGatheringModule {}
|
||||||
|
128
apps/api/src/services/data-gathering.processor.ts
Normal file
128
apps/api/src/services/data-gathering.processor.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
DATA_GATHERING_QUEUE,
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Job } from 'bull';
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
getDate,
|
||||||
|
getMonth,
|
||||||
|
getYear,
|
||||||
|
isBefore,
|
||||||
|
parseISO
|
||||||
|
} from 'date-fns';
|
||||||
|
|
||||||
|
import { DataGatheringService } from './data-gathering.service';
|
||||||
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
@Processor(DATA_GATHERING_QUEUE)
|
||||||
|
export class DataGatheringProcessor {
|
||||||
|
public constructor(
|
||||||
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Process(GATHER_ASSET_PROFILE_PROCESS)
|
||||||
|
public async gatherAssetProfile(job: Job<UniqueAsset>) {
|
||||||
|
try {
|
||||||
|
await this.dataGatheringService.gatherAssetProfiles([job.data]);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
error,
|
||||||
|
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Process(GATHER_HISTORICAL_MARKET_DATA_PROCESS)
|
||||||
|
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
|
||||||
|
try {
|
||||||
|
const { dataSource, date, symbol } = job.data;
|
||||||
|
|
||||||
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
|
[{ dataSource, symbol }],
|
||||||
|
parseISO(<string>(<unknown>date)),
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
|
||||||
|
let currentDate = parseISO(<string>(<unknown>date));
|
||||||
|
let lastMarketPrice: number;
|
||||||
|
|
||||||
|
while (
|
||||||
|
isBefore(
|
||||||
|
currentDate,
|
||||||
|
new Date(
|
||||||
|
Date.UTC(
|
||||||
|
getYear(new Date()),
|
||||||
|
getMonth(new Date()),
|
||||||
|
getDate(new Date()),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
||||||
|
?.marketPrice
|
||||||
|
) {
|
||||||
|
lastMarketPrice =
|
||||||
|
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
||||||
|
?.marketPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastMarketPrice) {
|
||||||
|
try {
|
||||||
|
await this.prismaService.marketData.create({
|
||||||
|
data: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: new Date(
|
||||||
|
Date.UTC(
|
||||||
|
getYear(currentDate),
|
||||||
|
getMonth(currentDate),
|
||||||
|
getDate(currentDate),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
marketPrice: lastMarketPrice
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count month one up for iteration
|
||||||
|
currentDate = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
getYear(currentDate),
|
||||||
|
getMonth(currentDate),
|
||||||
|
getDate(currentDate) + 1,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
`Historical market data gathering has been completed for ${symbol} (${dataSource}).`,
|
||||||
|
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
error,
|
||||||
|
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,191 +1,72 @@
|
|||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
PROPERTY_LAST_DATA_GATHERING,
|
DATA_GATHERING_QUEUE,
|
||||||
PROPERTY_LOCKED_DATA_GATHERING
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||||
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||||
|
QUEUE_JOB_STATUS_LIST
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import {
|
import { JobOptions, Queue } from 'bull';
|
||||||
differenceInHours,
|
import { format, subDays } from 'date-fns';
|
||||||
format,
|
|
||||||
getDate,
|
|
||||||
getMonth,
|
|
||||||
getYear,
|
|
||||||
isBefore,
|
|
||||||
subDays
|
|
||||||
} from 'date-fns';
|
|
||||||
|
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
|
import { MarketDataService } from './market-data.service';
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataGatheringService {
|
export class DataGatheringService {
|
||||||
private dataGatheringProgress: number;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject('DataEnhancers')
|
@Inject('DataEnhancers')
|
||||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||||
|
@InjectQueue(DATA_GATHERING_QUEUE)
|
||||||
|
private readonly dataGatheringQueue: Queue,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async gather7Days() {
|
public async addJobToQueue(name: string, data: any, options?: JobOptions) {
|
||||||
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
const hasJob = await this.hasJob(name, data);
|
||||||
|
|
||||||
if (isDataGatheringNeeded) {
|
|
||||||
Logger.log('7d data gathering has been started.', 'DataGatheringService');
|
|
||||||
console.time('data-gathering-7d');
|
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
|
||||||
data: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const symbols = await this.getSymbols7D();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.gatherSymbols(symbols);
|
|
||||||
|
|
||||||
await this.prismaService.property.upsert({
|
|
||||||
create: {
|
|
||||||
key: PROPERTY_LAST_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
},
|
|
||||||
update: { value: new Date().toISOString() },
|
|
||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'DataGatheringService');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
|
||||||
where: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
if (hasJob) {
|
||||||
Logger.log(
|
Logger.log(
|
||||||
'7d data gathering has been completed.',
|
`Job ${name} with data ${JSON.stringify(data)} already exists.`,
|
||||||
'DataGatheringService'
|
'DataGatheringService'
|
||||||
);
|
);
|
||||||
console.timeEnd('data-gathering-7d');
|
} else {
|
||||||
|
return this.dataGatheringQueue.add(name, data, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async gather7Days() {
|
||||||
|
const dataGatheringItems = await this.getSymbols7D();
|
||||||
|
await this.gatherSymbols(dataGatheringItems);
|
||||||
|
}
|
||||||
|
|
||||||
public async gatherMax() {
|
public async gatherMax() {
|
||||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
const dataGatheringItems = await this.getSymbolsMax();
|
||||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
await this.gatherSymbols(dataGatheringItems);
|
||||||
});
|
|
||||||
|
|
||||||
if (!isDataGatheringLocked) {
|
|
||||||
Logger.log(
|
|
||||||
'Max data gathering has been started.',
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
console.time('data-gathering-max');
|
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
|
||||||
data: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const symbols = await this.getSymbolsMax();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.gatherSymbols(symbols);
|
|
||||||
|
|
||||||
await this.prismaService.property.upsert({
|
|
||||||
create: {
|
|
||||||
key: PROPERTY_LAST_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
},
|
|
||||||
update: { value: new Date().toISOString() },
|
|
||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'DataGatheringService');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
|
||||||
where: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Logger.log(
|
|
||||||
'Max data gathering has been completed.',
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
console.timeEnd('data-gathering-max');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
|
||||||
|
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
|
||||||
|
return (
|
||||||
|
dataGatheringItem.dataSource === dataSource &&
|
||||||
|
dataGatheringItem.symbol === symbol
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
await this.gatherSymbols(symbols);
|
||||||
if (!isDataGatheringLocked) {
|
|
||||||
Logger.log(
|
|
||||||
`Symbol data gathering for ${symbol} has been started.`,
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
console.time('data-gathering-symbol');
|
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
|
||||||
data: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const symbols = (await this.getSymbolsMax()).filter(
|
|
||||||
(dataGatheringItem) => {
|
|
||||||
return (
|
|
||||||
dataGatheringItem.dataSource === dataSource &&
|
|
||||||
dataGatheringItem.symbol === symbol
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.gatherSymbols(symbols);
|
|
||||||
|
|
||||||
await this.prismaService.property.upsert({
|
|
||||||
create: {
|
|
||||||
key: PROPERTY_LAST_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
},
|
|
||||||
update: { value: new Date().toISOString() },
|
|
||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'DataGatheringService');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
|
||||||
where: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Logger.log(
|
|
||||||
`Symbol data gathering for ${symbol} has been completed.`,
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
console.timeEnd('data-gathering-symbol');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbolForDate({
|
public async gatherSymbolForDate({
|
||||||
@ -226,31 +107,24 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
|
||||||
Logger.log(
|
let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
|
||||||
'Profile data gathering has been started.',
|
return dataGatheringItem.dataSource !== 'MANUAL';
|
||||||
'DataGatheringService'
|
});
|
||||||
);
|
|
||||||
console.time('data-gathering-profile');
|
|
||||||
|
|
||||||
let dataGatheringItems = aDataGatheringItems?.filter(
|
if (!uniqueAssets) {
|
||||||
(dataGatheringItem) => {
|
uniqueAssets = await this.getUniqueAssets();
|
||||||
return dataGatheringItem.dataSource !== 'MANUAL';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!dataGatheringItems) {
|
|
||||||
dataGatheringItems = await this.getSymbolsProfileData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetProfiles = await this.dataProviderService.getAssetProfiles(
|
const assetProfiles = await this.dataProviderService.getAssetProfiles(
|
||||||
dataGatheringItems
|
uniqueAssets
|
||||||
);
|
|
||||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
|
||||||
dataGatheringItems.map(({ symbol }) => {
|
|
||||||
return symbol;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
const symbolProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesBySymbols(
|
||||||
|
uniqueAssets.map(({ symbol }) => {
|
||||||
|
return symbol;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
|
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
|
||||||
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
||||||
@ -322,136 +196,31 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Logger.log(
|
Logger.log(
|
||||||
'Profile data gathering has been completed.',
|
`Asset profile data gathering has been completed for ${uniqueAssets
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
|
return `${symbol} (${dataSource})`;
|
||||||
|
})
|
||||||
|
.join(',')}.`,
|
||||||
'DataGatheringService'
|
'DataGatheringService'
|
||||||
);
|
);
|
||||||
console.timeEnd('data-gathering-profile');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||||
let hasError = false;
|
|
||||||
let symbolCounter = 0;
|
|
||||||
|
|
||||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||||
if (dataSource === 'MANUAL') {
|
if (dataSource === 'MANUAL') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
|
await this.addJobToQueue(
|
||||||
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||||
try {
|
{
|
||||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
dataSource,
|
||||||
[{ dataSource, symbol }],
|
|
||||||
date,
|
date,
|
||||||
new Date()
|
symbol
|
||||||
);
|
},
|
||||||
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
|
||||||
let currentDate = date;
|
);
|
||||||
let lastMarketPrice: number;
|
|
||||||
|
|
||||||
while (
|
|
||||||
isBefore(
|
|
||||||
currentDate,
|
|
||||||
new Date(
|
|
||||||
Date.UTC(
|
|
||||||
getYear(new Date()),
|
|
||||||
getMonth(new Date()),
|
|
||||||
getDate(new Date()),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
|
||||||
?.marketPrice
|
|
||||||
) {
|
|
||||||
lastMarketPrice =
|
|
||||||
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
|
||||||
?.marketPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastMarketPrice) {
|
|
||||||
try {
|
|
||||||
await this.prismaService.marketData.create({
|
|
||||||
data: {
|
|
||||||
dataSource,
|
|
||||||
symbol,
|
|
||||||
date: currentDate,
|
|
||||||
marketPrice: lastMarketPrice
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
} else {
|
|
||||||
Logger.warn(
|
|
||||||
`Failed to gather data for symbol ${symbol} from ${dataSource} at ${format(
|
|
||||||
currentDate,
|
|
||||||
DATE_FORMAT
|
|
||||||
)}.`,
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count month one up for iteration
|
|
||||||
currentDate = new Date(
|
|
||||||
Date.UTC(
|
|
||||||
getYear(currentDate),
|
|
||||||
getMonth(currentDate),
|
|
||||||
getDate(currentDate) + 1,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
hasError = true;
|
|
||||||
Logger.error(error, 'DataGatheringService');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
|
|
||||||
Logger.log(
|
|
||||||
`Data gathering progress: ${(
|
|
||||||
this.dataGatheringProgress * 100
|
|
||||||
).toFixed(2)}%`,
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
symbolCounter += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.exchangeRateDataService.initialize();
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
throw '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getDataGatheringProgress() {
|
|
||||||
const isInProgress = await this.getIsInProgress();
|
|
||||||
|
|
||||||
if (isInProgress) {
|
|
||||||
return this.dataGatheringProgress;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getIsInProgress() {
|
|
||||||
return await this.prismaService.property.findUnique({
|
|
||||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getLastDataGathering() {
|
|
||||||
const lastDataGathering = await this.prismaService.property.findUnique({
|
|
||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (lastDataGathering?.value) {
|
|
||||||
return new Date(lastDataGathering.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||||
@ -501,17 +270,25 @@ export class DataGatheringService {
|
|||||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async reset() {
|
public async getUniqueAssets(): Promise<UniqueAsset[]> {
|
||||||
Logger.log('Data gathering has been reset.', 'DataGatheringService');
|
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy: [{ symbol: 'asc' }]
|
||||||
await this.prismaService.property.deleteMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ key: PROPERTY_LAST_DATA_GATHERING },
|
|
||||||
{ key: PROPERTY_LOCKED_DATA_GATHERING }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return symbolProfiles
|
||||||
|
.filter(({ dataSource }) => {
|
||||||
|
return (
|
||||||
|
dataSource !== DataSource.GHOSTFOLIO &&
|
||||||
|
dataSource !== DataSource.MANUAL &&
|
||||||
|
dataSource !== DataSource.RAKUTEN
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||||
@ -537,6 +314,7 @@ export class DataGatheringService {
|
|||||||
await this.prismaService.marketData.groupBy({
|
await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['symbol'],
|
by: ['symbol'],
|
||||||
|
orderBy: [{ symbol: 'asc' }],
|
||||||
where: {
|
where: {
|
||||||
date: { gt: startDate }
|
date: { gt: startDate }
|
||||||
}
|
}
|
||||||
@ -576,36 +354,17 @@ export class DataGatheringService {
|
|||||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
private async hasJob(name: string, data: any) {
|
||||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
const jobs = await this.dataGatheringQueue.getJobs(
|
||||||
orderBy: [{ symbol: 'asc' }]
|
QUEUE_JOB_STATUS_LIST.filter((status) => {
|
||||||
});
|
return status !== 'completed';
|
||||||
|
|
||||||
return symbolProfiles
|
|
||||||
.filter((symbolProfile) => {
|
|
||||||
return (
|
|
||||||
symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
|
|
||||||
symbolProfile.dataSource !== DataSource.MANUAL &&
|
|
||||||
symbolProfile.dataSource !== DataSource.RAKUTEN
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.map((symbolProfile) => {
|
);
|
||||||
return {
|
|
||||||
dataSource: symbolProfile.dataSource,
|
|
||||||
symbol: symbolProfile.symbol
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async isDataGatheringNeeded() {
|
return jobs.some((job) => {
|
||||||
const lastDataGathering = await this.getLastDataGathering();
|
return (
|
||||||
|
job.name === name && JSON.stringify(job.data) === JSON.stringify(data)
|
||||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
);
|
||||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const diffInHours = differenceInHours(new Date(), lastDataGathering);
|
|
||||||
|
|
||||||
return (diffInHours >= 1 || !lastDataGathering) && !isDataGatheringLocked;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
import { isAfter, isBefore, parse } from 'date-fns';
|
import { format, isAfter, isBefore, parse } from 'date-fns';
|
||||||
|
|
||||||
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -76,9 +76,12 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'AlphaVantageService');
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
return {};
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const holdings = await getJSON(
|
const result = await getJSON(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
|
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
|
||||||
).catch(() => {
|
).catch(() => {
|
||||||
return getJSON(
|
return getJSON(
|
||||||
@ -42,12 +42,17 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.weight < 0.95) {
|
||||||
|
// Skip if data is inaccurate
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!response.countries ||
|
!response.countries ||
|
||||||
(response.countries as unknown as Country[]).length === 0
|
(response.countries as unknown as Country[]).length === 0
|
||||||
) {
|
) {
|
||||||
response.countries = [];
|
response.countries = [];
|
||||||
for (const [name, value] of Object.entries<any>(holdings.countries)) {
|
for (const [name, value] of Object.entries<any>(result.countries)) {
|
||||||
let countryCode: string;
|
let countryCode: string;
|
||||||
|
|
||||||
for (const [key, country] of Object.entries<any>(
|
for (const [key, country] of Object.entries<any>(
|
||||||
@ -75,7 +80,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
(response.sectors as unknown as Sector[]).length === 0
|
(response.sectors as unknown as Sector[]).length === 0
|
||||||
) {
|
) {
|
||||||
response.sectors = [];
|
response.sectors = [];
|
||||||
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
|
for (const [name, value] of Object.entries<any>(result.sectors)) {
|
||||||
response.sectors.push({
|
response.sectors.push({
|
||||||
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
||||||
weight: value.weight
|
weight: value.weight
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||||
|
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
@ -9,7 +11,6 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
|||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
|
||||||
import { DataProviderService } from './data-provider.service';
|
import { DataProviderService } from './data-provider.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -22,6 +23,7 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
|
EodHistoricalDataService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
GoogleSheetsService,
|
GoogleSheetsService,
|
||||||
ManualService,
|
ManualService,
|
||||||
@ -30,6 +32,7 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
{
|
{
|
||||||
inject: [
|
inject: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
|
EodHistoricalDataService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
GoogleSheetsService,
|
GoogleSheetsService,
|
||||||
ManualService,
|
ManualService,
|
||||||
@ -39,6 +42,7 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
provide: 'DataProviderInterfaces',
|
provide: 'DataProviderInterfaces',
|
||||||
useFactory: (
|
useFactory: (
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
|
eodHistoricalDataService,
|
||||||
ghostfolioScraperApiService,
|
ghostfolioScraperApiService,
|
||||||
googleSheetsService,
|
googleSheetsService,
|
||||||
manualService,
|
manualService,
|
||||||
@ -46,6 +50,7 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
) => [
|
) => [
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
|
eodHistoricalDataService,
|
||||||
ghostfolioScraperApiService,
|
ghostfolioScraperApiService,
|
||||||
googleSheetsService,
|
googleSheetsService,
|
||||||
manualService,
|
manualService,
|
||||||
|
@ -0,0 +1,141 @@
|
|||||||
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
import {
|
||||||
|
IDataProviderHistoricalResponse,
|
||||||
|
IDataProviderResponse
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
|
import bent from 'bent';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EodHistoricalDataService implements DataProviderInterface {
|
||||||
|
private apiKey: string;
|
||||||
|
private readonly URL = 'https://eodhistoricaldata.com/api';
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {
|
||||||
|
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
public canHandle(symbol: string) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAssetProfile(
|
||||||
|
aSymbol: string
|
||||||
|
): Promise<Partial<SymbolProfile>> {
|
||||||
|
return {
|
||||||
|
dataSource: this.getName()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getHistorical(
|
||||||
|
aSymbol: string,
|
||||||
|
aGranularity: Granularity = 'day',
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<{
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`${this.URL}/eod/${aSymbol}?api_token=${
|
||||||
|
this.apiKey
|
||||||
|
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
|
||||||
|
to,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}&period={aGranularity}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await get();
|
||||||
|
|
||||||
|
return response.reduce(
|
||||||
|
(result, historicalItem, index, array) => {
|
||||||
|
result[aSymbol][historicalItem.date] = {
|
||||||
|
marketPrice: historicalItem.close,
|
||||||
|
performance: historicalItem.open - historicalItem.close
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{ [aSymbol]: {} }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.EOD_HISTORICAL_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getQuotes(
|
||||||
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`${this.URL}/real-time/${aSymbols[0]}?api_token=${
|
||||||
|
this.apiKey
|
||||||
|
}&fmt=json&s=${aSymbols.join(',')}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
|
||||||
|
const [response, symbolProfiles] = await Promise.all([
|
||||||
|
get(),
|
||||||
|
this.symbolProfileService.getSymbolProfiles(
|
||||||
|
aSymbols.map((symbol) => {
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
dataSource: DataSource.EOD_HISTORICAL_DATA
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const quotes = aSymbols.length === 1 ? [response] : response;
|
||||||
|
|
||||||
|
return quotes.reduce((result, item, index, array) => {
|
||||||
|
result[item.code] = {
|
||||||
|
currency: symbolProfiles.find((symbolProfile) => {
|
||||||
|
return symbolProfile.symbol === item.code;
|
||||||
|
})?.currency,
|
||||||
|
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
||||||
|
marketPrice: item.close,
|
||||||
|
marketState: 'delayed'
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'EodHistoricalDataService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
}
|
@ -2,8 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
|
|||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse
|
||||||
MarketState
|
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
@ -11,7 +10,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
|||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import bent from 'bent';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { addDays, format, isBefore } from 'date-fns';
|
import { addDays, format, isBefore } from 'date-fns';
|
||||||
|
|
||||||
@ -47,9 +46,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
try {
|
try {
|
||||||
const symbol = aSymbol;
|
const symbol = aSymbol;
|
||||||
|
|
||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
const [symbolProfile] =
|
||||||
[symbol]
|
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
|
||||||
);
|
|
||||||
const { defaultMarketPrice, selector, url } =
|
const { defaultMarketPrice, selector, url } =
|
||||||
symbolProfile.scraperConfiguration;
|
symbolProfile.scraperConfiguration;
|
||||||
|
|
||||||
@ -89,10 +87,13 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'GhostfolioScraperApiService');
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
@ -109,9 +110,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
const symbolProfiles =
|
||||||
aSymbols
|
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
|
||||||
);
|
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.findMany({
|
const marketData = await this.prismaService.marketData.findMany({
|
||||||
distinct: ['symbol'],
|
distinct: ['symbol'],
|
||||||
@ -133,7 +133,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
marketPrice: marketData.find((marketDataItem) => {
|
marketPrice: marketData.find((marketDataItem) => {
|
||||||
return marketDataItem.symbol === symbolProfile.symbol;
|
return marketDataItem.symbol === symbolProfile.symbol;
|
||||||
}).marketPrice,
|
}).marketPrice,
|
||||||
marketState: MarketState.delayed
|
marketState: 'delayed'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,8 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
|||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse
|
||||||
MarketState
|
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
@ -72,10 +71,13 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
[symbol]: historicalData
|
[symbol]: historicalData
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'GoogleSheetsService');
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
@ -92,9 +94,8 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
try {
|
try {
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
const symbolProfiles =
|
||||||
aSymbols
|
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
|
||||||
);
|
|
||||||
|
|
||||||
const sheet = await this.getSheet({
|
const sheet = await this.getSheet({
|
||||||
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
|
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
|
||||||
@ -114,7 +115,7 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
return symbolProfile.symbol === symbol;
|
return symbolProfile.symbol === symbol;
|
||||||
})?.currency,
|
})?.currency,
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
marketState: MarketState.delayed
|
marketState: 'delayed'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
|||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse
|
||||||
MarketState
|
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
@ -12,7 +11,7 @@ import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
|||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import bent from 'bent';
|
||||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -91,7 +90,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {}
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@ -118,7 +124,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
currency: undefined,
|
currency: undefined,
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
marketPrice: fgi.now.value,
|
marketPrice: fgi.now.value,
|
||||||
marketState: MarketState.open
|
marketState: 'open'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
|
|
||||||
import { YahooFinanceService } from './yahoo-finance.service';
|
import { YahooFinanceService } from './yahoo-finance.service';
|
||||||
@ -25,13 +26,18 @@ jest.mock(
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe('YahooFinanceService', () => {
|
describe('YahooFinanceService', () => {
|
||||||
|
let configurationService: ConfigurationService;
|
||||||
let cryptocurrencyService: CryptocurrencyService;
|
let cryptocurrencyService: CryptocurrencyService;
|
||||||
let yahooFinanceService: YahooFinanceService;
|
let yahooFinanceService: YahooFinanceService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
configurationService = new ConfigurationService();
|
||||||
cryptocurrencyService = new CryptocurrencyService();
|
cryptocurrencyService = new CryptocurrencyService();
|
||||||
|
|
||||||
yahooFinanceService = new YahooFinanceService(cryptocurrencyService);
|
yahooFinanceService = new YahooFinanceService(
|
||||||
|
configurationService,
|
||||||
|
cryptocurrencyService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('convertFromYahooFinanceSymbol', async () => {
|
it('convertFromYahooFinanceSymbol', async () => {
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse
|
||||||
MarketState
|
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
@ -20,16 +19,18 @@ import Big from 'big.js';
|
|||||||
import { countries } from 'countries-list';
|
import { countries } from 'countries-list';
|
||||||
import { addDays, format, isSameDay } from 'date-fns';
|
import { addDays, format, isSameDay } from 'date-fns';
|
||||||
import yahooFinance from 'yahoo-finance2';
|
import yahooFinance from 'yahoo-finance2';
|
||||||
import type {
|
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
||||||
Price,
|
|
||||||
QuoteSummaryResult
|
|
||||||
} from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class YahooFinanceService implements DataProviderInterface {
|
export class YahooFinanceService implements DataProviderInterface {
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly cryptocurrencyService: CryptocurrencyService
|
private readonly cryptocurrencyService: CryptocurrencyService
|
||||||
) {}
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return true;
|
return true;
|
||||||
@ -37,8 +38,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||||
const symbol = aYahooFinanceSymbol.replace(
|
const symbol = aYahooFinanceSymbol.replace(
|
||||||
new RegExp(`-${baseCurrency}$`),
|
new RegExp(`-${this.baseCurrency}$`),
|
||||||
baseCurrency
|
this.baseCurrency
|
||||||
);
|
);
|
||||||
return symbol.replace('=X', '');
|
return symbol.replace('=X', '');
|
||||||
}
|
}
|
||||||
@ -51,12 +52,15 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
* DOGEUSD -> DOGE-USD
|
* DOGEUSD -> DOGE-USD
|
||||||
*/
|
*/
|
||||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||||
if (aSymbol.includes(baseCurrency) && aSymbol.length >= 6) {
|
if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) {
|
||||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||||
return `${aSymbol}=X`;
|
return `${aSymbol}=X`;
|
||||||
} else if (
|
} else if (
|
||||||
this.cryptocurrencyService.isCryptocurrency(
|
this.cryptocurrencyService.isCryptocurrency(
|
||||||
aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
aSymbol.replace(
|
||||||
|
new RegExp(`-${this.baseCurrency}$`),
|
||||||
|
this.baseCurrency
|
||||||
|
)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Add a dash before the last three characters
|
// Add a dash before the last three characters
|
||||||
@ -64,8 +68,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
// DOGEUSD -> DOGE-USD
|
// DOGEUSD -> DOGE-USD
|
||||||
// SOL1USD -> SOL1-USD
|
// SOL1USD -> SOL1-USD
|
||||||
return aSymbol.replace(
|
return aSymbol.replace(
|
||||||
new RegExp(`-?${baseCurrency}$`),
|
new RegExp(`-?${this.baseCurrency}$`),
|
||||||
`-${baseCurrency}`
|
`-${this.baseCurrency}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -92,7 +96,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
response.assetSubClass = assetSubClass;
|
response.assetSubClass = assetSubClass;
|
||||||
response.currency = assetProfile.price.currency;
|
response.currency = assetProfile.price.currency;
|
||||||
response.dataSource = this.getName();
|
response.dataSource = this.getName();
|
||||||
response.name = this.formatName(assetProfile);
|
response.name = this.formatName({
|
||||||
|
longName: assetProfile.price.longName,
|
||||||
|
quoteType: assetProfile.price.quoteType,
|
||||||
|
shortName: assetProfile.price.shortName,
|
||||||
|
symbol: assetProfile.price.symbol
|
||||||
|
});
|
||||||
response.symbol = aSymbol;
|
response.symbol = aSymbol;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -122,7 +131,13 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
if (url) {
|
if (url) {
|
||||||
response.url = url;
|
response.url = url;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
|
||||||
|
error.name
|
||||||
|
}] ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -166,6 +181,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
if (symbol === 'USDGBp') {
|
if (symbol === 'USDGBp') {
|
||||||
// Convert GPB to GBp (pence)
|
// Convert GPB to GBp (pence)
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||||
|
} else if (symbol === 'USDILA') {
|
||||||
|
// Convert ILS to ILA
|
||||||
|
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||||
@ -176,12 +194,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.warn(
|
throw new Error(
|
||||||
`Skipping yahooFinance2.getHistorical("${aSymbol}"): [${error.name}] ${error.message}`,
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
'YahooFinanceService'
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,8 +232,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
marketState:
|
marketState:
|
||||||
quote.marketState === 'REGULAR' ||
|
quote.marketState === 'REGULAR' ||
|
||||||
this.cryptocurrencyService.isCryptocurrency(symbol)
|
this.cryptocurrencyService.isCryptocurrency(symbol)
|
||||||
? MarketState.open
|
? 'open'
|
||||||
: MarketState.closed,
|
: 'closed',
|
||||||
marketPrice: quote.regularMarketPrice || 0
|
marketPrice: quote.regularMarketPrice || 0
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -228,6 +246,18 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
.mul(100)
|
.mul(100)
|
||||||
.toNumber()
|
.toNumber()
|
||||||
};
|
};
|
||||||
|
} else if (
|
||||||
|
symbol === 'USDILS' &&
|
||||||
|
yahooFinanceSymbols.includes('USDILA=X')
|
||||||
|
) {
|
||||||
|
// Convert ILS to ILA
|
||||||
|
response['USDILA'] = {
|
||||||
|
...response[symbol],
|
||||||
|
currency: 'ILA',
|
||||||
|
marketPrice: new Big(response[symbol].marketPrice)
|
||||||
|
.mul(100)
|
||||||
|
.toNumber()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,23 +277,29 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
const quotes = searchResult.quotes
|
const quotes = searchResult.quotes
|
||||||
.filter((quote) => {
|
.filter((quote) => {
|
||||||
// filter out undefined symbols
|
// Filter out undefined symbols
|
||||||
return quote.symbol;
|
return quote.symbol;
|
||||||
})
|
})
|
||||||
.filter(({ quoteType, symbol }) => {
|
.filter(({ quoteType, symbol }) => {
|
||||||
return (
|
return (
|
||||||
(quoteType === 'CRYPTOCURRENCY' &&
|
(quoteType === 'CRYPTOCURRENCY' &&
|
||||||
this.cryptocurrencyService.isCryptocurrency(
|
this.cryptocurrencyService.isCryptocurrency(
|
||||||
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
symbol.replace(
|
||||||
|
new RegExp(`-${this.baseCurrency}$`),
|
||||||
|
this.baseCurrency
|
||||||
|
)
|
||||||
)) ||
|
)) ||
|
||||||
['EQUITY', 'ETF', 'MUTUALFUND'].includes(quoteType)
|
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.filter(({ quoteType, symbol }) => {
|
.filter(({ quoteType, symbol }) => {
|
||||||
if (quoteType === 'CRYPTOCURRENCY') {
|
if (quoteType === 'CRYPTOCURRENCY') {
|
||||||
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
|
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
|
||||||
// Transactions need to be converted manually to the base currency before
|
// Transactions need to be converted manually to the base currency before
|
||||||
return symbol.includes(baseCurrency);
|
return symbol.includes(this.baseCurrency);
|
||||||
|
} else if (quoteType === 'FUTURE') {
|
||||||
|
// Allow GC=F, but not MGC=F
|
||||||
|
return symbol.length === 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -288,7 +324,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
symbol,
|
symbol,
|
||||||
currency: marketDataItem.currency,
|
currency: marketDataItem.currency,
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
name: quote?.longname || quote?.shortname || symbol
|
name: this.formatName({
|
||||||
|
longName: quote.longname,
|
||||||
|
quoteType: quote.quoteType,
|
||||||
|
shortName: quote.shortname,
|
||||||
|
symbol: quote.symbol
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -298,8 +339,18 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatName(aAssetProfile: QuoteSummaryResult) {
|
private formatName({
|
||||||
let name = aAssetProfile.price.longName;
|
longName,
|
||||||
|
quoteType,
|
||||||
|
shortName,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
longName: Price['longName'];
|
||||||
|
quoteType: Price['quoteType'];
|
||||||
|
shortName: Price['shortName'];
|
||||||
|
symbol: Price['symbol'];
|
||||||
|
}) {
|
||||||
|
let name = longName;
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
name = name.replace('iShares ETF (CH) - ', '');
|
name = name.replace('iShares ETF (CH) - ', '');
|
||||||
@ -314,7 +365,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
name = name.replace('Xtrackers (IE) Plc - ', '');
|
name = name.replace('Xtrackers (IE) Plc - ', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
return name || aAssetProfile.price.shortName || aAssetProfile.price.symbol;
|
if (quoteType === 'FUTURE') {
|
||||||
|
// "Gold Jun 22" -> "Gold"
|
||||||
|
name = shortName?.slice(0, -6);
|
||||||
|
}
|
||||||
|
|
||||||
|
return name || shortName || symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseAssetClass(aPrice: Price): {
|
private parseAssetClass(aPrice: Price): {
|
||||||
@ -336,6 +392,20 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
case 'etf':
|
case 'etf':
|
||||||
assetClass = AssetClass.EQUITY;
|
assetClass = AssetClass.EQUITY;
|
||||||
assetSubClass = AssetSubClass.ETF;
|
assetSubClass = AssetSubClass.ETF;
|
||||||
|
break;
|
||||||
|
case 'future':
|
||||||
|
assetClass = AssetClass.COMMODITY;
|
||||||
|
assetSubClass = AssetSubClass.COMMODITY;
|
||||||
|
|
||||||
|
if (
|
||||||
|
aPrice?.shortName?.toLowerCase()?.startsWith('gold') ||
|
||||||
|
aPrice?.shortName?.toLowerCase()?.startsWith('palladium') ||
|
||||||
|
aPrice?.shortName?.toLowerCase()?.startsWith('platinum') ||
|
||||||
|
aPrice?.shortName?.toLowerCase()?.startsWith('silver')
|
||||||
|
) {
|
||||||
|
assetSubClass = AssetSubClass.PRECIOUS_METAL;
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 'mutualfund':
|
case 'mutualfund':
|
||||||
assetClass = AssetClass.EQUITY;
|
assetClass = AssetClass.EQUITY;
|
||||||
|
@ -1,12 +1,18 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PrismaModule } from './prisma.module';
|
import { PrismaModule } from './prisma.module';
|
||||||
import { PropertyModule } from './property/property.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DataProviderModule, PrismaModule, PropertyModule],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataProviderModule,
|
||||||
|
PrismaModule,
|
||||||
|
PropertyModule
|
||||||
|
],
|
||||||
providers: [ExchangeRateDataService],
|
providers: [ExchangeRateDataService],
|
||||||
exports: [ExchangeRateDataService]
|
exports: [ExchangeRateDataService]
|
||||||
})
|
})
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { isNumber, uniq } from 'lodash';
|
import { isNumber, uniq } from 'lodash';
|
||||||
|
|
||||||
|
import { ConfigurationService } from './configuration.service';
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from './prisma.service';
|
||||||
@ -11,11 +12,13 @@ import { PropertyService } from './property/property.service';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExchangeRateDataService {
|
export class ExchangeRateDataService {
|
||||||
|
private baseCurrency: string;
|
||||||
private currencies: string[] = [];
|
private currencies: string[] = [];
|
||||||
private currencyPairs: IDataGatheringItem[] = [];
|
private currencyPairs: IDataGatheringItem[] = [];
|
||||||
private exchangeRates: { [currencyPair: string]: number } = {};
|
private exchangeRates: { [currencyPair: string]: number } = {};
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService
|
private readonly propertyService: PropertyService
|
||||||
@ -24,7 +27,7 @@ export class ExchangeRateDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getCurrencies() {
|
public getCurrencies() {
|
||||||
return this.currencies?.length > 0 ? this.currencies : [baseCurrency];
|
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCurrencyPairs() {
|
public getCurrencyPairs() {
|
||||||
@ -32,6 +35,7 @@ export class ExchangeRateDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async initialize() {
|
public async initialize() {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
this.currencies = await this.prepareCurrencies();
|
this.currencies = await this.prepareCurrencies();
|
||||||
this.currencyPairs = [];
|
this.currencyPairs = [];
|
||||||
this.exchangeRates = {};
|
this.exchangeRates = {};
|
||||||
@ -212,14 +216,14 @@ export class ExchangeRateDataService {
|
|||||||
private prepareCurrencyPairs(aCurrencies: string[]) {
|
private prepareCurrencyPairs(aCurrencies: string[]) {
|
||||||
return aCurrencies
|
return aCurrencies
|
||||||
.filter((currency) => {
|
.filter((currency) => {
|
||||||
return currency !== baseCurrency;
|
return currency !== this.baseCurrency;
|
||||||
})
|
})
|
||||||
.map((currency) => {
|
.map((currency) => {
|
||||||
return {
|
return {
|
||||||
currency1: baseCurrency,
|
currency1: this.baseCurrency,
|
||||||
currency2: currency,
|
currency2: currency,
|
||||||
dataSource: this.dataProviderService.getPrimaryDataSource(),
|
dataSource: this.dataProviderService.getPrimaryDataSource(),
|
||||||
symbol: `${baseCurrency}${currency}`
|
symbol: `${this.baseCurrency}${currency}`
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,10 @@ import { CleanedEnvAccessors } from 'envalid';
|
|||||||
export interface Environment extends CleanedEnvAccessors {
|
export interface Environment extends CleanedEnvAccessors {
|
||||||
ACCESS_TOKEN_SALT: string;
|
ACCESS_TOKEN_SALT: string;
|
||||||
ALPHA_VANTAGE_API_KEY: string;
|
ALPHA_VANTAGE_API_KEY: string;
|
||||||
|
BASE_CURRENCY: string;
|
||||||
CACHE_TTL: number;
|
CACHE_TTL: number;
|
||||||
DATA_SOURCE_PRIMARY: string;
|
DATA_SOURCE_PRIMARY: string;
|
||||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
DATA_SOURCES: string[];
|
||||||
ENABLE_FEATURE_BLOG: boolean;
|
ENABLE_FEATURE_BLOG: boolean;
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||||
@ -15,6 +16,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
ENABLE_FEATURE_STATISTICS: boolean;
|
ENABLE_FEATURE_STATISTICS: boolean;
|
||||||
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
||||||
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
||||||
|
EOD_HISTORICAL_DATA_API_KEY: string;
|
||||||
GOOGLE_CLIENT_ID: string;
|
GOOGLE_CLIENT_ID: string;
|
||||||
GOOGLE_SECRET: string;
|
GOOGLE_SECRET: string;
|
||||||
GOOGLE_SHEETS_ACCOUNT: string;
|
GOOGLE_SHEETS_ACCOUNT: string;
|
||||||
@ -26,6 +28,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
PORT: number;
|
PORT: number;
|
||||||
RAKUTEN_RAPID_API_KEY: string;
|
RAKUTEN_RAPID_API_KEY: string;
|
||||||
REDIS_HOST: string;
|
REDIS_HOST: string;
|
||||||
|
REDIS_PASSWORD: string;
|
||||||
REDIS_PORT: number;
|
REDIS_PORT: number;
|
||||||
ROOT_URL: string;
|
ROOT_URL: string;
|
||||||
STRIPE_PUBLIC_KEY: string;
|
STRIPE_PUBLIC_KEY: string;
|
||||||
|
@ -1,18 +1,12 @@
|
|||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { MarketState } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
AssetClass,
|
|
||||||
AssetSubClass,
|
|
||||||
DataSource,
|
DataSource,
|
||||||
SymbolProfile,
|
SymbolProfile,
|
||||||
Type as TypeOfOrder
|
Type as TypeOfOrder
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
export const MarketState = {
|
|
||||||
closed: 'closed',
|
|
||||||
delayed: 'delayed',
|
|
||||||
open: 'open'
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IOrder {
|
export interface IOrder {
|
||||||
account: Account;
|
account: Account;
|
||||||
currency: string;
|
currency: string;
|
||||||
@ -39,10 +33,6 @@ export interface IDataProviderResponse {
|
|||||||
marketState: MarketState;
|
marketState: MarketState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDataGatheringItem {
|
export interface IDataGatheringItem extends UniqueAsset {
|
||||||
dataSource: DataSource;
|
|
||||||
date?: Date;
|
date?: Date;
|
||||||
symbol: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MarketState = typeof MarketState[keyof typeof MarketState];
|
|
||||||
|
@ -34,6 +34,20 @@ export class MarketDataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
|
||||||
|
const aggregations = await this.prismaService.marketData.aggregate({
|
||||||
|
_max: {
|
||||||
|
marketPrice: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return aggregations._max.marketPrice;
|
||||||
|
}
|
||||||
|
|
||||||
public async getRange({
|
public async getRange({
|
||||||
dateQuery,
|
dateQuery,
|
||||||
symbols
|
symbols
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import {
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
ScraperConfiguration,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
@ -12,8 +16,6 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { continents, countries } from 'countries-list';
|
import { continents, countries } from 'countries-list';
|
||||||
|
|
||||||
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SymbolProfileService {
|
export class SymbolProfileService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
@ -37,6 +39,35 @@ export class SymbolProfileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getSymbolProfiles(
|
public async getSymbolProfiles(
|
||||||
|
aUniqueAssets: UniqueAsset[]
|
||||||
|
): Promise<EnhancedSymbolProfile[]> {
|
||||||
|
return this.prismaService.symbolProfile
|
||||||
|
.findMany({
|
||||||
|
include: { SymbolProfileOverrides: true },
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
dataSource: {
|
||||||
|
in: aUniqueAssets.map(({ dataSource }) => {
|
||||||
|
return dataSource;
|
||||||
|
})
|
||||||
|
},
|
||||||
|
symbol: {
|
||||||
|
in: aUniqueAssets.map(({ symbol }) => {
|
||||||
|
return symbol;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
public async getSymbolProfilesBySymbols(
|
||||||
symbols: string[]
|
symbols: string[]
|
||||||
): Promise<EnhancedSymbolProfile[]> {
|
): Promise<EnhancedSymbolProfile[]> {
|
||||||
return this.prismaService.symbolProfile
|
return this.prismaService.symbolProfile
|
||||||
@ -59,7 +90,9 @@ export class SymbolProfileService {
|
|||||||
return symbolProfiles.map((symbolProfile) => {
|
return symbolProfiles.map((symbolProfile) => {
|
||||||
const item = {
|
const item = {
|
||||||
...symbolProfile,
|
...symbolProfile,
|
||||||
countries: this.getCountries(symbolProfile),
|
countries: this.getCountries(
|
||||||
|
symbolProfile?.countries as unknown as Prisma.JsonArray
|
||||||
|
),
|
||||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||||
sectors: this.getSectors(symbolProfile),
|
sectors: this.getSectors(symbolProfile),
|
||||||
symbolMapping: this.getSymbolMapping(symbolProfile)
|
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||||
@ -70,9 +103,17 @@ export class SymbolProfileService {
|
|||||||
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
||||||
item.assetSubClass =
|
item.assetSubClass =
|
||||||
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
|
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
|
||||||
item.countries =
|
|
||||||
(item.SymbolProfileOverrides.sectors as unknown as Country[]) ??
|
if (
|
||||||
item.countries;
|
(item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
|
||||||
|
?.length > 0
|
||||||
|
) {
|
||||||
|
item.countries = this.getCountries(
|
||||||
|
item.SymbolProfileOverrides
|
||||||
|
?.countries as unknown as Prisma.JsonArray
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
item.name = item.SymbolProfileOverrides?.name ?? item.name;
|
item.name = item.SymbolProfileOverrides?.name ?? item.name;
|
||||||
item.sectors =
|
item.sectors =
|
||||||
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
|
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
|
||||||
@ -85,20 +126,22 @@ export class SymbolProfileService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCountries(symbolProfile: SymbolProfile): Country[] {
|
private getCountries(aCountries: Prisma.JsonArray = []): Country[] {
|
||||||
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map(
|
if (aCountries === null) {
|
||||||
(country) => {
|
return [];
|
||||||
const { code, weight } = country as Prisma.JsonObject;
|
}
|
||||||
|
|
||||||
return {
|
return aCountries.map((country: Pick<Country, 'code' | 'weight'>) => {
|
||||||
code: code as string,
|
const { code, weight } = country;
|
||||||
continent:
|
|
||||||
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
return {
|
||||||
name: countries[code as string]?.name ?? UNKNOWN_KEY,
|
code,
|
||||||
weight: weight as number
|
weight,
|
||||||
};
|
continent:
|
||||||
}
|
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||||
);
|
name: countries[code as string]?.name ?? UNKNOWN_KEY
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getScraperConfiguration(
|
private getScraperConfiguration(
|
||||||
|
11
apps/api/src/services/tag/tag.module.ts
Normal file
11
apps/api/src/services/tag/tag.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TagService } from './tag.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
exports: [TagService],
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [TagService]
|
||||||
|
})
|
||||||
|
export class TagModule {}
|
30
apps/api/src/services/tag/tag.service.ts
Normal file
30
apps/api/src/services/tag/tag.service.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TagService {
|
||||||
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async get() {
|
||||||
|
return this.prismaService.tag.findMany({
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getByUser(userId: string) {
|
||||||
|
return this.prismaService.tag.findMany({
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
orders: {
|
||||||
|
some: {
|
||||||
|
userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,13 @@
|
|||||||
|
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
|
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: [TwitterBotService],
|
exports: [TwitterBotService],
|
||||||
imports: [ConfigurationModule, SymbolModule],
|
imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule],
|
||||||
providers: [TwitterBotService]
|
providers: [TwitterBotService]
|
||||||
})
|
})
|
||||||
export class TwitterBotModule {}
|
export class TwitterBotModule {}
|
||||||
|
@ -1,12 +1,19 @@
|
|||||||
|
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import {
|
||||||
|
PROPERTY_BENCHMARKS,
|
||||||
ghostfolioFearAndGreedIndexDataSource,
|
ghostfolioFearAndGreedIndexDataSource,
|
||||||
ghostfolioFearAndGreedIndexSymbol
|
ghostfolioFearAndGreedIndexSymbol
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
resolveFearAndGreedIndex,
|
||||||
|
resolveMarketCondition
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { isSunday } from 'date-fns';
|
import { isWeekend } from 'date-fns';
|
||||||
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
|
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -14,7 +21,9 @@ export class TwitterBotService {
|
|||||||
private twitterClient: TwitterApiReadWrite;
|
private twitterClient: TwitterApiReadWrite;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
private readonly symbolService: SymbolService
|
private readonly symbolService: SymbolService
|
||||||
) {
|
) {
|
||||||
this.twitterClient = new TwitterApi({
|
this.twitterClient = new TwitterApi({
|
||||||
@ -30,7 +39,7 @@ export class TwitterBotService {
|
|||||||
public async tweetFearAndGreedIndex() {
|
public async tweetFearAndGreedIndex() {
|
||||||
if (
|
if (
|
||||||
!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
|
!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
|
||||||
isSunday(new Date())
|
isWeekend(new Date())
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -48,7 +57,16 @@ export class TwitterBotService {
|
|||||||
symbolItem.marketPrice
|
symbolItem.marketPrice
|
||||||
);
|
);
|
||||||
|
|
||||||
const status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)\n\n#FearAndGreed #Markets #ServiceTweet`;
|
let status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)`;
|
||||||
|
|
||||||
|
const benchmarkListing = await this.getBenchmarkListing(3);
|
||||||
|
|
||||||
|
if (benchmarkListing?.length > 1) {
|
||||||
|
status += '\n\n';
|
||||||
|
status += '±% from ATH\n';
|
||||||
|
status += benchmarkListing;
|
||||||
|
}
|
||||||
|
|
||||||
const { data: createdTweet } = await this.twitterClient.v2.tweet(
|
const { data: createdTweet } = await this.twitterClient.v2.tweet(
|
||||||
status
|
status
|
||||||
);
|
);
|
||||||
@ -62,4 +80,35 @@ export class TwitterBotService {
|
|||||||
Logger.error(error, 'TwitterBotService');
|
Logger.error(error, 'TwitterBotService');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getBenchmarkListing(aMax: number) {
|
||||||
|
const benchmarkAssets: UniqueAsset[] =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
)) as UniqueAsset[]) ?? [];
|
||||||
|
|
||||||
|
const benchmarks = await this.benchmarkService.getBenchmarks(
|
||||||
|
benchmarkAssets
|
||||||
|
);
|
||||||
|
|
||||||
|
const benchmarkListing: string[] = [];
|
||||||
|
|
||||||
|
for (const [index, benchmark] of benchmarks.entries()) {
|
||||||
|
if (index > aMax - 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
benchmarkListing.push(
|
||||||
|
`${benchmark.name} ${(
|
||||||
|
benchmark.performances.allTimeHigh.performancePercent * 100
|
||||||
|
).toFixed(1)}%${
|
||||||
|
benchmark.marketCondition !== 'NEUTRAL_MARKET'
|
||||||
|
? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmarkListing.join('\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,6 @@
|
|||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"target": "es2015"
|
"target": "es2015"
|
||||||
},
|
},
|
||||||
"exclude": ["**/*.spec.ts", "**/*.test.ts"],
|
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
}
|
}
|
||||||
|
@ -5,5 +5,5 @@
|
|||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"types": ["jest", "node"]
|
"types": ["jest", "node"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"]
|
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
displayName: 'client',
|
displayName: 'client',
|
||||||
preset: '../../jest.preset.js',
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||||
globals: {
|
globals: {
|
||||||
'ts-jest': {
|
'ts-jest': {
|
||||||
@ -17,5 +17,6 @@ module.exports = {
|
|||||||
transform: {
|
transform: {
|
||||||
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
|
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)']
|
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
|
||||||
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
@ -1,20 +1,25 @@
|
|||||||
import { Platform } from '@angular/cdk/platform';
|
import { Platform } from '@angular/cdk/platform';
|
||||||
import { Inject, forwardRef } from '@angular/core';
|
import { Inject, forwardRef } from '@angular/core';
|
||||||
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
|
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
|
||||||
import { format, isValid } from 'date-fns';
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import * as deDateFnsLocale from 'date-fns/locale/de/index';
|
import { format, parse } from 'date-fns';
|
||||||
|
|
||||||
export class CustomDateAdapter extends NativeDateAdapter {
|
export class CustomDateAdapter extends NativeDateAdapter {
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
@Inject(MAT_DATE_LOCALE) public locale: string,
|
||||||
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
|
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
|
||||||
platform: Platform
|
platform: Platform
|
||||||
) {
|
) {
|
||||||
super(matDateLocale, platform);
|
super(matDateLocale, platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date as a string
|
||||||
|
*/
|
||||||
|
public format(aDate: Date, aParseFormat: string): string {
|
||||||
|
return format(aDate, getDateFormatString(this.locale));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the first day of the week to Monday
|
* Sets the first day of the week to Monday
|
||||||
*/
|
*/
|
||||||
@ -22,44 +27,10 @@ export class CustomDateAdapter extends NativeDateAdapter {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a date as a string according to the given format
|
|
||||||
*/
|
|
||||||
public format(aDate: Date, aParseFormat: string): string {
|
|
||||||
return format(aDate, aParseFormat, {
|
|
||||||
locale: <any>deDateFnsLocale
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a date from a provided value
|
* Parses a date from a provided value
|
||||||
*/
|
*/
|
||||||
public parse(aValue: any): Date {
|
public parse(aValue: string): Date {
|
||||||
let date: Date;
|
return parse(aValue, getDateFormatString(this.locale), new Date());
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO
|
|
||||||
// Native date parser from the following formats:
|
|
||||||
// - 'd.M.yyyy'
|
|
||||||
// - 'dd.MM.yyyy'
|
|
||||||
// https://github.com/you-dont-need/You-Dont-Need-Momentjs#string--date-format
|
|
||||||
const datePattern = /^(\d{1,2}).(\d{1,2}).(\d{4})$/;
|
|
||||||
const [, day, month, year] = datePattern.exec(aValue);
|
|
||||||
|
|
||||||
date = new Date(
|
|
||||||
parseInt(year, 10),
|
|
||||||
parseInt(month, 10) - 1, // monthIndex
|
|
||||||
parseInt(day, 10)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
} finally {
|
|
||||||
const isDateValid = date && isValid(date);
|
|
||||||
|
|
||||||
if (isDateValid) {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,13 @@ const routes: Routes = [
|
|||||||
(m) => m.ChangelogPageModule
|
(m) => m.ChangelogPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'about/privacy-policy',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
|
||||||
|
(m) => m.PrivacyPolicyPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'account',
|
path: 'account',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -120,6 +127,13 @@ const routes: Routes = [
|
|||||||
(m) => m.FirePageModule
|
(m) => m.FirePageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'portfolio/holdings',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/portfolio/holdings/holdings-page.module').then(
|
||||||
|
(m) => m.HoldingsPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'portfolio/report',
|
path: 'portfolio/report',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -200,7 +200,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[disabled]="element.isDefault || element.Order?.length > 0"
|
[disabled]="element.isDefault || element.transactionCount > 0"
|
||||||
(click)="onDeleteAccount(element.id)"
|
(click)="onDeleteAccount(element.id)"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||||
|
@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
|
||||||
|
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
|
||||||
|
import { AdminJobs, User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { JobStatus } from 'bull';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-admin-jobs',
|
||||||
|
styleUrls: ['./admin-jobs.scss'],
|
||||||
|
templateUrl: './admin-jobs.html'
|
||||||
|
})
|
||||||
|
export class AdminJobsComponent implements OnDestroy, OnInit {
|
||||||
|
public defaultDateTimeFormat: string;
|
||||||
|
public filterForm: FormGroup;
|
||||||
|
public jobs: AdminJobs['jobs'] = [];
|
||||||
|
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.defaultDateTimeFormat = getDateWithTimeFormatString(
|
||||||
|
this.user.settings.locale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.filterForm = this.formBuilder.group({
|
||||||
|
status: []
|
||||||
|
});
|
||||||
|
|
||||||
|
this.filterForm.valueChanges
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
const currentFilter = this.filterForm.get('status').value;
|
||||||
|
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fetchJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteJob(aId: string) {
|
||||||
|
this.adminService
|
||||||
|
.deleteJob(aId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.fetchJobs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteJobs() {
|
||||||
|
const currentFilter = this.filterForm.get('status').value;
|
||||||
|
|
||||||
|
this.adminService
|
||||||
|
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onViewData(aData: AdminJobs['jobs'][0]['data']) {
|
||||||
|
alert(JSON.stringify(aData, null, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) {
|
||||||
|
alert(JSON.stringify(aStacktrace, null, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchJobs(aStatus?: JobStatus[]) {
|
||||||
|
this.adminService
|
||||||
|
.fetchJobs({ status: aStatus })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ jobs }) => {
|
||||||
|
this.jobs = jobs;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
130
apps/client/src/app/components/admin-jobs/admin-jobs.html
Normal file
130
apps/client/src/app/components/admin-jobs/admin-jobs.html
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<form class="align-items-center d-flex" [formGroup]="filterForm">
|
||||||
|
<mat-form-field appearance="outline" class="flex-grow-1">
|
||||||
|
<mat-select formControlName="status">
|
||||||
|
<mat-option></mat-option>
|
||||||
|
<mat-option
|
||||||
|
*ngFor="let statusFilterOption of statusFilterOptions"
|
||||||
|
[value]="statusFilterOption"
|
||||||
|
>{{ statusFilterOption }}</mat-option
|
||||||
|
>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
class="ml-1"
|
||||||
|
color="warn"
|
||||||
|
mat-flat-button
|
||||||
|
(click)="onDeleteJobs()"
|
||||||
|
>
|
||||||
|
<span i18n>Delete Jobs</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<table class="gf-table w-100">
|
||||||
|
<thead>
|
||||||
|
<tr class="mat-header-row">
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<ng-container *ngFor="let job of jobs">
|
||||||
|
<tr class="mat-row">
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="arrow-down-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
|
||||||
|
<span i18n>Asset Profile</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container
|
||||||
|
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
|
||||||
|
>
|
||||||
|
<span i18n>Historical Market Data</span>
|
||||||
|
</ng-container>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
|
||||||
|
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
{{ job.attemptsMade }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
{{ job.timestamp | date: defaultDateTimeFormat }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
{{ job.finishedOn | date: defaultDateTimeFormat }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'active'"
|
||||||
|
name="play-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'completed'"
|
||||||
|
class="text-success"
|
||||||
|
name="checkmark-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'delayed'"
|
||||||
|
name="time-outline"
|
||||||
|
[ngClass]="{ 'text-danger': job.stacktrace?.length > 0 }"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'failed'"
|
||||||
|
class="text-danger"
|
||||||
|
name="alert-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'paused'"
|
||||||
|
name="pause-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'waiting'"
|
||||||
|
name="cafe-outline"
|
||||||
|
></ion-icon>
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
<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)="onViewData(job.data)">
|
||||||
|
View Data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
i18n
|
||||||
|
mat-menu-item
|
||||||
|
[disabled]="job.stacktrace?.length <= 0"
|
||||||
|
(click)="onViewStacktrace(job.stacktrace)"
|
||||||
|
>
|
||||||
|
View Stacktrace
|
||||||
|
</button>
|
||||||
|
<button i18n mat-menu-item (click)="onDeleteJob(job.id)">
|
||||||
|
Delete Job
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-container>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,22 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
|
||||||
|
import { AdminJobsComponent } from './admin-jobs.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AdminJobsComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAdminJobsModule {}
|
@ -0,0 +1,5 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -2,8 +2,10 @@
|
|||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
[historicalDataItems]="historicalDataItems"
|
[historicalDataItems]="historicalDataItems"
|
||||||
|
[locale]="locale"
|
||||||
[showXAxis]="true"
|
[showXAxis]="true"
|
||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
|
[symbol]="symbol"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
||||||
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||||
|
@ -8,11 +8,13 @@ import {
|
|||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getDateFormatString,
|
getDateFormatString,
|
||||||
getLocale
|
getLocale
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
@ -53,14 +55,24 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
|
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
|
||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog
|
private dialog: MatDialog,
|
||||||
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
@ -145,7 +157,8 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
date,
|
date,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
dataSource: this.dataSource,
|
dataSource: this.dataSource,
|
||||||
symbol: this.symbol
|
symbol: this.symbol,
|
||||||
|
user: this.user
|
||||||
},
|
},
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface MarketDataDetailDialogParams {
|
export interface MarketDataDetailDialogParams {
|
||||||
@ -5,4 +6,5 @@ export interface MarketDataDetailDialogParams {
|
|||||||
date: Date;
|
date: Date;
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
user: User;
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
@ -24,11 +25,16 @@ export class MarketDataDetailDialog implements OnDestroy {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams,
|
||||||
|
private dateAdapter: DateAdapter<any>,
|
||||||
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
|
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams
|
@Inject(MAT_DATE_LOCALE) private locale: string
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {
|
||||||
|
this.locale = this.data.user?.settings?.locale;
|
||||||
|
this.dateAdapter.setLocale(this.locale);
|
||||||
|
}
|
||||||
|
|
||||||
public onCancel(): void {
|
public onCancel(): void {
|
||||||
this.dialogRef.close({ withRefresh: false });
|
this.dialogRef.close({ withRefresh: false });
|
||||||
|
@ -31,9 +31,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
@ -53,9 +50,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.fetchAdminMarketData();
|
this.fetchAdminMarketData();
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,6 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
|||||||
import {
|
import {
|
||||||
differenceInSeconds,
|
differenceInSeconds,
|
||||||
formatDistanceToNowStrict,
|
formatDistanceToNowStrict,
|
||||||
isValid,
|
|
||||||
parseISO
|
parseISO
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
@ -32,23 +31,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public couponDuration: StringValue = '30 days';
|
public couponDuration: StringValue = '30 days';
|
||||||
public coupons: Coupon[];
|
public coupons: Coupon[];
|
||||||
public customCurrencies: string[];
|
public customCurrencies: string[];
|
||||||
public dataGatheringInProgress: boolean;
|
|
||||||
public dataGatheringProgress: number;
|
|
||||||
public exchangeRates: { label1: string; label2: string; value: number }[];
|
public exchangeRates: { label1: string; label2: string; value: number }[];
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionForSystemMessage: boolean;
|
public hasPermissionForSystemMessage: boolean;
|
||||||
public hasPermissionToToggleReadOnlyMode: boolean;
|
public hasPermissionToToggleReadOnlyMode: boolean;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
public lastDataGathering: string;
|
|
||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
public userCount: number;
|
public userCount: number;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
@ -82,9 +75,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.fetchAdminData();
|
this.fetchAdminData();
|
||||||
}
|
}
|
||||||
@ -128,7 +118,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public onDeleteCoupon(aCouponCode: string) {
|
public onDeleteCoupon(aCouponCode: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this coupon?');
|
const confirmation = confirm('Do you really want to delete this coupon?');
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation === true) {
|
||||||
const coupons = this.coupons.filter((coupon) => {
|
const coupons = this.coupons.filter((coupon) => {
|
||||||
return coupon.code !== aCouponCode;
|
return coupon.code !== aCouponCode;
|
||||||
});
|
});
|
||||||
@ -139,7 +129,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public onDeleteCurrency(aCurrency: string) {
|
public onDeleteCurrency(aCurrency: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this currency?');
|
const confirmation = confirm('Do you really want to delete this currency?');
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation === true) {
|
||||||
const currencies = this.customCurrencies.filter((currency) => {
|
const currencies = this.customCurrencies.filter((currency) => {
|
||||||
return currency !== aCurrency;
|
return currency !== aCurrency;
|
||||||
});
|
});
|
||||||
@ -152,8 +142,23 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onFlushCache() {
|
public onFlushCache() {
|
||||||
this.cacheService
|
const confirmation = confirm('Do you really want to flush the cache?');
|
||||||
.flush()
|
|
||||||
|
if (confirmation === true) {
|
||||||
|
this.cacheService
|
||||||
|
.flush()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGather7Days() {
|
||||||
|
this.adminService
|
||||||
|
.gather7Days()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -163,20 +168,14 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onGatherMax() {
|
public onGatherMax() {
|
||||||
const confirmation = confirm(
|
this.adminService
|
||||||
'This action may take some time. Do you want to proceed?'
|
.gatherMax()
|
||||||
);
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
if (confirmation === true) {
|
setTimeout(() => {
|
||||||
this.adminService
|
window.location.reload();
|
||||||
.gatherMax()
|
}, 300);
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
});
|
||||||
.subscribe(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public onGatherProfileData() {
|
public onGatherProfileData() {
|
||||||
@ -207,39 +206,15 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
this.dataService
|
this.dataService
|
||||||
.fetchAdminData()
|
.fetchAdminData()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(
|
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
||||||
({
|
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||||
dataGatheringProgress,
|
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||||
exchangeRates,
|
this.exchangeRates = exchangeRates;
|
||||||
lastDataGathering,
|
this.transactionCount = transactionCount;
|
||||||
settings,
|
this.userCount = userCount;
|
||||||
transactionCount,
|
|
||||||
userCount
|
|
||||||
}) => {
|
|
||||||
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
|
||||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
|
||||||
this.dataGatheringProgress = dataGatheringProgress;
|
|
||||||
this.exchangeRates = exchangeRates;
|
|
||||||
|
|
||||||
if (isValid(parseISO(lastDataGathering?.toString()))) {
|
this.changeDetectorRef.markForCheck();
|
||||||
this.lastDataGathering = formatDistanceToNowStrict(
|
});
|
||||||
new Date(lastDataGathering),
|
|
||||||
{
|
|
||||||
addSuffix: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else if (lastDataGathering === 'IN_PROGRESS') {
|
|
||||||
this.dataGatheringInProgress = true;
|
|
||||||
} else {
|
|
||||||
this.lastDataGathering = 'Starting soon...';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.transactionCount = transactionCount;
|
|
||||||
this.userCount = userCount;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateCouponCode(aLength: number) {
|
private generateCouponCode(aLength: number) {
|
||||||
|
@ -19,37 +19,30 @@
|
|||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Data Gathering</div>
|
<div class="w-50" i18n>Data Gathering</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<div>
|
<div class="overflow-hidden">
|
||||||
<ng-container *ngIf="lastDataGathering"
|
|
||||||
>{{ lastDataGathering }}</ng-container
|
|
||||||
>
|
|
||||||
<ng-container *ngIf="dataGatheringInProgress" i18n
|
|
||||||
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
|
|
||||||
}})</ng-container
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 overflow-hidden">
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<button
|
<button
|
||||||
color="accent"
|
color="accent"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
(click)="onFlushCache()"
|
(click)="onGather7Days()"
|
||||||
>
|
>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
class="mr-1"
|
class="mr-1"
|
||||||
name="close-circle-outline"
|
name="cloud-download-outline"
|
||||||
></ion-icon>
|
></ion-icon>
|
||||||
<span i18n>Reset Data Gathering</span>
|
<span i18n>Gather Recent Data</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<button
|
<button
|
||||||
color="warn"
|
color="accent"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onGatherMax()"
|
(click)="onGatherMax()"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="cloud-download-outline"
|
||||||
|
></ion-icon>
|
||||||
<span i18n>Gather All Data</span>
|
<span i18n>Gather All Data</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -58,7 +51,6 @@
|
|||||||
class="mb-2 mr-2"
|
class="mb-2 mr-2"
|
||||||
color="accent"
|
color="accent"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onGatherProfileData()"
|
(click)="onGatherProfileData()"
|
||||||
>
|
>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
@ -97,7 +89,6 @@
|
|||||||
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
||||||
class="mini-icon mx-1 no-min-width px-2"
|
class="mini-icon mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onDeleteCurrency(exchangeRate.label2)"
|
(click)="onDeleteCurrency(exchangeRate.label2)"
|
||||||
>
|
>
|
||||||
<ion-icon name="trash-outline"></ion-icon>
|
<ion-icon name="trash-outline"></ion-icon>
|
||||||
@ -109,7 +100,6 @@
|
|||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onAddCurrency()"
|
(click)="onAddCurrency()"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
||||||
@ -126,7 +116,6 @@
|
|||||||
<button
|
<button
|
||||||
class="mini-icon mx-1 no-min-width px-2"
|
class="mini-icon mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onDeleteSystemMessage()"
|
(click)="onDeleteSystemMessage()"
|
||||||
>
|
>
|
||||||
<ion-icon name="trash-outline"></ion-icon>
|
<ion-icon name="trash-outline"></ion-icon>
|
||||||
@ -197,6 +186,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>Housekeeping</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<button color="warn" mat-flat-button (click)="onFlushCache()">
|
||||||
|
<ion-icon class="mr-1" name="close-circle-outline"></ion-icon>
|
||||||
|
<span i18n>Flush Cache</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { AdminData, User } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
differenceInSeconds,
|
differenceInSeconds,
|
||||||
formatDistanceToNowStrict,
|
formatDistanceToNowStrict,
|
||||||
@ -15,21 +16,25 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './admin-users.html'
|
templateUrl: './admin-users.html'
|
||||||
})
|
})
|
||||||
export class AdminUsersComponent implements OnDestroy, OnInit {
|
export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||||
|
public user: User;
|
||||||
public users: AdminData['users'];
|
public users: AdminData['users'];
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService
|
private dataService: DataService,
|
||||||
) {}
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.fetchAdminData();
|
this.fetchAdminData();
|
||||||
}
|
}
|
||||||
|
@ -35,24 +35,36 @@
|
|||||||
>{{ userItem.alias || (userItem.id | slice:0:5) +
|
>{{ userItem.alias || (userItem.id | slice:0:5) +
|
||||||
'...' }}</span
|
'...' }}</span
|
||||||
>
|
>
|
||||||
<ion-icon
|
<gf-premium-indicator
|
||||||
*ngIf="userItem?.subscription?.type === 'Premium'"
|
*ngIf="userItem?.subscription?.type === 'Premium'"
|
||||||
class="ml-1 text-muted"
|
class="ml-1"
|
||||||
name="diamond-outline"
|
></gf-premium-indicator>
|
||||||
></ion-icon>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
{{ formatDistanceToNow(userItem.createdAt) }}
|
{{ formatDistanceToNow(userItem.createdAt) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
<td class="mat-cell px-1 py-2">
|
||||||
{{ userItem.accountCount }}
|
<gf-value
|
||||||
|
class="align-items-end"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="userItem.accountCount"
|
||||||
|
></gf-value>
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
<td class="mat-cell px-1 py-2">
|
||||||
{{ userItem.transactionCount }}
|
<gf-value
|
||||||
|
class="align-items-end"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="userItem.transactionCount"
|
||||||
|
></gf-value>
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
<td class="mat-cell px-1 py-2">
|
||||||
{{ userItem.engagement | number: '1.0-0' }}
|
<gf-value
|
||||||
|
class="align-items-end"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[precision]="0"
|
||||||
|
[value]="userItem.engagement"
|
||||||
|
></gf-value>
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2">
|
<td class="mat-cell px-1 py-2">
|
||||||
{{ formatDistanceToNow(userItem.lastActivity) }}
|
{{ formatDistanceToNow(userItem.lastActivity) }}
|
||||||
|
@ -2,13 +2,21 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||||
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
import { AdminUsersComponent } from './admin-users.component';
|
import { AdminUsersComponent } from './admin-users.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AdminUsersComponent],
|
declarations: [AdminUsersComponent],
|
||||||
exports: [],
|
exports: [],
|
||||||
imports: [CommonModule, MatButtonModule, MatMenuModule],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfPremiumIndicatorModule,
|
||||||
|
GfValueModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatMenuModule
|
||||||
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfAdminUsersModule {}
|
export class GfAdminUsersModule {}
|
||||||
|
@ -66,7 +66,9 @@
|
|||||||
>Resources</a
|
>Resources</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionForSubscription"
|
*ngIf="
|
||||||
|
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||||
|
"
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
@ -203,7 +205,9 @@
|
|||||||
>Resources</a
|
>Resources</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionForSubscription"
|
*ngIf="
|
||||||
|
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||||
|
"
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
@ -229,13 +233,7 @@
|
|||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo
|
<gf-logo [hideName]="currentRoute === 'register'"></gf-logo>
|
||||||
[hideName]="
|
|
||||||
!currentRoute ||
|
|
||||||
currentRoute === 'register' ||
|
|
||||||
currentRoute === 'start'
|
|
||||||
"
|
|
||||||
></gf-logo>
|
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
|
@ -18,6 +18,8 @@ import { DeviceDetectorService } from 'ngx-device-detector';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-home-holdings',
|
selector: 'gf-home-holdings',
|
||||||
styleUrls: ['./home-holdings.scss'],
|
styleUrls: ['./home-holdings.scss'],
|
||||||
@ -34,9 +36,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -79,9 +78,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
@ -126,12 +122,16 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
data: {
|
data: <PositionDetailDialogParams>{
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
baseCurrency: this.user?.settings?.baseCurrency,
|
baseCurrency: this.user?.settings?.baseCurrency,
|
||||||
deviceType: this.deviceType,
|
deviceType: this.deviceType,
|
||||||
hasImpersonationId: this.hasImpersonationId,
|
hasImpersonationId: this.hasImpersonationId,
|
||||||
|
hasPermissionToReportDataGlitch: hasPermission(
|
||||||
|
this.user?.permissions,
|
||||||
|
permissions.reportDataGlitch
|
||||||
|
),
|
||||||
locale: this.user?.settings?.locale
|
locale: this.user?.settings?.locale
|
||||||
},
|
},
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
Benchmark,
|
||||||
|
HistoricalDataItem,
|
||||||
|
InfoItem,
|
||||||
|
User
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
@ -15,19 +19,17 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './home-market.html'
|
templateUrl: './home-market.html'
|
||||||
})
|
})
|
||||||
export class HomeMarketComponent implements OnDestroy, OnInit {
|
export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||||
|
public benchmarks: Benchmark[];
|
||||||
public fearAndGreedIndex: number;
|
public fearAndGreedIndex: number;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public historicalData: HistoricalDataItem[];
|
public historicalData: HistoricalDataItem[];
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
public readonly numberOfDays = 90;
|
public readonly numberOfDays = 180;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -70,14 +72,20 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchBenchmarks()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ benchmarks }) => {
|
||||||
|
this.benchmarks = benchmarks;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
|
@ -1,18 +1,19 @@
|
|||||||
<div
|
<div class="container">
|
||||||
class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100"
|
<h3 class="mb-3 text-center" i18n>Markets</h3>
|
||||||
>
|
<div class="mb-5 row">
|
||||||
<div class="no-gutters row w-100">
|
|
||||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
<div class="mb-2 text-center text-muted">
|
<div class="mb-2 text-center text-muted">
|
||||||
<small i18n>Last {{ numberOfDays }} Days</small>
|
<small i18n>Last {{ numberOfDays }} Days</small>
|
||||||
</div>
|
</div>
|
||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
class="mb-5"
|
class="mb-3"
|
||||||
|
symbol="Fear & Greed Index"
|
||||||
yMax="100"
|
yMax="100"
|
||||||
yMaxLabel="Greed"
|
yMaxLabel="Greed"
|
||||||
yMin="0"
|
yMin="0"
|
||||||
yMinLabel="Fear"
|
yMinLabel="Fear"
|
||||||
[historicalDataItems]="historicalData"
|
[historicalDataItems]="historicalData"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
[showXAxis]="true"
|
[showXAxis]="true"
|
||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
@ -23,4 +24,20 @@
|
|||||||
></gf-fear-and-greed-index>
|
></gf-fear-and-greed-index>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
|
<gf-benchmark
|
||||||
|
*ngFor="let benchmark of benchmarks"
|
||||||
|
class="py-2"
|
||||||
|
[benchmark]="benchmark"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
></gf-benchmark>
|
||||||
|
<gf-benchmark
|
||||||
|
*ngIf="!benchmarks"
|
||||||
|
class="py-2"
|
||||||
|
[benchmark]="undefined"
|
||||||
|
></gf-benchmark>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
|
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
|
||||||
|
import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module';
|
||||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||||
|
|
||||||
import { HomeMarketComponent } from './home-market.component';
|
import { HomeMarketComponent } from './home-market.component';
|
||||||
@ -8,7 +9,12 @@ import { HomeMarketComponent } from './home-market.component';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [HomeMarketComponent],
|
declarations: [HomeMarketComponent],
|
||||||
exports: [],
|
exports: [],
|
||||||
imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfBenchmarkModule,
|
||||||
|
GfFearAndGreedIndexModule,
|
||||||
|
GfLineChartModule
|
||||||
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -42,9 +42,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -69,9 +66,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user