Compare commits
244 Commits
Author | SHA1 | Date | |
---|---|---|---|
0bb0b12991 | |||
d887de50d2 | |||
2571e5b8c0 | |||
e444d717e5 | |||
1866e26c1d | |||
9923074e04 | |||
c367e61b85 | |||
364f1ad9b9 | |||
2394cbd6fe | |||
a74d5cce20 | |||
95bcc3f32d | |||
e9dbd4a55d | |||
d440b09dc9 | |||
cc16ba5dc8 | |||
d10227bc39 | |||
4e214c32e8 | |||
49e2862e03 | |||
34e33a2400 | |||
ec9bc984af | |||
2388c494df | |||
d71ab10eed | |||
0e0592180f | |||
60e2aff488 | |||
7b5454e7de | |||
30835ced88 | |||
8897f32bc5 | |||
abaa6b5f27 | |||
2060fcaf0b | |||
fd2408dd62 | |||
31cca024f1 | |||
b535122945 | |||
5113e4e3ad | |||
35e039748f | |||
c6b9e0aa5b | |||
b250491ca5 | |||
61e501c659 | |||
c0f19d56ec | |||
8e2b235b1f | |||
c3407e9b34 | |||
74193e4ee2 | |||
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 | |||
e9a46cb224 | |||
4a75c6d483 | |||
bbe9183fb0 | |||
1b03ddc586 | |||
beb12637ce | |||
20358d9105 | |||
0e4c39d145 | |||
83ebacbb06 | |||
7c58c5fb7f | |||
f3271ab1ff | |||
9f597cbff1 | |||
90efc2ac51 | |||
056b318d86 | |||
82ede2fe32 | |||
8ae041faa0 | |||
bd4608e521 | |||
0d8362ca8f | |||
638ae3f7fa | |||
6e7cf0380b | |||
ec2ecab751 | |||
598fe41b8c | |||
ba7c98d325 | |||
65e062ad26 | |||
8526b5a027 | |||
f1feb04f29 | |||
500e09d95a | |||
aef91d3e30 | |||
70723f8d5f | |||
6cfd052781 | |||
23f2ac472e | |||
d5ba624403 | |||
9b49ed77f7 | |||
08405d14d5 | |||
56b169e1c4 | |||
67f2b326f3 | |||
3d3a6c1204 | |||
bfc8f87d88 | |||
957200854c | |||
6575440877 | |||
255af6a6e9 | |||
795a6a6799 | |||
2a854e2574 | |||
52d113e71f | |||
204c7360c3 | |||
fa41e25c8f | |||
ba765b9de6 | |||
fa79196278 | |||
d1230ca3ad | |||
69a1316cfe | |||
a256b783bc | |||
ebbdd47fa2 | |||
3d21e2eac6 | |||
bc117fe601 | |||
65f6bcb166 | |||
b8c43ecf89 | |||
1214127ec0 | |||
e986310302 | |||
6762572658 | |||
eb77652d6a | |||
a7b59f4ec6 | |||
dd71f2be45 | |||
d530cb38fa | |||
16b79a7e60 | |||
7f0c98cae6 | |||
57e4163848 | |||
14773bf1aa |
8
.env
8
.env
@ -3,14 +3,14 @@ COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||
# CACHE
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||
|
||||
# POSTGRES
|
||||
POSTGRES_DB=ghostfolio-db
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||
|
||||
ACCESS_TOKEN_SALT=GHOSTFOLIO
|
||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||
ALPHA_VANTAGE_API_KEY=
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||
JWT_SECRET_KEY=123456
|
||||
PORT=3333
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
|
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
custom: ['https://www.buymeacoffee.com/ghostfolio']
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -24,15 +24,16 @@
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
.env.prod
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/dist
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
|
574
CHANGELOG.md
574
CHANGELOG.md
@ -5,6 +5,578 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.171.0 - 22.07.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added _Internet Identity_ as a new social login provider
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the empty state of the
|
||||
- _Analysis_ section
|
||||
- _Holdings_ section
|
||||
- performance chart on the home page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the distorted tooltip in the performance chart on the home page
|
||||
- Fixed a calculation issue of the current month in the investment timeline grouped by month
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.170.0 - 19.07.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for the tags in the create or edit transaction dialog
|
||||
- Added support for the cryptocurrency _TerraUSD_ (`UST-USD`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema
|
||||
- Removed the activities import limit for users with a subscription
|
||||
|
||||
### Todo
|
||||
|
||||
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
|
||||
|
||||
## 1.169.0 - 14.07.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for the cryptocurrency _Songbird_ (`SGB1-USD`)
|
||||
- Added support for the cryptocurrency _Terra 2.0_ (`LUNA2-USD`)
|
||||
- Added a blog post
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed the cryptocurrencies list to support more coins by default
|
||||
- Upgraded `date-fns` from version `2.22.1` to `2.28.0`
|
||||
|
||||
## 1.168.0 - 10.07.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the investment timeline grouped by month
|
||||
|
||||
### Changed
|
||||
|
||||
- Handled an occasional currency pair inconsistency in the _Yahoo Finance_ service (`GBP=X` instead of `USDGBP=X`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the content height of the account detail dialog
|
||||
|
||||
## 1.167.0 - 07.07.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added _Markets_ to the public pages
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the _Create Account_ link in the _Live Demo_
|
||||
- Upgraded `ngx-markdown` from version `13.0.0` to `14.0.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the _Holdings_ section for users without a subscription
|
||||
|
||||
## 1.166.0 - 30.06.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added an account detail dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the label of the (symbol) search
|
||||
- Refactored the demo account as a route (`/demo`)
|
||||
- Upgraded `nestjs` from version `8.2.3` to `8.4.7`
|
||||
- Upgraded `prisma` from version `3.14.0` to `3.15.2`
|
||||
- Upgraded `yahoo-finance2` from version `2.3.2` to `2.3.3`
|
||||
- Upgraded `zone.js` from version `0.11.4` to `0.11.6`
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for sub-labels in the value component
|
||||
- Added a symbol profile overrides model for manual adjustments
|
||||
|
||||
### Changed
|
||||
|
||||
- Reused the value component in the _Ghostfolio in Numbers_ section of the about page
|
||||
- Persisted the savings rate in the _FIRE_ calculator
|
||||
- Upgraded `yahoo-finance2` from version `2.3.0` to `2.3.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the calculation of the total value for sell and dividend activities in the create or edit transaction dialog
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.139.0 - 18.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the total amount to the tooltip in the chart of the _FIRE_ calculator
|
||||
|
||||
### Changed
|
||||
|
||||
- Beautified the ETF names in the symbol profile
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with changing the investment horizon in the chart of the _FIRE_ calculator
|
||||
- Fixed an issue with the end dates in the `.ics` file of the future activities (drafts) export
|
||||
- Fixed the data source of the _Fear & Greed Index_ (market mood)
|
||||
|
||||
## 1.138.0 - 16.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to export a single future activity (draft) as an `.ics` file
|
||||
- Added the _Boringly Getting Rich_ guide to the resources section
|
||||
|
||||
### Changed
|
||||
|
||||
- Separated the deposit and savings in the chart of the _FIRE_ calculator
|
||||
|
||||
## 1.137.0 - 15.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to export future activities (drafts) as an `.ics` file
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the search functionality to `yahoo-finance2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the average price / investment calculation for sell activities
|
||||
|
||||
## 1.136.0 - 13.04.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the _Total_ label to _Total Assets_ in the portfolio summary tab on the home page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the calculation of the projected total amount in the _FIRE_ calculator
|
||||
- Fixed an issue with the loading state of the _FIRE_ calculator
|
||||
|
||||
## 1.135.0 - 10.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a calculator to the _FIRE_ section
|
||||
- Added support for the cryptocurrency _Terra_ (`LUNA1-USD`)
|
||||
- Added support for the cryptocurrency _THORChain_ (`RUNE-USD`)
|
||||
|
||||
## 1.134.0 - 09.04.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Switched to the new calculation engine
|
||||
- Improved the 4% rule in the _FIRE_ section
|
||||
- Changed the background of the header to a solid color
|
||||
|
||||
## 1.133.0 - 07.04.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the empty state of the portfolio proportion chart component
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with dates in the value component
|
||||
|
||||
## 1.132.1 - 06.04.2022
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with percentages in the value component
|
||||
|
||||
## 1.132.0 - 06.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for localization (date and number format) in user settings
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the label of the average price from _Ø Buy Price_ to _Average Unit Price_
|
||||
|
||||
## 1.131.1 - 04.04.2022
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the missing API version in the _Stripe_ success callback url
|
||||
|
||||
## 1.131.0 - 02.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added API versioning
|
||||
- Added more durations in the coupon system
|
||||
|
||||
### Changed
|
||||
|
||||
- Display the value in base currency in the accounts table on mobile
|
||||
- Display the value in base currency in the activities table on mobile
|
||||
- Renamed `orders` to `activities` in import and export functionality
|
||||
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
|
||||
- Improved the pricing page
|
||||
- Upgraded `prisma` from version `3.10.0` to `3.11.1`
|
||||
- Upgraded `yahoo-finance2` from version `2.2.0` to `2.3.0`
|
||||
|
||||
## 1.130.0 - 30.03.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a _FIRE_ (Financial Independence, Retire Early) section including the 4% rule
|
||||
- Added more durations in the coupon system
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the currency conversion (duplicate) in the account calculations
|
||||
|
||||
## 1.129.0 - 26.03.2022
|
||||
|
||||
### Added
|
||||
@ -180,7 +752,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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 `storybook` from version `6.4.9` to `6.4.18`
|
||||
|
||||
|
@ -12,7 +12,7 @@ COPY ./package.json package.json
|
||||
COPY ./yarn.lock yarn.lock
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN apk add --no-cache python3 g++ make openssl
|
||||
RUN apk add --no-cache python3 g++ make openssl git
|
||||
RUN yarn install
|
||||
|
||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||
@ -23,7 +23,7 @@ COPY ./angular.json angular.json
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.js replace.build.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 ./libs libs
|
||||
COPY ./apps apps
|
||||
|
140
README.md
140
README.md
@ -9,10 +9,10 @@
|
||||
|
||||
<h1>Ghostfolio</h1>
|
||||
<p>
|
||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||
<strong>Open Source Wealth Management Software</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><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>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#contributing">
|
||||
@ -24,17 +24,18 @@
|
||||
</p>
|
||||
</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">
|
||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
||||
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
||||
</div>
|
||||
|
||||
## Ghostfolio Premium
|
||||
|
||||
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?
|
||||
|
||||
@ -47,7 +48,7 @@ Ghostfolio is for you if you are...
|
||||
- 🧘 into minimalism
|
||||
- 🧺 caring about diversifying your financial resources
|
||||
- 🆓 interested in financial independence
|
||||
- 🙅 saying no to spreadsheets in 2021
|
||||
- 🙅 saying no to spreadsheets in 2022
|
||||
- 😎 still reading this list
|
||||
|
||||
## Features
|
||||
@ -62,6 +63,10 @@ Ghostfolio is for you if you are...
|
||||
- ✅ Zen Mode
|
||||
- ✅ Mobile-first design
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||
</div>
|
||||
|
||||
## Technology Stack
|
||||
|
||||
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
|
||||
@ -74,46 +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).
|
||||
|
||||
## Run with Docker (self-hosting)
|
||||
## Self-hosting
|
||||
|
||||
### Prerequisites
|
||||
### Run with Docker Compose
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
#### Prerequisites
|
||||
|
||||
### 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):
|
||||
|
||||
```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:
|
||||
|
||||
```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:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.build.yml build
|
||||
docker-compose -f docker/docker-compose.build.yml up -d
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
```
|
||||
|
||||
#### Setup Database
|
||||
##### Setup Database
|
||||
|
||||
Run the following command to setup the database once Ghostfolio is running:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
||||
```
|
||||
|
||||
### Fetch Historical Data
|
||||
#### Fetch Historical Data
|
||||
|
||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
|
||||
@ -121,13 +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. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
### Migrate Database
|
||||
#### Upgrade Version
|
||||
|
||||
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
||||
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 --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose-build-local.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
|
||||
|
||||
@ -136,11 +147,12 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- A local copy of this Git repository (clone)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
@ -162,16 +174,92 @@ Run `yarn start:client`
|
||||
|
||||
Run `yarn start:storybook`
|
||||
|
||||
### Migrate Database
|
||||
|
||||
With the following command you can keep your database schema in sync:
|
||||
|
||||
```bash
|
||||
yarn database:push
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run `yarn test`
|
||||
|
||||
## Public API
|
||||
|
||||
### Import Activities
|
||||
|
||||
#### Request
|
||||
|
||||
`POST http://localhost:3333/api/v1/import`
|
||||
|
||||
#### Authorization: Bearer Token
|
||||
|
||||
Set the header as follows:
|
||||
|
||||
```
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
#### Body
|
||||
|
||||
```
|
||||
{
|
||||
"activities": [
|
||||
{
|
||||
"currency": "USD",
|
||||
"dataSource": "YAHOO",
|
||||
"date": "2021-09-15T00:00:00.000Z",
|
||||
"fee": 19,
|
||||
"quantity": 5,
|
||||
"symbol": "MSFT"
|
||||
"type": "BUY",
|
||||
"unitPrice": 298.58
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ------------------- | -------------------------------------------------- |
|
||||
| accountId | string (`optional`) | Id of the account |
|
||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| date | string | Date in the format `ISO-8601` |
|
||||
| fee | number | Fee of the activity |
|
||||
| quantity | number | Quantity of the activity |
|
||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
|
||||
| unitPrice | number | Price per unit of the activity |
|
||||
|
||||
#### Response
|
||||
|
||||
##### Success
|
||||
|
||||
`201 Created`
|
||||
|
||||
##### Error
|
||||
|
||||
`400 Bad Request`
|
||||
|
||||
```
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": [
|
||||
"activities.1 is a duplicate activity"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||
|
||||
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
|
||||
|
||||
## License
|
||||
|
||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||
|
38
angular.json
38
angular.json
@ -2,6 +2,7 @@
|
||||
"version": 1,
|
||||
"projects": {
|
||||
"api": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/api",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
@ -47,7 +48,7 @@
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.js",
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/api"]
|
||||
@ -56,6 +57,7 @@
|
||||
"tags": []
|
||||
},
|
||||
"client": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
@ -113,7 +115,7 @@
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/lib/marked.js"],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
@ -180,7 +182,7 @@
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/client/jest.config.js",
|
||||
"jestConfig": "apps/client/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/client"]
|
||||
@ -189,6 +191,7 @@
|
||||
"tags": []
|
||||
},
|
||||
"client-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/client-e2e",
|
||||
"sourceRoot": "apps/client-e2e/src",
|
||||
"projectType": "application",
|
||||
@ -211,6 +214,7 @@
|
||||
"implicitDependencies": ["client"]
|
||||
},
|
||||
"common": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "libs/common",
|
||||
"sourceRoot": "libs/common/src",
|
||||
"projectType": "library",
|
||||
@ -225,7 +229,7 @@
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/common"],
|
||||
"options": {
|
||||
"jestConfig": "libs/common/jest.config.js",
|
||||
"jestConfig": "libs/common/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
@ -233,6 +237,7 @@
|
||||
"tags": []
|
||||
},
|
||||
"ui": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
@ -247,7 +252,7 @@
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/ui"],
|
||||
"options": {
|
||||
"jestConfig": "libs/ui/jest.config.js",
|
||||
"jestConfig": "libs/ui/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
},
|
||||
@ -258,14 +263,12 @@
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@nrwl/storybook:storybook",
|
||||
"builder": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"uiFramework": "@storybook/angular",
|
||||
"port": 4400,
|
||||
"config": {
|
||||
"configFolder": "libs/ui/.storybook"
|
||||
},
|
||||
"projectBuildConfig": "ui:build-storybook"
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
@ -274,15 +277,13 @@
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@nrwl/storybook:build",
|
||||
"builder": "@storybook/angular:build-storybook",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"uiFramework": "@storybook/angular",
|
||||
"outputPath": "dist/storybook/ui",
|
||||
"config": {
|
||||
"configFolder": "libs/ui/.storybook"
|
||||
},
|
||||
"projectBuildConfig": "ui:build-storybook"
|
||||
"outputDir": "dist/storybook/ui",
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
@ -294,6 +295,7 @@
|
||||
"tags": []
|
||||
},
|
||||
"ui-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/ui-e2e",
|
||||
"sourceRoot": "apps/ui-e2e/src",
|
||||
"projectType": "application",
|
||||
|
@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
displayName: 'api',
|
||||
preset: '../../jest.preset.js',
|
||||
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||
@ -12,5 +12,6 @@ module.exports = {
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
testTimeout: 10000,
|
||||
testEnvironment: 'node'
|
||||
testEnvironment: 'node',
|
||||
preset: '../../jest.preset.js'
|
||||
};
|
@ -78,8 +78,12 @@ export class AccessController {
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||
const access = await this.accessService.access({ id });
|
||||
|
||||
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(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -88,10 +92,7 @@ export class AccessController {
|
||||
}
|
||||
|
||||
return this.accessService.deleteAccess({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
nullifyValuesInObject,
|
||||
@ -7,7 +7,10 @@ import {
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type {
|
||||
AccountWithValue,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -35,7 +38,7 @@ export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
@ -91,9 +94,10 @@ export class AccountController {
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accountsWithAggregations = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
@ -122,13 +126,45 @@ export class AccountController {
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
|
||||
return this.accountService.account({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
public async getAccountById(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Param('id') id: string
|
||||
): Promise<AccountWithValue> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id,
|
||||
[{ id, type: 'ACCOUNT' }]
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
accountsWithAggregations = {
|
||||
...nullifyValuesInObject(accountsWithAggregations, [
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency'
|
||||
]),
|
||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||
'balance',
|
||||
'balanceInBaseCurrency',
|
||||
'convertedBalance',
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
return accountsWithAggregations.accounts[0];
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { CashDetails } from './interfaces/cash-details.interface';
|
||||
|
||||
@ -102,22 +104,43 @@ export class AccountService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getCashDetails(
|
||||
aUserId: string,
|
||||
aCurrency: string
|
||||
): Promise<CashDetails> {
|
||||
public async getCashDetails({
|
||||
currency,
|
||||
filters = [],
|
||||
userId
|
||||
}: {
|
||||
currency: string;
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
}): Promise<CashDetails> {
|
||||
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||
|
||||
const accounts = await this.accounts({
|
||||
where: { userId: aUserId }
|
||||
const where: Prisma.AccountWhereInput = { userId };
|
||||
|
||||
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) {
|
||||
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
aCurrency
|
||||
currency
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
@ -56,6 +60,24 @@ export class AdminController {
|
||||
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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherMax(): Promise<void> {
|
||||
@ -71,10 +93,20 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.gatherProfileData();
|
||||
this.dataGatheringService.gatherMax();
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherMax();
|
||||
}
|
||||
|
||||
@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')
|
||||
@ -115,9 +156,14 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
|
||||
|
||||
return;
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol')
|
||||
|
@ -11,6 +11,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { QueueModule } from './queue/queue.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -21,6 +22,7 @@ import { AdminService } from './admin.service';
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
QueueModule,
|
||||
SubscriptionModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
@ -15,21 +14,24 @@ import {
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Property } from '@prisma/client';
|
||||
import { Property } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
@ -38,25 +40,22 @@ export class AdminService {
|
||||
|
||||
public async get(): Promise<AdminData> {
|
||||
return {
|
||||
dataGatheringProgress:
|
||||
await this.dataGatheringService.getDataGatheringProgress(),
|
||||
exchangeRates: this.exchangeRateDataService
|
||||
.getCurrencies()
|
||||
.filter((currency) => {
|
||||
return currency !== baseCurrency;
|
||||
return currency !== this.baseCurrency;
|
||||
})
|
||||
.map((currency) => {
|
||||
return {
|
||||
label1: baseCurrency,
|
||||
label1: this.baseCurrency,
|
||||
label2: currency,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
baseCurrency,
|
||||
this.baseCurrency,
|
||||
currency
|
||||
)
|
||||
};
|
||||
}),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
settings: await this.propertyService.get(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
@ -157,30 +156,11 @@ export class AdminService {
|
||||
|
||||
if (key === PROPERTY_CURRENCIES) {
|
||||
await this.exchangeRateDataService.initialize();
|
||||
await this.dataGatheringService.reset();
|
||||
}
|
||||
|
||||
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']> {
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy: {
|
||||
@ -192,7 +172,6 @@ export class AdminService {
|
||||
_count: {
|
||||
select: { Account: true, Order: true }
|
||||
},
|
||||
alias: true,
|
||||
Analytics: {
|
||||
select: {
|
||||
activityCount: true,
|
||||
@ -212,7 +191,7 @@ export class AdminService {
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
|
||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||
@ -224,7 +203,6 @@ export class AdminService {
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
alias,
|
||||
createdAt,
|
||||
engagement,
|
||||
id,
|
||||
|
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 { RedisCacheService } from './redis-cache/redis-cache.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
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();
|
||||
}
|
||||
}
|
||||
public constructor() {}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
@ -19,6 +20,7 @@ import { AccountModule } from './account/account.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
@ -36,6 +38,14 @@ import { UserModule } from './user/user.module';
|
||||
AccountModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
BenchmarkModule,
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST,
|
||||
port: parseInt(process.env.REDIS_PORT, 10),
|
||||
password: process.env.REDIS_PASSWORD
|
||||
}
|
||||
}),
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
ConfigurationModule,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -9,7 +10,9 @@ import {
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
VERSION_NEUTRAL,
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
@ -29,7 +32,9 @@ export class AuthController {
|
||||
) {}
|
||||
|
||||
@Get('anonymous/:accessToken')
|
||||
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
|
||||
public async accessTokenLogin(
|
||||
@Param('accessToken') accessToken: string
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateAnonymousLogin(
|
||||
accessToken
|
||||
@ -51,6 +56,7 @@ export class AuthController {
|
||||
|
||||
@Get('google/callback')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public googleLoginCallback(@Req() req, @Res() res) {
|
||||
// Handles the Google OAuth2 callback
|
||||
const jwt: string = req.user.jwt;
|
||||
@ -62,6 +68,23 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@Get('internet-identity/:principalId')
|
||||
public async internetIdentityLogin(
|
||||
@Param('principalId') principalId: string
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||
principalId
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('webauthn/generate-registration-options')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async generateRegistrationOptions() {
|
||||
|
@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
||||
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
||||
|
||||
@ -13,7 +14,7 @@ export class AuthService {
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
public async validateAnonymousLogin(accessToken: string) {
|
||||
public async validateAnonymousLogin(accessToken: string): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const hashedAccessToken = this.userService.createAccessToken(
|
||||
@ -26,7 +27,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const jwt: string = this.jwtService.sign({
|
||||
const jwt = this.jwtService.sign({
|
||||
id: user.id
|
||||
});
|
||||
|
||||
@ -40,6 +41,33 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
public async validateInternetIdentityLogin(principalId: string) {
|
||||
try {
|
||||
const provider: Provider = 'INTERNET_IDENTITY';
|
||||
|
||||
let [user] = await this.userService.users({
|
||||
where: { provider, thirdPartyId: principalId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
thirdPartyId: principalId
|
||||
});
|
||||
}
|
||||
|
||||
return this.jwtService.sign({
|
||||
id: user.id
|
||||
});
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(
|
||||
'validateInternetIdentityLogin',
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async validateOAuthLogin({
|
||||
provider,
|
||||
thirdPartyId
|
||||
@ -57,13 +85,14 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
const jwt: string = this.jwtService.sign({
|
||||
return this.jwtService.sign({
|
||||
id: user.id
|
||||
});
|
||||
|
||||
return jwt;
|
||||
} catch (err) {
|
||||
throw new InternalServerErrorException('validateOAuthLogin', err.message);
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(
|
||||
'validateOAuthLogin',
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
30
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
30
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
@ -0,0 +1,30 @@
|
||||
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, UseInterceptors } from '@nestjs/common';
|
||||
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
|
||||
@Controller('benchmark')
|
||||
export class BenchmarkController {
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
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 { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('cache')
|
||||
export class CacheController {
|
||||
public constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {
|
||||
this.redisCacheService.reset();
|
||||
}
|
||||
) {}
|
||||
|
||||
@Post('flush')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
@ -11,7 +10,6 @@ import { Module } from '@nestjs/common';
|
||||
import { CacheController } from './cache.controller';
|
||||
|
||||
@Module({
|
||||
exports: [CacheService],
|
||||
controllers: [CacheController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
@ -21,7 +19,6 @@ import { CacheController } from './cache.controller';
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [CacheService]
|
||||
]
|
||||
})
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,13 +1,6 @@
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
|
@ -14,7 +14,7 @@ export class ExportService {
|
||||
activityIds?: string[];
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
let orders = await this.prismaService.order.findMany({
|
||||
let activities = await this.prismaService.order.findMany({
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
accountId: true,
|
||||
@ -30,18 +30,19 @@ export class ExportService {
|
||||
});
|
||||
|
||||
if (activityIds) {
|
||||
orders = orders.filter((order) => {
|
||||
return activityIds.includes(order.id);
|
||||
activities = activities.filter((activity) => {
|
||||
return activityIds.includes(activity.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
meta: { date: new Date().toISOString(), version: environment.version },
|
||||
orders: orders.map(
|
||||
activities: activities.map(
|
||||
({
|
||||
accountId,
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
type,
|
||||
@ -49,13 +50,14 @@ export class ExportService {
|
||||
}) => {
|
||||
return {
|
||||
accountId,
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
currency: SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
date: date.toISOString(),
|
||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
||||
};
|
||||
}
|
||||
|
@ -6,5 +6,5 @@ export class ImportDataDto {
|
||||
@IsArray()
|
||||
@Type(() => CreateOrderDto)
|
||||
@ValidateNested({ each: true })
|
||||
orders: CreateOrderDto[];
|
||||
activities: CreateOrderDto[];
|
||||
}
|
||||
|
@ -34,9 +34,21 @@ export class ImportController {
|
||||
);
|
||||
}
|
||||
|
||||
let maxActivitiesToImport = this.configurationService.get(
|
||||
'MAX_ACTIVITIES_TO_IMPORT'
|
||||
);
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Premium'
|
||||
) {
|
||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.importService.import({
|
||||
orders: importData.orders,
|
||||
maxActivitiesToImport,
|
||||
activities: importData.activities,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
} catch (error) {
|
||||
|
@ -16,23 +16,29 @@ export class ImportService {
|
||||
) {}
|
||||
|
||||
public async import({
|
||||
orders,
|
||||
activities,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
}: {
|
||||
orders: Partial<CreateOrderDto>[];
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const order of orders) {
|
||||
if (!order.dataSource) {
|
||||
if (order.type === 'ITEM') {
|
||||
order.dataSource = 'MANUAL';
|
||||
for (const activity of activities) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
activity.dataSource = 'MANUAL';
|
||||
} else {
|
||||
order.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.validateOrders({ orders, userId });
|
||||
await this.validateActivities({
|
||||
activities,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
});
|
||||
|
||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
||||
(account) => {
|
||||
@ -50,7 +56,7 @@ export class ImportService {
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
} of orders) {
|
||||
} of activities) {
|
||||
await this.orderService.createOrder({
|
||||
fee,
|
||||
quantity,
|
||||
@ -79,24 +85,20 @@ export class ImportService {
|
||||
}
|
||||
}
|
||||
|
||||
private async validateOrders({
|
||||
orders,
|
||||
private async validateActivities({
|
||||
activities,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
}: {
|
||||
orders: Partial<CreateOrderDto>[];
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
userId: string;
|
||||
}) {
|
||||
if (
|
||||
orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
|
||||
) {
|
||||
throw new Error(
|
||||
`Too many transactions (${this.configurationService.get(
|
||||
'MAX_ORDERS_TO_IMPORT'
|
||||
)} at most)`
|
||||
);
|
||||
if (activities?.length > maxActivitiesToImport) {
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||
}
|
||||
|
||||
const existingOrders = await this.orderService.orders({
|
||||
const existingActivities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
@ -105,22 +107,22 @@ export class ImportService {
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
] of orders.entries()) {
|
||||
const duplicateOrder = existingOrders.find((order) => {
|
||||
] of activities.entries()) {
|
||||
const duplicateActivity = existingActivities.find((activity) => {
|
||||
return (
|
||||
order.SymbolProfile.currency === currency &&
|
||||
order.SymbolProfile.dataSource === dataSource &&
|
||||
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
|
||||
order.fee === fee &&
|
||||
order.quantity === quantity &&
|
||||
order.SymbolProfile.symbol === symbol &&
|
||||
order.type === type &&
|
||||
order.unitPrice === unitPrice
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
|
||||
activity.fee === fee &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === symbol &&
|
||||
activity.type === type &&
|
||||
activity.unitPrice === unitPrice
|
||||
);
|
||||
});
|
||||
|
||||
if (duplicateOrder) {
|
||||
throw new Error(`orders.${index} is a duplicate transaction`);
|
||||
if (duplicateActivity) {
|
||||
throw new Error(`activities.${index} is a duplicate activity`);
|
||||
}
|
||||
|
||||
if (dataSource !== 'MANUAL') {
|
||||
@ -130,13 +132,13 @@ export class ImportService {
|
||||
|
||||
if (quotes[symbol] === undefined) {
|
||||
throw new Error(
|
||||
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (quotes[symbol].currency !== currency) {
|
||||
throw new Error(
|
||||
`orders.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.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 { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -26,7 +27,8 @@ import { InfoService } from './info.service';
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
SymbolProfileModule,
|
||||
TagModule
|
||||
],
|
||||
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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
@ -33,7 +34,8 @@ export class InfoService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly tagService: TagService
|
||||
) {}
|
||||
|
||||
public async get(): Promise<InfoItem> {
|
||||
@ -52,9 +54,17 @@ export class InfoService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
);
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
);
|
||||
} else {
|
||||
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
|
||||
}
|
||||
|
||||
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
@ -95,11 +105,12 @@ export class InfoService {
|
||||
isReadOnlyMode,
|
||||
platforms,
|
||||
systemMessage,
|
||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
statistics: await this.getStatistics(),
|
||||
subscriptions: await this.getSubscriptions()
|
||||
subscriptions: await this.getSubscriptions(),
|
||||
tags: await this.tagService.get()
|
||||
};
|
||||
}
|
||||
|
||||
@ -205,13 +216,6 @@ export class InfoService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering =
|
||||
await this.dataGatheringService.getLastDataGathering();
|
||||
|
||||
return lastDataGathering ?? null;
|
||||
}
|
||||
|
||||
private async getStatistics() {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
||||
return undefined;
|
||||
|
@ -1,5 +1,12 @@
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Tag,
|
||||
Type
|
||||
} from '@prisma/client';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
@ -10,14 +17,22 @@ import {
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
accountId: string;
|
||||
accountId?: string;
|
||||
|
||||
@IsEnum(AssetClass, { each: true })
|
||||
@IsOptional()
|
||||
assetClass?: AssetClass;
|
||||
|
||||
@IsEnum(AssetSubClass, { each: true })
|
||||
@IsOptional()
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsEnum(DataSource, { each: true })
|
||||
@IsOptional()
|
||||
dataSource: DataSource;
|
||||
dataSource?: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
@ -31,6 +46,10 @@ export class CreateOrderDto {
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
tags?: Tag[];
|
||||
|
||||
@IsEnum(Type, { each: true })
|
||||
type: Type;
|
||||
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
@ -16,6 +18,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
@ -42,8 +45,12 @@ export class OrderController {
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||
const order = await this.orderService.order({ id });
|
||||
|
||||
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(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -52,19 +59,45 @@ export class OrderController {
|
||||
}
|
||||
|
||||
return this.orderService.deleteOrder({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
id
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Activities> {
|
||||
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 impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
@ -73,6 +106,7 @@ export class OrderController {
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
|
||||
let activities = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userId: impersonationUserId || this.request.user.id
|
||||
@ -135,23 +169,15 @@ export class OrderController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
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({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
id
|
||||
});
|
||||
|
||||
if (!originalOrder) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
|
||||
!originalOrder ||
|
||||
originalOrder.userId !== this.request.user.id
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -178,15 +204,17 @@ export class OrderController {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
},
|
||||
update: {
|
||||
assetClass: data.assetClass,
|
||||
assetSubClass: data.assetSubClass,
|
||||
name: data.symbol
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -1,14 +1,27 @@
|
||||
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.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 { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Order,
|
||||
Prisma,
|
||||
Tag,
|
||||
Type as TypeOfOrder
|
||||
} from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { Activity } from './interfaces/activities.interface';
|
||||
@ -17,9 +30,8 @@ import { Activity } from './interfaces/activities.interface';
|
||||
export class OrderService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
@ -55,9 +67,12 @@ export class OrderService {
|
||||
public async createOrder(
|
||||
data: Prisma.OrderCreateInput & {
|
||||
accountId?: string;
|
||||
assetClass?: AssetClass;
|
||||
assetSubClass?: AssetSubClass;
|
||||
currency?: string;
|
||||
dataSource?: DataSource;
|
||||
symbol?: string;
|
||||
tags?: Tag[];
|
||||
userId: string;
|
||||
}
|
||||
): Promise<Order> {
|
||||
@ -67,6 +82,8 @@ export class OrderService {
|
||||
return account.isDefault === true;
|
||||
});
|
||||
|
||||
const tags = data.tags ?? [];
|
||||
|
||||
let Account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
@ -77,6 +94,8 @@ export class OrderService {
|
||||
};
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
const dataSource: DataSource = 'MANUAL';
|
||||
const id = uuidv4();
|
||||
@ -84,6 +103,8 @@ export class OrderService {
|
||||
|
||||
Account = undefined;
|
||||
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.dataSource = dataSource;
|
||||
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||
@ -97,12 +118,14 @@ export class OrderService {
|
||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||
}
|
||||
|
||||
await this.dataGatheringService.gatherProfileData([
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
}
|
||||
]);
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
@ -117,12 +140,13 @@ export class OrderService {
|
||||
]);
|
||||
}
|
||||
|
||||
await this.cacheService.flush();
|
||||
|
||||
delete data.accountId;
|
||||
delete data.assetClass;
|
||||
delete data.assetSubClass;
|
||||
delete data.currency;
|
||||
delete data.dataSource;
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
delete data.userId;
|
||||
|
||||
const orderData: Prisma.OrderCreateInput = data;
|
||||
@ -131,7 +155,12 @@ export class OrderService {
|
||||
data: {
|
||||
...orderData,
|
||||
Account,
|
||||
isDraft
|
||||
isDraft,
|
||||
tags: {
|
||||
connect: tags.map(({ id }) => {
|
||||
return { id };
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -151,11 +180,13 @@ export class OrderService {
|
||||
}
|
||||
|
||||
public async getOrders({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
types,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
@ -163,10 +194,64 @@ export class OrderService {
|
||||
}): Promise<Activity[]> {
|
||||
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) {
|
||||
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) {
|
||||
where.OR = types.map((type) => {
|
||||
return {
|
||||
@ -188,7 +273,8 @@ export class OrderService {
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true
|
||||
SymbolProfile: true,
|
||||
tags: true
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
@ -217,9 +303,12 @@ export class OrderService {
|
||||
where
|
||||
}: {
|
||||
data: Prisma.OrderUpdateInput & {
|
||||
assetClass?: AssetClass;
|
||||
assetSubClass?: AssetSubClass;
|
||||
currency?: string;
|
||||
dataSource?: DataSource;
|
||||
symbol?: string;
|
||||
tags?: Tag[];
|
||||
};
|
||||
where: Prisma.OrderWhereUniqueInput;
|
||||
}): Promise<Order> {
|
||||
@ -227,13 +316,15 @@ export class OrderService {
|
||||
delete data.Account;
|
||||
}
|
||||
|
||||
const tags = data.tags ?? [];
|
||||
|
||||
let isDraft = false;
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
const name = data.SymbolProfile.connect.dataSource_symbol.symbol;
|
||||
|
||||
data.SymbolProfile = { update: { name } };
|
||||
delete data.SymbolProfile.connect;
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
||||
isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
if (!isDraft) {
|
||||
@ -248,16 +339,22 @@ export class OrderService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.cacheService.flush();
|
||||
|
||||
delete data.assetClass;
|
||||
delete data.assetSubClass;
|
||||
delete data.currency;
|
||||
delete data.dataSource;
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
|
||||
return this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
isDraft
|
||||
isDraft,
|
||||
tags: {
|
||||
connect: tags.map(({ id }) => {
|
||||
return { id };
|
||||
})
|
||||
}
|
||||
},
|
||||
where
|
||||
});
|
||||
|
@ -1,10 +1,31 @@
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Tag,
|
||||
Type
|
||||
} from '@prisma/client';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateOrderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountId: string;
|
||||
accountId?: string;
|
||||
|
||||
@IsEnum(AssetClass, { each: true })
|
||||
@IsOptional()
|
||||
assetClass?: AssetClass;
|
||||
|
||||
@IsEnum(AssetSubClass, { each: true })
|
||||
@IsOptional()
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
@ -27,6 +48,10 @@ export class UpdateOrderDto {
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
tags?: Tag[];
|
||||
|
||||
@IsString()
|
||||
type: Type;
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
function mockGetValue(symbol: string, date: Date) {
|
||||
@ -20,14 +21,24 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'NOVN.SW':
|
||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||
return { marketPrice: 87.8 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
default:
|
||||
return { marketPrice: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export const CurrentRateServiceMock = {
|
||||
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
|
||||
const result = [];
|
||||
getValues: ({
|
||||
dataGatheringItems,
|
||||
dateQuery
|
||||
}: GetValuesParams): Promise<GetValueObject[]> => {
|
||||
const result: GetValueObject[] = [];
|
||||
if (dateQuery.lt) {
|
||||
for (
|
||||
let date = resetHours(dateQuery.gte);
|
||||
@ -37,8 +48,10 @@ export const CurrentRateServiceMock = {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
date,
|
||||
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
||||
.marketPrice,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
date
|
||||
).marketPrice,
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
@ -48,8 +61,10 @@ export const CurrentRateServiceMock = {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
date,
|
||||
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
||||
.marketPrice,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
date
|
||||
).marketPrice,
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
|
||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
return {
|
||||
@ -73,7 +74,12 @@ describe('CurrentRateService', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
dataProviderService = new DataProviderService(null, [], null);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
@ -96,15 +102,15 @@ describe('CurrentRateService', () => {
|
||||
},
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject([
|
||||
).toMatchObject<GetValueObject[]>([
|
||||
{
|
||||
date: undefined,
|
||||
marketPrice: 1841.823902,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
date: undefined,
|
||||
marketPrice: 1847.839966,
|
||||
marketPriceInBaseCurrency: 1847.839966,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]);
|
||||
|
@ -28,13 +28,7 @@ export class CurrentRateService {
|
||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||
|
||||
const promises: Promise<
|
||||
{
|
||||
date: Date;
|
||||
marketPrice: number;
|
||||
symbol: string;
|
||||
}[]
|
||||
>[] = [];
|
||||
const promises: Promise<GetValueObject[]>[] = [];
|
||||
|
||||
if (includeToday) {
|
||||
const today = resetHours(new Date());
|
||||
@ -42,16 +36,17 @@ export class CurrentRateService {
|
||||
this.dataProviderService
|
||||
.getQuotes(dataGatheringItems)
|
||||
.then((dataResultProvider) => {
|
||||
const result = [];
|
||||
const result: GetValueObject[] = [];
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
date: today,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
|
||||
0,
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||
userCurrency
|
||||
),
|
||||
marketPriceInBaseCurrency:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[dataGatheringItem.symbol]
|
||||
?.marketPrice ?? 0,
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
@ -74,11 +69,12 @@ export class CurrentRateService {
|
||||
return data.map((marketDataItem) => {
|
||||
return {
|
||||
date: marketDataItem.date,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
marketDataItem.marketPrice,
|
||||
currencies[marketDataItem.symbol],
|
||||
userCurrency
|
||||
),
|
||||
marketPriceInBaseCurrency:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
marketDataItem.marketPrice,
|
||||
currencies[marketDataItem.symbol],
|
||||
userCurrency
|
||||
),
|
||||
symbol: marketDataItem.symbol
|
||||
};
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
export interface GetValueObject {
|
||||
date: Date;
|
||||
marketPrice: number;
|
||||
marketPriceInBaseCurrency: number;
|
||||
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 { Tag } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
@ -16,6 +20,7 @@ export interface PortfolioPositionDetail {
|
||||
orders: OrderWithAccount[];
|
||||
quantity: number;
|
||||
SymbolProfile: EnhancedSymbolProfile;
|
||||
tags: Tag[];
|
||||
transactionCount: number;
|
||||
value: number;
|
||||
}
|
||||
@ -25,10 +30,3 @@ export interface HistoricalDataContainer {
|
||||
isAllTimeLow: boolean;
|
||||
items: HistoricalDataItem[];
|
||||
}
|
||||
|
||||
export interface HistoricalDataItem {
|
||||
averagePrice?: number;
|
||||
date: string;
|
||||
grossPerformancePercent?: number;
|
||||
value: number;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculatorNew', () => {
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell', async () => {
|
||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
@ -52,16 +52,20 @@ describe('PortfolioCalculatorNew', () => {
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculatorNew.computeTransactionPoints();
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2021-11-22')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
@ -91,6 +95,15 @@ describe('PortfolioCalculatorNew', () => {
|
||||
],
|
||||
totalInvestment: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2021-11-22', investment: new Big('285.8') },
|
||||
{ date: '2021-11-30', investment: new Big('0') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-11-01', investment: new Big('12.6') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculatorNew', () => {
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy', async () => {
|
||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
@ -41,16 +41,20 @@ describe('PortfolioCalculatorNew', () => {
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculatorNew.computeTransactionPoints();
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2021-11-30')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
@ -80,6 +84,14 @@ describe('PortfolioCalculatorNew', () => {
|
||||
],
|
||||
totalInvestment: new Big('273.2')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2021-11-30', investment: new Big('273.2') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-11-01', investment: new Big('273.2') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,73 +0,0 @@
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
||||
|
||||
describe('PortfolioCalculatorNew', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('annualized performance percentage', () => {
|
||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
||||
currentRateService,
|
||||
currency: 'USD',
|
||||
orders: []
|
||||
});
|
||||
|
||||
it('Get annualized performance', async () => {
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||
netPerformancePercent: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 0,
|
||||
netPerformancePercent: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
/**
|
||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||
*/
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 65, // < 1 year
|
||||
netPerformancePercent: new Big(0.1025)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.729705);
|
||||
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 365, // 1 year
|
||||
netPerformancePercent: new Big(0.05)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.05);
|
||||
|
||||
/**
|
||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||
*/
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 575, // > 1 year
|
||||
netPerformancePercent: new Big(0.2374)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.145);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,997 +0,0 @@
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
ResponseError,
|
||||
TimelinePosition,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
addMilliseconds,
|
||||
addMonths,
|
||||
addYears,
|
||||
endOfDay,
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
max,
|
||||
min
|
||||
} from 'date-fns';
|
||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
|
||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||
import {
|
||||
Accuracy,
|
||||
TimelineSpecification
|
||||
} from './interfaces/timeline-specification.interface';
|
||||
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
|
||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||
|
||||
export class PortfolioCalculatorNew {
|
||||
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
|
||||
true;
|
||||
|
||||
private static readonly ENABLE_LOGGING = false;
|
||||
|
||||
private currency: string;
|
||||
private currentRateService: CurrentRateService;
|
||||
private orders: PortfolioOrder[];
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
public constructor({
|
||||
currency,
|
||||
currentRateService,
|
||||
orders
|
||||
}: {
|
||||
currency: string;
|
||||
currentRateService: CurrentRateService;
|
||||
orders: PortfolioOrder[];
|
||||
}) {
|
||||
this.currency = currency;
|
||||
this.currentRateService = currentRateService;
|
||||
this.orders = orders;
|
||||
|
||||
this.orders.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
public computeTransactionPoints() {
|
||||
this.transactionPoints = [];
|
||||
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||
|
||||
let lastDate: string = null;
|
||||
let lastTransactionPoint: TransactionPoint = null;
|
||||
for (const order of this.orders) {
|
||||
const currentDate = order.date;
|
||||
|
||||
let currentTransactionPointItem: TransactionPointSymbol;
|
||||
const oldAccumulatedSymbol = symbols[order.symbol];
|
||||
|
||||
const factor = this.getFactor(order.type);
|
||||
const unitPrice = new Big(order.unitPrice);
|
||||
if (oldAccumulatedSymbol) {
|
||||
const newQuantity = order.quantity
|
||||
.mul(factor)
|
||||
.plus(oldAccumulatedSymbol.quantity);
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
investment: newQuantity.eq(0)
|
||||
? new Big(0)
|
||||
: unitPrice
|
||||
.mul(order.quantity)
|
||||
.mul(factor)
|
||||
.plus(oldAccumulatedSymbol.investment),
|
||||
quantity: newQuantity,
|
||||
symbol: order.symbol,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
};
|
||||
} else {
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee,
|
||||
firstBuyDate: order.date,
|
||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||
quantity: order.quantity.mul(factor),
|
||||
symbol: order.symbol,
|
||||
transactionCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
symbols[order.symbol] = currentTransactionPointItem;
|
||||
|
||||
const items = lastTransactionPoint?.items ?? [];
|
||||
const newItems = items.filter(
|
||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||
);
|
||||
newItems.push(currentTransactionPointItem);
|
||||
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
||||
lastTransactionPoint = {
|
||||
date: currentDate,
|
||||
items: newItems
|
||||
};
|
||||
this.transactionPoints.push(lastTransactionPoint);
|
||||
} else {
|
||||
lastTransactionPoint.items = newItems;
|
||||
}
|
||||
lastDate = currentDate;
|
||||
}
|
||||
}
|
||||
|
||||
public getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent
|
||||
}: {
|
||||
daysInMarket: number;
|
||||
netPerformancePercent: Big;
|
||||
}): Big {
|
||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||
return new Big(
|
||||
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
|
||||
).minus(1);
|
||||
}
|
||||
|
||||
return new Big(0);
|
||||
}
|
||||
|
||||
public getTransactionPoints(): TransactionPoint[] {
|
||||
return this.transactionPoints;
|
||||
}
|
||||
|
||||
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
|
||||
this.transactionPoints = transactionPoints;
|
||||
}
|
||||
|
||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
||||
if (!this.transactionPoints?.length) {
|
||||
return {
|
||||
currentValue: new Big(0),
|
||||
hasErrors: false,
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
positions: [],
|
||||
totalInvestment: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
const lastTransactionPoint =
|
||||
this.transactionPoints[this.transactionPoints.length - 1];
|
||||
|
||||
// use Date.now() to use the mock for today
|
||||
const today = new Date(Date.now());
|
||||
|
||||
let firstTransactionPoint: TransactionPoint = null;
|
||||
let firstIndex = this.transactionPoints.length;
|
||||
const dates = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
|
||||
dates.push(resetHours(start));
|
||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
currencies[item.symbol] = item.currency;
|
||||
}
|
||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
||||
if (
|
||||
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
||||
firstTransactionPoint === null
|
||||
) {
|
||||
firstTransactionPoint = this.transactionPoints[i];
|
||||
firstIndex = i;
|
||||
}
|
||||
if (firstTransactionPoint !== null) {
|
||||
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
|
||||
}
|
||||
}
|
||||
|
||||
dates.push(resetHours(today));
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const todayString = format(today, DATE_FORMAT);
|
||||
|
||||
if (firstIndex > 0) {
|
||||
firstIndex--;
|
||||
}
|
||||
const initialValues: { [symbol: string]: Big } = {};
|
||||
|
||||
const positions: TimelinePosition[] = [];
|
||||
let hasAnySymbolMetricsErrors = false;
|
||||
|
||||
const errors: ResponseError['errors'] = [];
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||
|
||||
const {
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
initialValue,
|
||||
netPerformance,
|
||||
netPerformancePercentage
|
||||
} = this.getSymbolMetrics({
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol: item.symbol
|
||||
});
|
||||
|
||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||
initialValues[item.symbol] = initialValue;
|
||||
|
||||
positions.push({
|
||||
averagePrice: item.quantity.eq(0)
|
||||
? new Big(0)
|
||||
: item.investment.div(item.quantity),
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||
grossPerformancePercentage: !hasErrors
|
||||
? grossPerformancePercentage ?? null
|
||||
: null,
|
||||
investment: item.investment,
|
||||
marketPrice: marketValue?.toNumber() ?? null,
|
||||
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
||||
netPerformancePercentage: !hasErrors
|
||||
? netPerformancePercentage ?? null
|
||||
: null,
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
transactionCount: item.transactionCount
|
||||
});
|
||||
|
||||
if (hasErrors) {
|
||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||
}
|
||||
}
|
||||
|
||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
||||
|
||||
return {
|
||||
...overall,
|
||||
errors,
|
||||
positions,
|
||||
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
|
||||
};
|
||||
}
|
||||
|
||||
public getInvestments(): { date: string; investment: Big }[] {
|
||||
if (this.transactionPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.transactionPoints.map((transactionPoint) => {
|
||||
return {
|
||||
date: transactionPoint.date,
|
||||
investment: transactionPoint.items.reduce(
|
||||
(investment, transactionPointSymbol) =>
|
||||
investment.plus(transactionPointSymbol.investment),
|
||||
new Big(0)
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async calculateTimeline(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
endDate: string
|
||||
): Promise<TimelineInfoInterface> {
|
||||
if (timelineSpecification.length === 0) {
|
||||
return {
|
||||
maxNetPerformance: new Big(0),
|
||||
minNetPerformance: new Big(0),
|
||||
timelinePeriods: []
|
||||
};
|
||||
}
|
||||
|
||||
const startDate = timelineSpecification[0].start;
|
||||
const start = parseDate(startDate);
|
||||
const end = parseDate(endDate);
|
||||
|
||||
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
||||
let i = 0;
|
||||
let j = -1;
|
||||
for (
|
||||
let currentDate = start;
|
||||
!isAfter(currentDate, end);
|
||||
currentDate = this.addToDate(
|
||||
currentDate,
|
||||
timelineSpecification[i].accuracy
|
||||
)
|
||||
) {
|
||||
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
|
||||
i++;
|
||||
}
|
||||
while (
|
||||
j + 1 < this.transactionPoints.length &&
|
||||
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
|
||||
) {
|
||||
j++;
|
||||
}
|
||||
|
||||
let periodEndDate = currentDate;
|
||||
if (timelineSpecification[i].accuracy === 'day') {
|
||||
let nextEndDate = end;
|
||||
if (j + 1 < this.transactionPoints.length) {
|
||||
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
|
||||
}
|
||||
periodEndDate = min([
|
||||
addMonths(currentDate, 3),
|
||||
max([currentDate, nextEndDate])
|
||||
]);
|
||||
}
|
||||
const timePeriodForDates = this.getTimePeriodForDate(
|
||||
j,
|
||||
currentDate,
|
||||
endOfDay(periodEndDate)
|
||||
);
|
||||
currentDate = periodEndDate;
|
||||
if (timePeriodForDates != null) {
|
||||
timelinePeriodPromises.push(timePeriodForDates);
|
||||
}
|
||||
}
|
||||
|
||||
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||
timelinePeriodPromises
|
||||
);
|
||||
const minNetPerformance = timelineInfoInterfaces
|
||||
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||
.filter((performance) => performance !== null)
|
||||
.reduce((minPerformance, current) => {
|
||||
if (minPerformance.lt(current)) {
|
||||
return minPerformance;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
});
|
||||
|
||||
const maxNetPerformance = timelineInfoInterfaces
|
||||
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
||||
.filter((performance) => performance !== null)
|
||||
.reduce((maxPerformance, current) => {
|
||||
if (maxPerformance.gt(current)) {
|
||||
return maxPerformance;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
});
|
||||
|
||||
const timelinePeriods = timelineInfoInterfaces.map(
|
||||
(timelineInfo) => timelineInfo.timelinePeriods
|
||||
);
|
||||
|
||||
return {
|
||||
maxNetPerformance,
|
||||
minNetPerformance,
|
||||
timelinePeriods: flatten(timelinePeriods)
|
||||
};
|
||||
}
|
||||
|
||||
private calculateOverallPerformance(
|
||||
positions: TimelinePosition[],
|
||||
initialValues: { [symbol: string]: Big }
|
||||
) {
|
||||
let currentValue = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformancePercentage = new Big(0);
|
||||
let hasErrors = false;
|
||||
let netPerformance = new Big(0);
|
||||
let netPerformancePercentage = new Big(0);
|
||||
let sumOfWeights = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
currentValue = currentValue.plus(
|
||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
||||
|
||||
if (currentPosition.grossPerformance) {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
);
|
||||
|
||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (currentPosition.grossPerformancePercentage) {
|
||||
// Use the average from the initial value and the current investment as
|
||||
// a weight
|
||||
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
|
||||
.plus(currentPosition.investment)
|
||||
.div(2);
|
||||
|
||||
sumOfWeights = sumOfWeights.plus(weight);
|
||||
|
||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||
currentPosition.grossPerformancePercentage.mul(weight)
|
||||
);
|
||||
|
||||
netPerformancePercentage = netPerformancePercentage.plus(
|
||||
currentPosition.netPerformancePercentage.mul(weight)
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.warn(
|
||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
||||
'PortfolioCalculatorNew'
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (sumOfWeights.gt(0)) {
|
||||
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
|
||||
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
|
||||
} else {
|
||||
grossPerformancePercentage = new Big(0);
|
||||
netPerformancePercentage = new Big(0);
|
||||
}
|
||||
|
||||
return {
|
||||
currentValue,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
totalInvestment
|
||||
};
|
||||
}
|
||||
|
||||
private async getTimePeriodForDate(
|
||||
j: number,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<TimelineInfoInterface> {
|
||||
let investment: Big = new Big(0);
|
||||
let fees: Big = new Big(0);
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
if (j >= 0) {
|
||||
const currencies: { [name: string]: string } = {};
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
currencies[item.symbol] = item.currency;
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
investment = investment.plus(item.investment);
|
||||
fees = fees.plus(item.fee);
|
||||
}
|
||||
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (dataGatheringItems.length > 0) {
|
||||
try {
|
||||
marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
gte: startDate,
|
||||
lt: endOfDay(endDate)
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Failed to fetch info for date ${startDate} with exception`,
|
||||
error,
|
||||
'PortfolioCalculatorNew'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results: TimelinePeriod[] = [];
|
||||
let maxNetPerformance: Big = null;
|
||||
let minNetPerformance: Big = null;
|
||||
for (
|
||||
let currentDate = startDate;
|
||||
isBefore(currentDate, endDate);
|
||||
currentDate = addDays(currentDate, 1)
|
||||
) {
|
||||
let value = new Big(0);
|
||||
const currentDateAsString = format(currentDate, DATE_FORMAT);
|
||||
let invalid = false;
|
||||
if (j >= 0) {
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
if (
|
||||
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
|
||||
) {
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
value = value.plus(
|
||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!invalid) {
|
||||
const grossPerformance = value.minus(investment);
|
||||
const netPerformance = grossPerformance.minus(fees);
|
||||
if (
|
||||
minNetPerformance === null ||
|
||||
minNetPerformance.gt(netPerformance)
|
||||
) {
|
||||
minNetPerformance = netPerformance;
|
||||
}
|
||||
if (
|
||||
maxNetPerformance === null ||
|
||||
maxNetPerformance.lt(netPerformance)
|
||||
) {
|
||||
maxNetPerformance = netPerformance;
|
||||
}
|
||||
|
||||
const result = {
|
||||
grossPerformance,
|
||||
investment,
|
||||
netPerformance,
|
||||
value,
|
||||
date: currentDateAsString
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
maxNetPerformance,
|
||||
minNetPerformance,
|
||||
timelinePeriods: results
|
||||
};
|
||||
}
|
||||
|
||||
private getFactor(type: TypeOfOrder) {
|
||||
let factor: number;
|
||||
|
||||
switch (type) {
|
||||
case 'BUY':
|
||||
factor = 1;
|
||||
break;
|
||||
case 'SELL':
|
||||
factor = -1;
|
||||
break;
|
||||
default:
|
||||
factor = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
private addToDate(date: Date, accuracy: Accuracy): Date {
|
||||
switch (accuracy) {
|
||||
case 'day':
|
||||
return addDays(date, 1);
|
||||
case 'month':
|
||||
return addMonths(date, 1);
|
||||
case 'year':
|
||||
return addYears(date, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private getSymbolMetrics({
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol
|
||||
}: {
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
start: Date;
|
||||
symbol: string;
|
||||
}) {
|
||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
||||
return order.symbol === symbol;
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
hasErrors: false,
|
||||
initialValue: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
const dateOfFirstTransaction = new Date(first(orders).date);
|
||||
const endDate = new Date(Date.now());
|
||||
|
||||
const unitPriceAtStartDate =
|
||||
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
||||
|
||||
const unitPriceAtEndDate =
|
||||
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
||||
|
||||
if (
|
||||
!unitPriceAtEndDate ||
|
||||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
||||
) {
|
||||
return {
|
||||
hasErrors: true,
|
||||
initialValue: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
let averagePriceAtEndDate = new Big(0);
|
||||
let averagePriceAtStartDate = new Big(0);
|
||||
let feesAtStartDate = new Big(0);
|
||||
let fees = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformanceAtStartDate = new Big(0);
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastTransactionInvestment = new Big(0);
|
||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
||||
let maxTotalInvestment = new Big(0);
|
||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
let valueAtStartDate: Big;
|
||||
|
||||
// Add a synthetic order at the start and the end date
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(start, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
itemType: 'start',
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
unitPrice: unitPriceAtStartDate
|
||||
});
|
||||
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(endDate, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
itemType: 'end',
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
unitPrice: unitPriceAtEndDate
|
||||
});
|
||||
|
||||
// Sort orders so that the start and end placeholder order are at the right
|
||||
// position
|
||||
orders = sortBy(orders, (order) => {
|
||||
let sortIndex = new Date(order.date);
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
sortIndex = addMilliseconds(sortIndex, -1);
|
||||
}
|
||||
|
||||
if (order.itemType === 'end') {
|
||||
sortIndex = addMilliseconds(sortIndex, 1);
|
||||
}
|
||||
|
||||
return sortIndex.getTime();
|
||||
});
|
||||
|
||||
const indexOfStartOrder = orders.findIndex((order) => {
|
||||
return order.itemType === 'start';
|
||||
});
|
||||
|
||||
const indexOfEndOrder = orders.findIndex((order) => {
|
||||
return order.itemType === 'end';
|
||||
});
|
||||
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
// Take the unit price of the order as the market price if there are no
|
||||
// orders of this symbol before the start date
|
||||
order.unitPrice =
|
||||
indexOfStartOrder === 0
|
||||
? orders[i + 1]?.unitPrice
|
||||
: unitPriceAtStartDate;
|
||||
}
|
||||
|
||||
// Calculate the average start price as soon as any units are held
|
||||
if (
|
||||
averagePriceAtStartDate.eq(0) &&
|
||||
i >= indexOfStartOrder &&
|
||||
totalUnits.gt(0)
|
||||
) {
|
||||
averagePriceAtStartDate = totalInvestment.div(totalUnits);
|
||||
}
|
||||
|
||||
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
|
||||
order.unitPrice
|
||||
);
|
||||
|
||||
if (!investmentAtStartDate && i >= indexOfStartOrder) {
|
||||
investmentAtStartDate = totalInvestment ?? new Big(0);
|
||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||
}
|
||||
|
||||
const transactionInvestment = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
|
||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||
|
||||
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
|
||||
maxTotalInvestment = totalInvestment;
|
||||
}
|
||||
|
||||
if (i === indexOfEndOrder && totalUnits.gt(0)) {
|
||||
averagePriceAtEndDate = totalInvestment.div(totalUnits);
|
||||
}
|
||||
|
||||
if (i >= indexOfStartOrder && !initialValue) {
|
||||
if (
|
||||
i === indexOfStartOrder &&
|
||||
!valueOfInvestmentBeforeTransaction.eq(0)
|
||||
) {
|
||||
initialValue = valueOfInvestmentBeforeTransaction;
|
||||
} else if (transactionInvestment.gt(0)) {
|
||||
initialValue = transactionInvestment;
|
||||
}
|
||||
}
|
||||
|
||||
fees = fees.plus(order.fee);
|
||||
|
||||
totalUnits = totalUnits.plus(
|
||||
order.quantity.mul(this.getFactor(order.type))
|
||||
);
|
||||
|
||||
const valueOfInvestment = totalUnits.mul(order.unitPrice);
|
||||
|
||||
const grossPerformanceFromSell =
|
||||
order.type === TypeOfOrder.SELL
|
||||
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
|
||||
: new Big(0);
|
||||
|
||||
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
||||
grossPerformanceFromSell
|
||||
);
|
||||
|
||||
totalInvestmentWithGrossPerformanceFromSell =
|
||||
totalInvestmentWithGrossPerformanceFromSell
|
||||
.plus(transactionInvestment)
|
||||
.plus(grossPerformanceFromSell);
|
||||
|
||||
lastAveragePrice = totalUnits.eq(0)
|
||||
? new Big(0)
|
||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
if (
|
||||
i > indexOfStartOrder &&
|
||||
!lastValueOfInvestmentBeforeTransaction
|
||||
.plus(lastTransactionInvestment)
|
||||
.eq(0)
|
||||
) {
|
||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.mul(
|
||||
new Big(1).plus(grossHoldingPeriodReturn)
|
||||
);
|
||||
|
||||
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(fees.minus(feesAtStartDate))
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.mul(
|
||||
new Big(1).plus(netHoldingPeriodReturn)
|
||||
);
|
||||
}
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
|
||||
lastTransactionInvestment = transactionInvestment;
|
||||
|
||||
lastValueOfInvestmentBeforeTransaction =
|
||||
valueOfInvestmentBeforeTransaction;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
}
|
||||
}
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.minus(1);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.minus(1);
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
);
|
||||
|
||||
const totalNetPerformance = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
|
||||
maxTotalInvestment.minus(investmentAtStartDate)
|
||||
);
|
||||
|
||||
const grossPerformancePercentage =
|
||||
PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
||||
averagePriceAtStartDate.eq(0) ||
|
||||
averagePriceAtEndDate.eq(0) ||
|
||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||
: new Big(0)
|
||||
: // This formula has the issue that buying more units with a price
|
||||
// lower than the average buying price results in a positive
|
||||
// performance even if the market price stays constant
|
||||
unitPriceAtEndDate
|
||||
.div(averagePriceAtEndDate)
|
||||
.div(
|
||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||
)
|
||||
.minus(1);
|
||||
|
||||
const feesPerUnit = totalUnits.gt(0)
|
||||
? fees.minus(feesAtStartDate).div(totalUnits)
|
||||
: new Big(0);
|
||||
|
||||
const netPerformancePercentage =
|
||||
PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
||||
averagePriceAtStartDate.eq(0) ||
|
||||
averagePriceAtEndDate.eq(0) ||
|
||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||
: new Big(0)
|
||||
: // This formula has the issue that buying more units with a price
|
||||
// lower than the average buying price results in a positive
|
||||
// performance even if the market price stays constant
|
||||
unitPriceAtEndDate
|
||||
.minus(feesPerUnit)
|
||||
.div(averagePriceAtEndDate)
|
||||
.div(
|
||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||
)
|
||||
.minus(1);
|
||||
|
||||
if (PortfolioCalculatorNew.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
`
|
||||
${symbol}
|
||||
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
|
||||
2
|
||||
)} -> ${unitPriceAtEndDate.toFixed(2)}
|
||||
Average price: ${averagePriceAtStartDate.toFixed(
|
||||
2
|
||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||
Gross performance: ${totalGrossPerformance.toFixed(
|
||||
2
|
||||
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
|
||||
Fees per unit: ${feesPerUnit.toFixed(2)}
|
||||
Net performance: ${totalNetPerformance.toFixed(
|
||||
2
|
||||
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
initialValue,
|
||||
grossPerformancePercentage,
|
||||
netPerformancePercentage,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance,
|
||||
grossPerformance: totalGrossPerformance
|
||||
};
|
||||
}
|
||||
|
||||
private isNextItemActive(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
currentDate: Date,
|
||||
i: number
|
||||
) {
|
||||
return (
|
||||
i + 1 < timelineSpecification.length &&
|
||||
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
|
||||
);
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculatorNew', () => {
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -23,22 +23,26 @@ describe('PortfolioCalculatorNew', () => {
|
||||
|
||||
describe('get current positions', () => {
|
||||
it('with no orders', async () => {
|
||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: []
|
||||
});
|
||||
|
||||
portfolioCalculatorNew.computeTransactionPoints();
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
new Date()
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
@ -51,6 +55,10 @@ describe('PortfolioCalculatorNew', () => {
|
||||
positions: [],
|
||||
totalInvestment: new Big(0)
|
||||
});
|
||||
|
||||
expect(investments).toEqual([]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,110 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2022-03-07',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.3),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(75.8)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2022-04-08',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(2.95),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(1),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(85.73)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('87.8'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('17.68'),
|
||||
netPerformancePercentage: new Big('0.11662269129287598945'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('75.80'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||
investment: new Big('75.80'),
|
||||
netPerformance: new Big('17.68'),
|
||||
netPerformancePercentage: new Big('0.11662269129287598945'),
|
||||
marketPrice: 87.8,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'NOVN.SW',
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('75.80')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2022-03-07', investment: new Big('151.6') },
|
||||
{ date: '2022-04-08', investment: new Big('75.8') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||
{ date: '2022-04-01', investment: new Big('-85.73') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
File diff suppressed because it is too large
Load Diff
@ -1,27 +1,31 @@
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
addMilliseconds,
|
||||
addMonths,
|
||||
addYears,
|
||||
differenceInDays,
|
||||
endOfDay,
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
max,
|
||||
min
|
||||
min,
|
||||
set
|
||||
} from 'date-fns';
|
||||
import { flatten, isNumber } from 'lodash';
|
||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
|
||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||
import {
|
||||
@ -32,22 +36,39 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
|
||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||
|
||||
export class PortfolioCalculator {
|
||||
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
|
||||
true;
|
||||
|
||||
private static readonly ENABLE_LOGGING = false;
|
||||
|
||||
private currency: string;
|
||||
private currentRateService: CurrentRateService;
|
||||
private orders: PortfolioOrder[];
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
public constructor(
|
||||
private currentRateService: CurrentRateService,
|
||||
private currency: string
|
||||
) {}
|
||||
public constructor({
|
||||
currency,
|
||||
currentRateService,
|
||||
orders
|
||||
}: {
|
||||
currency: string;
|
||||
currentRateService: CurrentRateService;
|
||||
orders: PortfolioOrder[];
|
||||
}) {
|
||||
this.currency = currency;
|
||||
this.currentRateService = currentRateService;
|
||||
this.orders = orders;
|
||||
|
||||
public computeTransactionPoints(orders: PortfolioOrder[]) {
|
||||
orders.sort((a, b) => a.date.localeCompare(b.date));
|
||||
this.orders.sort((a, b) => a.date?.localeCompare(b.date));
|
||||
}
|
||||
|
||||
public computeTransactionPoints() {
|
||||
this.transactionPoints = [];
|
||||
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||
|
||||
let lastDate: string = null;
|
||||
let lastTransactionPoint: TransactionPoint = null;
|
||||
for (const order of orders) {
|
||||
for (const order of this.orders) {
|
||||
const currentDate = order.date;
|
||||
|
||||
let currentTransactionPointItem: TransactionPointSymbol;
|
||||
@ -59,17 +80,30 @@ export class PortfolioCalculator {
|
||||
const newQuantity = order.quantity
|
||||
.mul(factor)
|
||||
.plus(oldAccumulatedSymbol.quantity);
|
||||
|
||||
let investment = new Big(0);
|
||||
|
||||
if (newQuantity.gt(0)) {
|
||||
if (order.type === 'BUY') {
|
||||
investment = oldAccumulatedSymbol.investment.plus(
|
||||
order.quantity.mul(unitPrice)
|
||||
);
|
||||
} else if (order.type === 'SELL') {
|
||||
const averagePrice = oldAccumulatedSymbol.investment.div(
|
||||
oldAccumulatedSymbol.quantity
|
||||
);
|
||||
investment = oldAccumulatedSymbol.investment.minus(
|
||||
order.quantity.mul(averagePrice)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
currentTransactionPointItem = {
|
||||
investment,
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
investment: newQuantity.eq(0)
|
||||
? new Big(0)
|
||||
: unitPrice
|
||||
.mul(order.quantity)
|
||||
.mul(factor)
|
||||
.plus(oldAccumulatedSymbol.investment),
|
||||
quantity: newQuantity,
|
||||
symbol: order.symbol,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
@ -94,7 +128,7 @@ export class PortfolioCalculator {
|
||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||
);
|
||||
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) {
|
||||
lastTransactionPoint = {
|
||||
date: currentDate,
|
||||
@ -140,7 +174,6 @@ export class PortfolioCalculator {
|
||||
hasErrors: false,
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
netAnnualizedPerformance: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
positions: [],
|
||||
@ -195,124 +228,50 @@ export class PortfolioCalculator {
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
if (marketSymbol.marketPriceInBaseCurrency) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
marketSymbol.marketPriceInBaseCurrency
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
const startString = format(start, DATE_FORMAT);
|
||||
|
||||
const holdingPeriodReturns: { [symbol: string]: Big } = {};
|
||||
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
|
||||
const grossPerformance: { [symbol: string]: Big } = {};
|
||||
const netPerformance: { [symbol: string]: Big } = {};
|
||||
const todayString = format(today, DATE_FORMAT);
|
||||
|
||||
if (firstIndex > 0) {
|
||||
firstIndex--;
|
||||
}
|
||||
const invalidSymbols = [];
|
||||
const lastInvestments: { [symbol: string]: Big } = {};
|
||||
const lastQuantities: { [symbol: string]: Big } = {};
|
||||
const lastFees: { [symbol: string]: Big } = {};
|
||||
const initialValues: { [symbol: string]: Big } = {};
|
||||
|
||||
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
|
||||
const currentDate =
|
||||
i === firstIndex ? startString : this.transactionPoints[i].date;
|
||||
const nextDate =
|
||||
i + 1 < this.transactionPoints.length
|
||||
? this.transactionPoints[i + 1].date
|
||||
: todayString;
|
||||
|
||||
const items = this.transactionPoints[i].items;
|
||||
for (const item of items) {
|
||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
Logger.warn(
|
||||
`Missing value for symbol ${item.symbol} at ${nextDate}`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let lastInvestment: Big = new Big(0);
|
||||
let lastQuantity: Big = item.quantity;
|
||||
if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) {
|
||||
lastInvestment = item.investment.minus(lastInvestments[item.symbol]);
|
||||
lastQuantity = lastQuantities[item.symbol];
|
||||
}
|
||||
|
||||
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
|
||||
let initialValue = itemValue?.mul(lastQuantity);
|
||||
let investedValue = itemValue?.mul(item.quantity);
|
||||
const isFirstOrderAndIsStartBeforeCurrentDate =
|
||||
i === firstIndex &&
|
||||
isBefore(parseDate(this.transactionPoints[i].date), start);
|
||||
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
|
||||
const fee = isFirstOrderAndIsStartBeforeCurrentDate
|
||||
? new Big(0)
|
||||
: item.fee.minus(lastFee);
|
||||
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
|
||||
initialValue = item.investment;
|
||||
investedValue = item.investment;
|
||||
}
|
||||
if (i === firstIndex || !initialValues[item.symbol]) {
|
||||
initialValues[item.symbol] = initialValue;
|
||||
}
|
||||
if (!item.quantity.eq(0)) {
|
||||
if (!initialValue) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
Logger.warn(
|
||||
`Missing value for symbol ${item.symbol} at ${currentDate}`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cashFlow = lastInvestment;
|
||||
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
|
||||
item.quantity
|
||||
);
|
||||
|
||||
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
|
||||
holdingPeriodReturns[item.symbol] = (
|
||||
holdingPeriodReturns[item.symbol] ?? new Big(1)
|
||||
).mul(holdingPeriodReturn);
|
||||
grossPerformance[item.symbol] = (
|
||||
grossPerformance[item.symbol] ?? new Big(0)
|
||||
).plus(endValue.minus(investedValue));
|
||||
|
||||
const netHoldingPeriodReturn = endValue.div(
|
||||
initialValue.plus(cashFlow).plus(fee)
|
||||
);
|
||||
netHoldingPeriodReturns[item.symbol] = (
|
||||
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
|
||||
).mul(netHoldingPeriodReturn);
|
||||
netPerformance[item.symbol] = (
|
||||
netPerformance[item.symbol] ?? new Big(0)
|
||||
).plus(endValue.minus(investedValue).minus(fee));
|
||||
}
|
||||
lastInvestments[item.symbol] = item.investment;
|
||||
lastQuantities[item.symbol] = item.quantity;
|
||||
lastFees[item.symbol] = item.fee;
|
||||
}
|
||||
}
|
||||
|
||||
const positions: TimelinePosition[] = [];
|
||||
let hasAnySymbolMetricsErrors = false;
|
||||
|
||||
const errors: ResponseError['errors'] = [];
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
|
||||
|
||||
const {
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
initialValue,
|
||||
netPerformance,
|
||||
netPerformancePercentage
|
||||
} = this.getSymbolMetrics({
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol: item.symbol
|
||||
});
|
||||
|
||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||
initialValues[item.symbol] = initialValue;
|
||||
|
||||
positions.push({
|
||||
averagePrice: item.quantity.eq(0)
|
||||
? new Big(0)
|
||||
@ -320,31 +279,33 @@ export class PortfolioCalculator {
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: isValid
|
||||
? grossPerformance[item.symbol] ?? null
|
||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||
grossPerformancePercentage: !hasErrors
|
||||
? grossPerformancePercentage ?? null
|
||||
: null,
|
||||
grossPerformancePercentage:
|
||||
isValid && holdingPeriodReturns[item.symbol]
|
||||
? holdingPeriodReturns[item.symbol].minus(1)
|
||||
: null,
|
||||
investment: item.investment,
|
||||
marketPrice: marketValue?.toNumber() ?? null,
|
||||
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
|
||||
netPerformancePercentage:
|
||||
isValid && netHoldingPeriodReturns[item.symbol]
|
||||
? netHoldingPeriodReturns[item.symbol].minus(1)
|
||||
: null,
|
||||
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
||||
netPerformancePercentage: !hasErrors
|
||||
? netPerformancePercentage ?? null
|
||||
: null,
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
transactionCount: item.transactionCount
|
||||
});
|
||||
|
||||
if (hasErrors) {
|
||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||
}
|
||||
}
|
||||
|
||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
||||
|
||||
return {
|
||||
...overall,
|
||||
errors,
|
||||
positions,
|
||||
hasErrors: hasErrors || overall.hasErrors
|
||||
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
|
||||
};
|
||||
}
|
||||
|
||||
@ -365,6 +326,53 @@ export class PortfolioCalculator {
|
||||
});
|
||||
}
|
||||
|
||||
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
||||
if (this.orders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const investments = [];
|
||||
let currentDate: Date;
|
||||
let investmentByMonth = new Big(0);
|
||||
|
||||
for (const [index, order] of this.orders.entries()) {
|
||||
if (
|
||||
isSameMonth(parseDate(order.date), currentDate) &&
|
||||
isSameYear(parseDate(order.date), currentDate)
|
||||
) {
|
||||
// Same month: Add up investments
|
||||
|
||||
investmentByMonth = investmentByMonth.plus(
|
||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
);
|
||||
} else {
|
||||
// New month: Store previous month and reset
|
||||
|
||||
if (currentDate) {
|
||||
investments.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
});
|
||||
}
|
||||
|
||||
currentDate = parseDate(order.date);
|
||||
investmentByMonth = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
}
|
||||
|
||||
if (index === this.orders.length - 1) {
|
||||
// Store current month (latest order)
|
||||
investments.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return investments;
|
||||
}
|
||||
|
||||
public async calculateTimeline(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
endDate: string
|
||||
@ -462,20 +470,16 @@ export class PortfolioCalculator {
|
||||
|
||||
private calculateOverallPerformance(
|
||||
positions: TimelinePosition[],
|
||||
initialValues: { [p: string]: Big }
|
||||
initialValues: { [symbol: string]: Big }
|
||||
) {
|
||||
let hasErrors = false;
|
||||
let currentValue = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformancePercentage = new Big(0);
|
||||
let hasErrors = false;
|
||||
let netPerformance = new Big(0);
|
||||
let netPerformancePercentage = new Big(0);
|
||||
let completeInitialValue = new Big(0);
|
||||
let netAnnualizedPerformance = new Big(0);
|
||||
|
||||
// use Date.now() to use the mock for today
|
||||
const today = new Date(Date.now());
|
||||
let sumOfWeights = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
@ -485,36 +489,34 @@ export class PortfolioCalculator {
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
||||
|
||||
if (currentPosition.grossPerformance) {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
);
|
||||
|
||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (
|
||||
currentPosition.grossPerformancePercentage &&
|
||||
initialValues[currentPosition.symbol]
|
||||
) {
|
||||
const currentInitialValue = initialValues[currentPosition.symbol];
|
||||
completeInitialValue = completeInitialValue.plus(currentInitialValue);
|
||||
if (currentPosition.grossPerformancePercentage) {
|
||||
// Use the average from the initial value and the current investment as
|
||||
// a weight
|
||||
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
|
||||
.plus(currentPosition.investment)
|
||||
.div(2);
|
||||
|
||||
sumOfWeights = sumOfWeights.plus(weight);
|
||||
|
||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
netAnnualizedPerformance = netAnnualizedPerformance.plus(
|
||||
this.getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(
|
||||
today,
|
||||
parseDate(currentPosition.firstBuyDate)
|
||||
),
|
||||
netPerformancePercent: currentPosition.netPerformancePercentage
|
||||
}).mul(currentInitialValue)
|
||||
currentPosition.grossPerformancePercentage.mul(weight)
|
||||
);
|
||||
|
||||
netPerformancePercentage = netPerformancePercentage.plus(
|
||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||
currentPosition.netPerformancePercentage.mul(weight)
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.warn(
|
||||
@ -525,13 +527,12 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
if (!completeInitialValue.eq(0)) {
|
||||
grossPerformancePercentage =
|
||||
grossPerformancePercentage.div(completeInitialValue);
|
||||
netPerformancePercentage =
|
||||
netPerformancePercentage.div(completeInitialValue);
|
||||
netAnnualizedPerformance =
|
||||
netAnnualizedPerformance.div(completeInitialValue);
|
||||
if (sumOfWeights.gt(0)) {
|
||||
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
|
||||
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
|
||||
} else {
|
||||
grossPerformancePercentage = new Big(0);
|
||||
netPerformancePercentage = new Big(0);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -539,7 +540,6 @@ export class PortfolioCalculator {
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
netAnnualizedPerformance,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
totalInvestment
|
||||
@ -598,9 +598,9 @@ export class PortfolioCalculator {
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
if (marketSymbol.marketPriceInBaseCurrency) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
marketSymbol.marketPriceInBaseCurrency
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -693,6 +693,356 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
private getSymbolMetrics({
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol
|
||||
}: {
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
start: Date;
|
||||
symbol: string;
|
||||
}) {
|
||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
||||
return order.symbol === symbol;
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
hasErrors: false,
|
||||
initialValue: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
const dateOfFirstTransaction = new Date(first(orders).date);
|
||||
const endDate = new Date(Date.now());
|
||||
|
||||
const unitPriceAtStartDate =
|
||||
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
||||
|
||||
const unitPriceAtEndDate =
|
||||
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
||||
|
||||
if (
|
||||
!unitPriceAtEndDate ||
|
||||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
||||
) {
|
||||
return {
|
||||
hasErrors: true,
|
||||
initialValue: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
let averagePriceAtEndDate = new Big(0);
|
||||
let averagePriceAtStartDate = new Big(0);
|
||||
let feesAtStartDate = new Big(0);
|
||||
let fees = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformanceAtStartDate = new Big(0);
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastTransactionInvestment = new Big(0);
|
||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
||||
let maxTotalInvestment = new Big(0);
|
||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
let valueAtStartDate: Big;
|
||||
|
||||
// Add a synthetic order at the start and the end date
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(start, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
itemType: 'start',
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
unitPrice: unitPriceAtStartDate
|
||||
});
|
||||
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(endDate, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
itemType: 'end',
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
unitPrice: unitPriceAtEndDate
|
||||
});
|
||||
|
||||
// Sort orders so that the start and end placeholder order are at the right
|
||||
// position
|
||||
orders = sortBy(orders, (order) => {
|
||||
let sortIndex = new Date(order.date);
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
sortIndex = addMilliseconds(sortIndex, -1);
|
||||
}
|
||||
|
||||
if (order.itemType === 'end') {
|
||||
sortIndex = addMilliseconds(sortIndex, 1);
|
||||
}
|
||||
|
||||
return sortIndex.getTime();
|
||||
});
|
||||
|
||||
const indexOfStartOrder = orders.findIndex((order) => {
|
||||
return order.itemType === 'start';
|
||||
});
|
||||
|
||||
const indexOfEndOrder = orders.findIndex((order) => {
|
||||
return order.itemType === 'end';
|
||||
});
|
||||
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
// Take the unit price of the order as the market price if there are no
|
||||
// orders of this symbol before the start date
|
||||
order.unitPrice =
|
||||
indexOfStartOrder === 0
|
||||
? orders[i + 1]?.unitPrice
|
||||
: unitPriceAtStartDate;
|
||||
}
|
||||
|
||||
// Calculate the average start price as soon as any units are held
|
||||
if (
|
||||
averagePriceAtStartDate.eq(0) &&
|
||||
i >= indexOfStartOrder &&
|
||||
totalUnits.gt(0)
|
||||
) {
|
||||
averagePriceAtStartDate = totalInvestment.div(totalUnits);
|
||||
}
|
||||
|
||||
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
|
||||
order.unitPrice
|
||||
);
|
||||
|
||||
if (!investmentAtStartDate && i >= indexOfStartOrder) {
|
||||
investmentAtStartDate = totalInvestment ?? new Big(0);
|
||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||
}
|
||||
|
||||
const transactionInvestment = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
|
||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||
|
||||
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
|
||||
maxTotalInvestment = totalInvestment;
|
||||
}
|
||||
|
||||
if (i === indexOfEndOrder && totalUnits.gt(0)) {
|
||||
averagePriceAtEndDate = totalInvestment.div(totalUnits);
|
||||
}
|
||||
|
||||
if (i >= indexOfStartOrder && !initialValue) {
|
||||
if (
|
||||
i === indexOfStartOrder &&
|
||||
!valueOfInvestmentBeforeTransaction.eq(0)
|
||||
) {
|
||||
initialValue = valueOfInvestmentBeforeTransaction;
|
||||
} else if (transactionInvestment.gt(0)) {
|
||||
initialValue = transactionInvestment;
|
||||
}
|
||||
}
|
||||
|
||||
fees = fees.plus(order.fee);
|
||||
|
||||
totalUnits = totalUnits.plus(
|
||||
order.quantity.mul(this.getFactor(order.type))
|
||||
);
|
||||
|
||||
const valueOfInvestment = totalUnits.mul(order.unitPrice);
|
||||
|
||||
const grossPerformanceFromSell =
|
||||
order.type === TypeOfOrder.SELL
|
||||
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
|
||||
: new Big(0);
|
||||
|
||||
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
||||
grossPerformanceFromSell
|
||||
);
|
||||
|
||||
totalInvestmentWithGrossPerformanceFromSell =
|
||||
totalInvestmentWithGrossPerformanceFromSell
|
||||
.plus(transactionInvestment)
|
||||
.plus(grossPerformanceFromSell);
|
||||
|
||||
lastAveragePrice = totalUnits.eq(0)
|
||||
? new Big(0)
|
||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
if (
|
||||
i > indexOfStartOrder &&
|
||||
!lastValueOfInvestmentBeforeTransaction
|
||||
.plus(lastTransactionInvestment)
|
||||
.eq(0)
|
||||
) {
|
||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.mul(
|
||||
new Big(1).plus(grossHoldingPeriodReturn)
|
||||
);
|
||||
|
||||
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(fees.minus(feesAtStartDate))
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.mul(
|
||||
new Big(1).plus(netHoldingPeriodReturn)
|
||||
);
|
||||
}
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
|
||||
lastTransactionInvestment = transactionInvestment;
|
||||
|
||||
lastValueOfInvestmentBeforeTransaction =
|
||||
valueOfInvestmentBeforeTransaction;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
}
|
||||
}
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.minus(1);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.minus(1);
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
);
|
||||
|
||||
const totalNetPerformance = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
|
||||
maxTotalInvestment.minus(investmentAtStartDate)
|
||||
);
|
||||
|
||||
const grossPerformancePercentage =
|
||||
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
||||
averagePriceAtStartDate.eq(0) ||
|
||||
averagePriceAtEndDate.eq(0) ||
|
||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||
: new Big(0)
|
||||
: // This formula has the issue that buying more units with a price
|
||||
// lower than the average buying price results in a positive
|
||||
// performance even if the market price stays constant
|
||||
unitPriceAtEndDate
|
||||
.div(averagePriceAtEndDate)
|
||||
.div(
|
||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||
)
|
||||
.minus(1);
|
||||
|
||||
const feesPerUnit = totalUnits.gt(0)
|
||||
? fees.minus(feesAtStartDate).div(totalUnits)
|
||||
: new Big(0);
|
||||
|
||||
const netPerformancePercentage =
|
||||
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
||||
averagePriceAtStartDate.eq(0) ||
|
||||
averagePriceAtEndDate.eq(0) ||
|
||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||
: new Big(0)
|
||||
: // This formula has the issue that buying more units with a price
|
||||
// lower than the average buying price results in a positive
|
||||
// performance even if the market price stays constant
|
||||
unitPriceAtEndDate
|
||||
.minus(feesPerUnit)
|
||||
.div(averagePriceAtEndDate)
|
||||
.div(
|
||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||
)
|
||||
.minus(1);
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
`
|
||||
${symbol}
|
||||
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
|
||||
2
|
||||
)} -> ${unitPriceAtEndDate.toFixed(2)}
|
||||
Average price: ${averagePriceAtStartDate.toFixed(
|
||||
2
|
||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||
Gross performance: ${totalGrossPerformance.toFixed(
|
||||
2
|
||||
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
|
||||
Fees per unit: ${feesPerUnit.toFixed(2)}
|
||||
Net performance: ${totalNetPerformance.toFixed(
|
||||
2
|
||||
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
initialValue,
|
||||
grossPerformancePercentage,
|
||||
netPerformancePercentage,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance,
|
||||
grossPerformance: totalGrossPerformance
|
||||
};
|
||||
}
|
||||
|
||||
private isNextItemActive(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
currentDate: Date,
|
||||
|
@ -1,26 +0,0 @@
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioServiceStrategy {
|
||||
public constructor(
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly portfolioServiceNew: PortfolioServiceNew,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
public get(newCalculationEngine?: boolean) {
|
||||
if (
|
||||
newCalculationEngine ||
|
||||
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
|
||||
) {
|
||||
return this.portfolioServiceNew;
|
||||
}
|
||||
|
||||
return this.portfolioService;
|
||||
}
|
||||
}
|
@ -4,13 +4,14 @@ import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Filter,
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
@ -19,7 +20,12 @@ import {
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
DateRange,
|
||||
GroupBy,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -38,18 +44,22 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ -57,9 +67,10 @@ export class PortfolioController {
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getChart(impersonationId, range);
|
||||
const historicalDataContainer = await this.portfolioService.getChart(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
let chartData = historicalDataContainer.items;
|
||||
|
||||
@ -101,27 +112,49 @@ export class PortfolioController {
|
||||
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getDetails(
|
||||
@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 }> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
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 } =
|
||||
await this.portfolioServiceStrategy
|
||||
.get(true)
|
||||
.getDetails(impersonationId, this.request.user.id, range);
|
||||
await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
this.request.user.id,
|
||||
range,
|
||||
filters
|
||||
);
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
hasError = true;
|
||||
@ -162,13 +195,35 @@ export class PortfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
return { accounts, hasError, holdings };
|
||||
let hasDetails = true;
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
holdings[symbol] = {
|
||||
...portfolioPosition,
|
||||
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
||||
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
hasError,
|
||||
holdings
|
||||
};
|
||||
}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('groupBy') groupBy?: GroupBy
|
||||
): Promise<PortfolioInvestments> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
@ -180,9 +235,16 @@ export class PortfolioController {
|
||||
);
|
||||
}
|
||||
|
||||
let investments = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getInvestments(impersonationId);
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
if (groupBy === 'month') {
|
||||
investments = await this.portfolioService.getInvestments(
|
||||
impersonationId,
|
||||
'month'
|
||||
);
|
||||
} else {
|
||||
investments = await this.portfolioService.getInvestments(impersonationId);
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -209,9 +271,10 @@ export class PortfolioController {
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const performanceInformation = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPerformance(impersonationId, range);
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -234,9 +297,10 @@ export class PortfolioController {
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPositions(impersonationId, range);
|
||||
const result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -276,9 +340,12 @@ export class PortfolioController {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioServiceStrategy
|
||||
.get(true)
|
||||
.getDetails(access.userId, access.userId);
|
||||
const { holdings } = await this.portfolioService.getDetails(
|
||||
access.userId,
|
||||
access.userId,
|
||||
'max',
|
||||
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
||||
);
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
hasDetails,
|
||||
@ -286,30 +353,28 @@ export class PortfolioController {
|
||||
};
|
||||
|
||||
const totalValue = Object.values(holdings)
|
||||
.filter((holding) => {
|
||||
return holding.assetClass === 'EQUITY';
|
||||
})
|
||||
.map((portfolioPosition) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
portfolioPosition.currency,
|
||||
this.request.user?.Settings?.currency ?? baseCurrency
|
||||
this.request.user?.Settings?.currency ?? this.baseCurrency
|
||||
);
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
if (portfolioPosition.assetClass === 'EQUITY') {
|
||||
portfolioPublicDetails.holdings[symbol] = {
|
||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: portfolioPosition.currency,
|
||||
markets: portfolioPosition.markets,
|
||||
name: portfolioPosition.name,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
value: portfolioPosition.value / totalValue
|
||||
};
|
||||
}
|
||||
portfolioPublicDetails.holdings[symbol] = {
|
||||
allocationCurrent: portfolioPosition.value / totalValue,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
name: portfolioPosition.name,
|
||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
symbol: portfolioPosition.symbol,
|
||||
url: portfolioPosition.url,
|
||||
value: portfolioPosition.value / totalValue
|
||||
};
|
||||
}
|
||||
|
||||
return portfolioPublicDetails;
|
||||
@ -320,9 +385,17 @@ export class PortfolioController {
|
||||
public async getSummary(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<PortfolioSummary> {
|
||||
let summary = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getSummary(impersonationId);
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let summary = await this.portfolioService.getSummary(impersonationId);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -356,9 +429,11 @@ export class PortfolioController {
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
let position = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPosition(dataSource, impersonationId, symbol);
|
||||
let position = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
if (position) {
|
||||
if (
|
||||
@ -399,6 +474,6 @@ export class PortfolioController {
|
||||
);
|
||||
}
|
||||
|
||||
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
|
||||
return await this.portfolioService.getReport(impersonationId);
|
||||
}
|
||||
}
|
||||
|
@ -13,15 +13,13 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PortfolioController],
|
||||
exports: [PortfolioServiceStrategy],
|
||||
exports: [PortfolioService],
|
||||
imports: [
|
||||
AccessModule,
|
||||
ConfigurationModule,
|
||||
@ -39,8 +37,6 @@ import { RulesService } from './rules.service';
|
||||
AccountService,
|
||||
CurrentRateService,
|
||||
PortfolioService,
|
||||
PortfolioServiceNew,
|
||||
PortfolioServiceStrategy,
|
||||
RulesService
|
||||
]
|
||||
})
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,6 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
|
||||
import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||
@ -16,20 +15,21 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap
|
||||
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
||||
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
|
||||
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { 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 {
|
||||
ASSET_SUB_CLASS_EMERGENCY_FUND,
|
||||
UNKNOWN_KEY,
|
||||
baseCurrency
|
||||
UNKNOWN_KEY
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Accounts,
|
||||
EnhancedSymbolProfile,
|
||||
Filter,
|
||||
HistoricalDataItem,
|
||||
PortfolioDetails,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioReport,
|
||||
@ -41,14 +41,23 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
|
||||
import type {
|
||||
AccountWithValue,
|
||||
DateRange,
|
||||
GroupBy,
|
||||
Market,
|
||||
OrderWithAccount,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import {
|
||||
AssetClass,
|
||||
DataSource,
|
||||
Prisma,
|
||||
Tag,
|
||||
Type as TypeOfOrder
|
||||
} from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
differenceInDays,
|
||||
endOfToday,
|
||||
format,
|
||||
isAfter,
|
||||
@ -56,24 +65,31 @@ import {
|
||||
max,
|
||||
parse,
|
||||
parseISO,
|
||||
set,
|
||||
setDayOfYear,
|
||||
startOfDay,
|
||||
subDays,
|
||||
subYears
|
||||
} from 'date-fns';
|
||||
import { isEmpty, sortBy } from 'lodash';
|
||||
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
|
||||
|
||||
import {
|
||||
HistoricalDataContainer,
|
||||
HistoricalDataItem,
|
||||
PortfolioPositionDetail
|
||||
} from './interfaces/portfolio-position-detail.interface';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
const developedMarkets = require('../../assets/countries/developed-markets.json');
|
||||
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly currentRateService: CurrentRateService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
@ -83,16 +99,27 @@ export class PortfolioService {
|
||||
private readonly rulesService: RulesService,
|
||||
private readonly symbolProfileService: SymbolProfileService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public async getAccounts(
|
||||
aUserId: string,
|
||||
aFilters?: Filter[]
|
||||
): Promise<AccountWithValue[]> {
|
||||
const where: Prisma.AccountWhereInput = { userId: aUserId };
|
||||
|
||||
if (aFilters?.[0].id && aFilters?.[0].type === 'ACCOUNT') {
|
||||
where.id = aFilters[0].id;
|
||||
}
|
||||
|
||||
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
|
||||
const [accounts, details] = await Promise.all([
|
||||
this.accountService.accounts({
|
||||
where,
|
||||
include: { Order: true, Platform: true },
|
||||
orderBy: { name: 'asc' },
|
||||
where: { userId: aUserId }
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
this.getDetails(aUserId, aUserId)
|
||||
this.getDetails(aUserId, aUserId, undefined, aFilters)
|
||||
]);
|
||||
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
@ -106,21 +133,21 @@ export class PortfolioService {
|
||||
}
|
||||
}
|
||||
|
||||
const value = details.accounts[account.id]?.current ?? 0;
|
||||
const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0;
|
||||
|
||||
const result = {
|
||||
...account,
|
||||
transactionCount,
|
||||
value,
|
||||
valueInBaseCurrency,
|
||||
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
account.currency,
|
||||
userCurrency
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
valueInBaseCurrency,
|
||||
userCurrency,
|
||||
account.currency
|
||||
)
|
||||
};
|
||||
|
||||
@ -130,8 +157,11 @@ export class PortfolioService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
|
||||
const accounts = await this.getAccounts(aUserId);
|
||||
public async getAccountsWithAggregations(
|
||||
aUserId: string,
|
||||
aFilters?: Filter[]
|
||||
): Promise<Accounts> {
|
||||
const accounts = await this.getAccounts(aUserId, aFilters);
|
||||
let totalBalanceInBaseCurrency = new Big(0);
|
||||
let totalValueInBaseCurrency = new Big(0);
|
||||
let transactionCount = 0;
|
||||
@ -155,46 +185,79 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getInvestments(
|
||||
aImpersonationId: string
|
||||
aImpersonationId: string,
|
||||
groupBy?: GroupBy
|
||||
): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
userId,
|
||||
includeDrafts: true
|
||||
});
|
||||
|
||||
const { transactionPoints } = await this.getTransactionPoints({
|
||||
userId,
|
||||
includeDrafts: true
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.currency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const investments = portfolioCalculator.getInvestments().map((item) => {
|
||||
return {
|
||||
date: item.date,
|
||||
investment: item.investment.toNumber()
|
||||
};
|
||||
});
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
// Add investment of today
|
||||
const investmentOfToday = investments.filter((investment) => {
|
||||
return investment.date === format(new Date(), DATE_FORMAT);
|
||||
});
|
||||
|
||||
if (investmentOfToday.length <= 0) {
|
||||
const pastInvestments = investments.filter((investment) => {
|
||||
return isBefore(parseDate(investment.date), new Date());
|
||||
if (groupBy === 'month') {
|
||||
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
|
||||
return {
|
||||
date: item.date,
|
||||
investment: item.investment.toNumber()
|
||||
};
|
||||
});
|
||||
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
||||
|
||||
investments.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
investment: lastInvestment?.investment ?? 0
|
||||
// Add investment of current month
|
||||
const dateOfCurrentMonth = format(
|
||||
set(new Date(), { date: 1 }),
|
||||
DATE_FORMAT
|
||||
);
|
||||
const investmentOfCurrentMonth = investments.filter(({ date }) => {
|
||||
return date === dateOfCurrentMonth;
|
||||
});
|
||||
|
||||
if (investmentOfCurrentMonth.length <= 0) {
|
||||
investments.push({
|
||||
date: dateOfCurrentMonth,
|
||||
investment: 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
investments = portfolioCalculator
|
||||
.getInvestments()
|
||||
.map(({ date, investment }) => {
|
||||
return {
|
||||
date,
|
||||
investment: investment.toNumber()
|
||||
};
|
||||
});
|
||||
|
||||
// Add investment of today
|
||||
const investmentOfToday = investments.filter(({ date }) => {
|
||||
return date === format(new Date(), DATE_FORMAT);
|
||||
});
|
||||
|
||||
if (investmentOfToday.length <= 0) {
|
||||
const pastInvestments = investments.filter(({ date }) => {
|
||||
return isBefore(parseDate(date), new Date());
|
||||
});
|
||||
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
||||
|
||||
investments.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
investment: lastInvestment?.investment ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(investments, (investment) => {
|
||||
@ -208,12 +271,17 @@ export class PortfolioService {
|
||||
): Promise<HistoricalDataContainer> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
userId
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.currency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
return {
|
||||
@ -250,7 +318,6 @@ export class PortfolioService {
|
||||
.filter((timelineItem) => timelineItem !== null)
|
||||
.map((timelineItem) => ({
|
||||
date: timelineItem.date,
|
||||
marketPrice: timelineItem.value,
|
||||
value: timelineItem.netPerformance.toNumber()
|
||||
}));
|
||||
|
||||
@ -290,7 +357,8 @@ export class PortfolioService {
|
||||
public async getDetails(
|
||||
aImpersonationId: string,
|
||||
aUserId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
aDateRange: DateRange = 'max',
|
||||
aFilters?: Filter[]
|
||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
@ -299,16 +367,20 @@ export class PortfolioService {
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
const userCurrency =
|
||||
this.request.user?.Settings?.currency ??
|
||||
user.Settings?.currency ??
|
||||
baseCurrency;
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
userCurrency
|
||||
);
|
||||
this.request.user?.Settings?.currency ??
|
||||
this.baseCurrency;
|
||||
|
||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
||||
userId
|
||||
const { orders, portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
userId,
|
||||
filters: aFilters
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
@ -321,10 +393,11 @@ export class PortfolioService {
|
||||
startDate
|
||||
);
|
||||
|
||||
const cashDetails = await this.accountService.getCashDetails(
|
||||
const cashDetails = await this.accountService.getCashDetails({
|
||||
userId,
|
||||
userCurrency
|
||||
);
|
||||
currency: userCurrency,
|
||||
filters: aFilters
|
||||
});
|
||||
|
||||
const holdings: PortfolioDetails['holdings'] = {};
|
||||
const totalInvestment = currentPositions.totalInvestment.plus(
|
||||
@ -346,7 +419,7 @@ export class PortfolioService {
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes(dataGatheringItems),
|
||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
|
||||
]);
|
||||
|
||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||
@ -365,10 +438,34 @@ export class PortfolioService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = item.quantity.mul(item.marketPrice);
|
||||
const value = item.quantity.mul(item.marketPrice ?? 0);
|
||||
const symbolProfile = symbolProfileMap[item.symbol];
|
||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||
|
||||
const markets: { [key in Market]: number } = {
|
||||
developedMarkets: 0,
|
||||
emergingMarkets: 0,
|
||||
otherMarkets: 0
|
||||
};
|
||||
|
||||
for (const country of symbolProfile.countries) {
|
||||
if (developedMarkets.includes(country.code)) {
|
||||
markets.developedMarkets = new Big(markets.developedMarkets)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else if (emergingMarkets.includes(country.code)) {
|
||||
markets.emergingMarkets = new Big(markets.emergingMarkets)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
} else {
|
||||
markets.otherMarkets = new Big(markets.otherMarkets)
|
||||
.plus(country.weight)
|
||||
.toNumber();
|
||||
}
|
||||
}
|
||||
|
||||
holdings[item.symbol] = {
|
||||
markets,
|
||||
allocationCurrent: value.div(totalValue).toNumber(),
|
||||
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
||||
assetClass: symbolProfile.assetClass,
|
||||
@ -389,28 +486,37 @@ export class PortfolioService {
|
||||
sectors: symbolProfile.sectors,
|
||||
symbol: item.symbol,
|
||||
transactionCount: item.transactionCount,
|
||||
url: symbolProfile.url,
|
||||
value: value.toNumber()
|
||||
};
|
||||
}
|
||||
|
||||
const cashPositions = await this.getCashPositions({
|
||||
cashDetails,
|
||||
emergencyFund,
|
||||
userCurrency,
|
||||
investment: totalInvestment,
|
||||
value: totalValue
|
||||
});
|
||||
if (
|
||||
aFilters?.length === 0 ||
|
||||
(aFilters?.length === 1 &&
|
||||
aFilters[0].type === 'ASSET_CLASS' &&
|
||||
aFilters[0].id === 'CASH')
|
||||
) {
|
||||
const cashPositions = await this.getCashPositions({
|
||||
cashDetails,
|
||||
emergencyFund,
|
||||
userCurrency,
|
||||
investment: totalInvestment,
|
||||
value: totalValue
|
||||
});
|
||||
|
||||
for (const symbol of Object.keys(cashPositions)) {
|
||||
holdings[symbol] = cashPositions[symbol];
|
||||
for (const symbol of Object.keys(cashPositions)) {
|
||||
holdings[symbol] = cashPositions[symbol];
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await this.getValueOfAccounts(
|
||||
const accounts = await this.getValueOfAccounts({
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId
|
||||
);
|
||||
userId,
|
||||
filters: aFilters
|
||||
});
|
||||
|
||||
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
|
||||
}
|
||||
@ -432,8 +538,11 @@ export class PortfolioService {
|
||||
);
|
||||
});
|
||||
|
||||
let tags: Tag[] = [];
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
tags,
|
||||
averagePrice: undefined,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
@ -454,12 +563,13 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const positionCurrency = orders[0].SymbolProfile.currency;
|
||||
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
aSymbol
|
||||
]);
|
||||
const [SymbolProfile] =
|
||||
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders
|
||||
.filter((order) => {
|
||||
tags = tags.concat(order.tags);
|
||||
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
})
|
||||
.map((order) => ({
|
||||
@ -474,11 +584,15 @@ export class PortfolioService {
|
||||
unitPrice: new Big(order.unitPrice)
|
||||
}));
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
positionCurrency
|
||||
);
|
||||
portfolioCalculator.computeTransactionPoints(portfolioOrders);
|
||||
tags = uniqBy(tags, 'id');
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: positionCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
const transactionPoints = portfolioCalculator.getTransactionPoints();
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
@ -580,6 +694,7 @@ export class PortfolioService {
|
||||
netPerformance,
|
||||
orders,
|
||||
SymbolProfile,
|
||||
tags,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
grossPerformancePercent:
|
||||
@ -588,7 +703,7 @@ export class PortfolioService {
|
||||
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
|
||||
quantity: quantity.toNumber(),
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
quantity.mul(marketPrice).toNumber(),
|
||||
quantity.mul(marketPrice ?? 0).toNumber(),
|
||||
currency,
|
||||
userCurrency
|
||||
)
|
||||
@ -636,6 +751,7 @@ export class PortfolioService {
|
||||
minPrice,
|
||||
orders,
|
||||
SymbolProfile,
|
||||
tags,
|
||||
averagePrice: 0,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
@ -657,12 +773,16 @@ export class PortfolioService {
|
||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
userId
|
||||
});
|
||||
|
||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.currency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {
|
||||
@ -692,7 +812,7 @@ export class PortfolioService {
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes(dataGatheringItem),
|
||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
|
||||
]);
|
||||
|
||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||
@ -712,8 +832,7 @@ export class PortfolioService {
|
||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||
investment: new Big(position.investment).toNumber(),
|
||||
marketState:
|
||||
dataProviderResponses[position.symbol]?.marketState ??
|
||||
MarketState.delayed,
|
||||
dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
|
||||
name: symbolProfileMap[position.symbol].name,
|
||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||
netPerformancePercentage:
|
||||
@ -730,18 +849,21 @@ export class PortfolioService {
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
userId
|
||||
});
|
||||
|
||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.currency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {
|
||||
hasErrors: false,
|
||||
performance: {
|
||||
annualizedPerformancePercent: 0,
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
@ -760,26 +882,34 @@ export class PortfolioService {
|
||||
);
|
||||
|
||||
const hasErrors = currentPositions.hasErrors;
|
||||
const annualizedPerformancePercent =
|
||||
currentPositions.netAnnualizedPerformance.toNumber();
|
||||
const currentValue = currentPositions.currentValue.toNumber();
|
||||
const currentGrossPerformance =
|
||||
currentPositions.grossPerformance.toNumber();
|
||||
const currentGrossPerformancePercent =
|
||||
currentPositions.grossPerformancePercentage.toNumber();
|
||||
const currentNetPerformance = currentPositions.netPerformance.toNumber();
|
||||
const currentNetPerformancePercent =
|
||||
currentPositions.netPerformancePercentage.toNumber();
|
||||
const currentGrossPerformance = currentPositions.grossPerformance;
|
||||
let currentGrossPerformancePercent =
|
||||
currentPositions.grossPerformancePercentage;
|
||||
const currentNetPerformance = currentPositions.netPerformance;
|
||||
let currentNetPerformancePercent =
|
||||
currentPositions.netPerformancePercentage;
|
||||
|
||||
if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
|
||||
// If algebraic sign is different, harmonize it
|
||||
currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
|
||||
}
|
||||
|
||||
if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
|
||||
// If algebraic sign is different, harmonize it
|
||||
currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
|
||||
}
|
||||
|
||||
return {
|
||||
errors: currentPositions.errors,
|
||||
hasErrors: currentPositions.hasErrors || hasErrors,
|
||||
performance: {
|
||||
annualizedPerformancePercent,
|
||||
currentGrossPerformance,
|
||||
currentGrossPerformancePercent,
|
||||
currentNetPerformance,
|
||||
currentNetPerformancePercent,
|
||||
currentValue
|
||||
currentValue,
|
||||
currentGrossPerformance: currentGrossPerformance.toNumber(),
|
||||
currentGrossPerformancePercent:
|
||||
currentGrossPerformancePercent.toNumber(),
|
||||
currentNetPerformance: currentNetPerformance.toNumber(),
|
||||
currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -788,9 +918,10 @@ export class PortfolioService {
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
||||
userId
|
||||
});
|
||||
const { orders, portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
userId
|
||||
});
|
||||
|
||||
if (isEmpty(orders)) {
|
||||
return {
|
||||
@ -798,10 +929,12 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
currency
|
||||
);
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
@ -813,12 +946,12 @@ export class PortfolioService {
|
||||
for (const position of currentPositions.positions) {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
const accounts = await this.getValueOfAccounts(
|
||||
const accounts = await this.getValueOfAccounts({
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
currency,
|
||||
userId
|
||||
);
|
||||
userId,
|
||||
userCurrency: currency
|
||||
});
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
@ -880,10 +1013,10 @@ export class PortfolioService {
|
||||
|
||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||
|
||||
const { balanceInBaseCurrency } = await this.accountService.getCashDetails(
|
||||
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
|
||||
userId,
|
||||
userCurrency
|
||||
);
|
||||
currency: userCurrency
|
||||
});
|
||||
const orders = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
@ -907,8 +1040,24 @@ export class PortfolioService {
|
||||
.plus(items)
|
||||
.toNumber();
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
|
||||
const annualizedPerformancePercent = new PortfolioCalculator({
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: []
|
||||
})
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent: new Big(
|
||||
performanceInformation.performance.currentNetPerformancePercent
|
||||
)
|
||||
})
|
||||
?.toNumber();
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
annualizedPerformancePercent,
|
||||
cash,
|
||||
dividend,
|
||||
fees,
|
||||
@ -917,8 +1066,6 @@ export class PortfolioService {
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
annualizedPerformancePercent:
|
||||
performanceInformation.performance.annualizedPerformancePercent,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
emergencyFund: emergencyFund.toNumber(),
|
||||
ordersCount: orders.filter((order) => {
|
||||
@ -937,8 +1084,8 @@ export class PortfolioService {
|
||||
cashDetails: CashDetails;
|
||||
emergencyFund: Big;
|
||||
investment: Big;
|
||||
userCurrency: string;
|
||||
value: Big;
|
||||
userCurrency: string;
|
||||
}) {
|
||||
const cashPositions: PortfolioDetails['holdings'] = {};
|
||||
|
||||
@ -969,7 +1116,7 @@ export class PortfolioService {
|
||||
grossPerformancePercent: 0,
|
||||
investment: convertedBalance,
|
||||
marketPrice: 0,
|
||||
marketState: MarketState.open,
|
||||
marketState: 'open',
|
||||
name: account.currency,
|
||||
netPerformance: 0,
|
||||
netPerformancePercent: 0,
|
||||
@ -1103,18 +1250,23 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
private async getTransactionPoints({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
userId
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
userId: string;
|
||||
}): Promise<{
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: OrderWithAccount[];
|
||||
portfolioOrders: PortfolioOrder[];
|
||||
}> {
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
const userCurrency =
|
||||
this.request.user?.Settings?.currency ?? this.baseCurrency;
|
||||
|
||||
const orders = await this.orderService.getOrders({
|
||||
filters,
|
||||
includeDrafts,
|
||||
userCurrency,
|
||||
userId,
|
||||
@ -1122,7 +1274,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return { transactionPoints: [], orders: [] };
|
||||
return { transactionPoints: [], orders: [], portfolioOrders: [] };
|
||||
}
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
@ -1149,26 +1301,55 @@ export class PortfolioService {
|
||||
)
|
||||
}));
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
userCurrency
|
||||
);
|
||||
portfolioCalculator.computeTransactionPoints(portfolioOrders);
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
return {
|
||||
transactionPoints: portfolioCalculator.getTransactionPoints(),
|
||||
orders
|
||||
orders,
|
||||
portfolioOrders,
|
||||
transactionPoints: portfolioCalculator.getTransactionPoints()
|
||||
};
|
||||
}
|
||||
|
||||
private async getValueOfAccounts(
|
||||
orders: OrderWithAccount[],
|
||||
portfolioItemsNow: { [p: string]: TimelinePosition },
|
||||
userCurrency: string,
|
||||
userId: string
|
||||
) {
|
||||
private async getValueOfAccounts({
|
||||
filters = [],
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
orders: OrderWithAccount[];
|
||||
portfolioItemsNow: { [p: string]: TimelinePosition };
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
|
||||
const currentAccounts = await this.accountService.getAccounts(userId);
|
||||
let currentAccounts = [];
|
||||
|
||||
if (filters.length === 0) {
|
||||
currentAccounts = await this.accountService.getAccounts(userId);
|
||||
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
|
||||
currentAccounts = await this.accountService.accounts({
|
||||
where: { id: filters[0].id }
|
||||
});
|
||||
} else {
|
||||
const accountIds = uniq(
|
||||
orders.map(({ accountId }) => {
|
||||
return accountId;
|
||||
})
|
||||
);
|
||||
|
||||
currentAccounts = await this.accountService.accounts({
|
||||
where: { id: { in: accountIds } }
|
||||
});
|
||||
}
|
||||
|
||||
for (const account of currentAccounts) {
|
||||
const ordersByAccount = orders.filter(({ accountId }) => {
|
||||
@ -1178,34 +1359,47 @@ export class PortfolioService {
|
||||
accounts[account.id] = {
|
||||
balance: account.balance,
|
||||
currency: account.currency,
|
||||
current: account.balance,
|
||||
current: this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
userCurrency
|
||||
),
|
||||
name: account.name,
|
||||
original: account.balance
|
||||
original: this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
|
||||
for (const order of ordersByAccount) {
|
||||
let currentValueOfSymbol =
|
||||
let currentValueOfSymbolInBaseCurrency =
|
||||
order.quantity *
|
||||
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') {
|
||||
currentValueOfSymbol *= -1;
|
||||
originalValueOfSymbol *= -1;
|
||||
currentValueOfSymbolInBaseCurrency *= -1;
|
||||
originalValueOfSymbolInBaseCurrency *= -1;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
||||
currentValueOfSymbol;
|
||||
currentValueOfSymbolInBaseCurrency;
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
||||
originalValueOfSymbol;
|
||||
originalValueOfSymbolInBaseCurrency;
|
||||
} else {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||
balance: 0,
|
||||
currency: order.Account?.currency,
|
||||
current: currentValueOfSymbol,
|
||||
current: currentValueOfSymbolInBaseCurrency,
|
||||
name: account.name,
|
||||
original: originalValueOfSymbol
|
||||
original: originalValueOfSymbolInBaseCurrency
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import { RedisCacheService } from './redis-cache.service';
|
||||
useFactory: async (configurationService: ConfigurationService) => ({
|
||||
host: configurationService.get('REDIS_HOST'),
|
||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||
password: configurationService.get('REDIS_PASSWORD'),
|
||||
port: configurationService.get('REDIS_PORT'),
|
||||
store: redisStore,
|
||||
ttl: configurationService.get('CACHE_TTL')
|
||||
|
@ -45,7 +45,7 @@ export class SubscriptionService {
|
||||
payment_method_types: ['card'],
|
||||
success_url: `${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
|
||||
)}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
|
||||
};
|
||||
|
||||
if (couponId) {
|
||||
|
@ -1,9 +1,7 @@
|
||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface SymbolItem {
|
||||
export interface SymbolItem extends UniqueAsset {
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
historicalData: HistoricalDataItem[];
|
||||
marketPrice: number;
|
||||
}
|
||||
|
@ -46,7 +46,6 @@ export class SymbolController {
|
||||
* Must be after /lookup
|
||||
*/
|
||||
@Get(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getSymbolData(
|
||||
|
@ -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 {
|
||||
IDataGatheringItem,
|
||||
@ -6,6 +5,7 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { format, subDays } from 'date-fns';
|
||||
@ -55,7 +55,8 @@ export class SymbolService {
|
||||
currency,
|
||||
historicalData,
|
||||
marketPrice,
|
||||
dataSource: dataGatheringItem.dataSource
|
||||
dataSource: dataGatheringItem.dataSource,
|
||||
symbol: dataGatheringItem.symbol
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,4 +0,0 @@
|
||||
export interface Access {
|
||||
alias?: string;
|
||||
id: string;
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
export interface UserSettings {
|
||||
emergencyFund?: number;
|
||||
isNewCalculationEngine?: boolean;
|
||||
locale?: string;
|
||||
isRestrictedView?: boolean;
|
||||
}
|
||||
|
@ -1,15 +1,19 @@
|
||||
import { IsBoolean, IsNumber, IsOptional } from 'class-validator';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingDto {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
emergencyFund?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isNewCalculationEngine?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isRestrictedView?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
locale?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
savingsRate?: number;
|
||||
}
|
||||
|
@ -2,17 +2,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
hasPermission,
|
||||
hasRole,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
@ -37,7 +34,7 @@ import { UserService } from './user.service';
|
||||
export class UserController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private jwtService: JwtService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
@ -63,8 +60,13 @@ export class UserController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getUser(@Param('id') id: string): Promise<User> {
|
||||
return this.userService.getUser(this.request.user);
|
||||
public async getUser(
|
||||
@Headers('accept-language') acceptLanguage: string
|
||||
): Promise<User> {
|
||||
return this.userService.getUser(
|
||||
this.request.user,
|
||||
acceptLanguage?.split(',')?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ -118,7 +120,7 @@ export class UserController {
|
||||
};
|
||||
|
||||
for (const key in userSettings) {
|
||||
if (userSettings[key] === false) {
|
||||
if (userSettings[key] === false || userSettings[key] === null) {
|
||||
delete userSettings[key];
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.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 { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -19,7 +20,8 @@ import { UserService } from './user.service';
|
||||
}),
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
SubscriptionModule
|
||||
SubscriptionModule,
|
||||
TagModule
|
||||
],
|
||||
providers: [UserService]
|
||||
})
|
||||
|
@ -2,20 +2,17 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
baseCurrency,
|
||||
locale
|
||||
} from '@ghostfolio/common/config';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasRole,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Role, User, ViewMode } from '@prisma/client';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UserSettings } from './interfaces/user-settings.interface';
|
||||
@ -26,21 +23,22 @@ const crypto = require('crypto');
|
||||
export class UserService {
|
||||
public static DEFAULT_CURRENCY = 'USD';
|
||||
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly tagService: TagService
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public async getUser({
|
||||
Account,
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
Settings,
|
||||
subscription
|
||||
}: UserWithSettings): Promise<IUser> {
|
||||
public async getUser(
|
||||
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
||||
aLocale = locale
|
||||
): Promise<IUser> {
|
||||
const access = await this.prismaService.access.findMany({
|
||||
include: {
|
||||
User: true
|
||||
@ -48,12 +46,20 @@ export class UserService {
|
||||
orderBy: { User: { alias: 'asc' } },
|
||||
where: { GranteeUser: { id } }
|
||||
});
|
||||
let tags = await this.tagService.getByUser(id);
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
subscription.type === 'Basic'
|
||||
) {
|
||||
tags = [];
|
||||
}
|
||||
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
subscription,
|
||||
tags,
|
||||
access: access.map((accessItem) => {
|
||||
return {
|
||||
alias: accessItem.User.alias,
|
||||
@ -63,8 +69,8 @@ export class UserService {
|
||||
accounts: Account,
|
||||
settings: {
|
||||
...(<UserSettings>Settings.settings),
|
||||
locale,
|
||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
|
||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
||||
}
|
||||
};
|
||||
@ -89,17 +95,63 @@ export class UserService {
|
||||
public async user(
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||
): 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 },
|
||||
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_FEAR_AND_GREED_INDEX')) {
|
||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
user.subscription =
|
||||
this.subscriptionService.getSubscription(Subscription);
|
||||
}
|
||||
|
||||
let currentPermissions = getPermissions(user.role);
|
||||
|
||||
if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
@ -122,36 +174,10 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
user.permissions = currentPermissions;
|
||||
|
||||
if (userFromDatabase?.Settings) {
|
||||
if (!userFromDatabase.Settings.currency) {
|
||||
// Set default currency if needed
|
||||
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
|
||||
}
|
||||
} else if (userFromDatabase) {
|
||||
// Set default settings if needed
|
||||
userFromDatabase.Settings = {
|
||||
currency: UserService.DEFAULT_CURRENCY,
|
||||
settings: null,
|
||||
updatedAt: new Date(),
|
||||
userId: userFromDatabase?.id,
|
||||
viewMode: ViewMode.DEFAULT
|
||||
};
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
user.subscription = this.subscriptionService.getSubscription(
|
||||
userFromDatabase?.Subscription
|
||||
);
|
||||
|
||||
if (user.subscription.type === SubscriptionType.Basic) {
|
||||
user.permissions = user.permissions.filter((permission) => {
|
||||
return permission !== permissions.updateViewMode;
|
||||
});
|
||||
user.Settings.viewMode = ViewMode.ZEN;
|
||||
}
|
||||
}
|
||||
user.Account = sortBy(user.Account, (account) => {
|
||||
return account.name;
|
||||
});
|
||||
user.permissions = currentPermissions.sort();
|
||||
|
||||
return user;
|
||||
}
|
||||
@ -190,14 +216,14 @@ export class UserService {
|
||||
...data,
|
||||
Account: {
|
||||
create: {
|
||||
currency: baseCurrency,
|
||||
currency: this.baseCurrency,
|
||||
isDefault: true,
|
||||
name: 'Default Account'
|
||||
}
|
||||
},
|
||||
Settings: {
|
||||
create: {
|
||||
currency: baseCurrency
|
||||
currency: this.baseCurrency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
7793
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
Normal file
7793
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
Normal file
File diff suppressed because it is too large
Load Diff
7
apps/api/src/assets/cryptocurrencies/custom.json
Normal file
7
apps/api/src/assets/cryptocurrencies/custom.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"LUNA1": "Terra",
|
||||
"LUNA2": "Terra",
|
||||
"SGB1": "Songbird",
|
||||
"UNI1": "Uniswap",
|
||||
"UST": "TerraUSD"
|
||||
}
|
@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
@ -7,8 +7,11 @@ import { environment } from './environments/environment';
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors();
|
||||
const globalPrefix = 'api';
|
||||
app.setGlobalPrefix(globalPrefix);
|
||||
app.enableVersioning({
|
||||
defaultVersion: '1',
|
||||
type: VersioningType.URI
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
forbidNonWhitelisted: true,
|
||||
@ -17,10 +20,11 @@ async function bootstrap() {
|
||||
})
|
||||
);
|
||||
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
const port = process.env.PORT || 3333;
|
||||
await app.listen(port, () => {
|
||||
await app.listen(port, host, () => {
|
||||
logLogo();
|
||||
Logger.log(`Listening at http://localhost:${port}`);
|
||||
Logger.log(`Listening at http://${host}:${port}`);
|
||||
Logger.log('');
|
||||
});
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -12,9 +12,10 @@ export class ConfigurationService {
|
||||
this.environmentConfiguration = cleanEnv(process.env, {
|
||||
ACCESS_TOKEN_SALT: str(),
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
BASE_CURRENCY: str({ default: 'USD' }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||
DATA_SOURCES: json({ default: [DataSource.YAHOO] }),
|
||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: 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_SUBSCRIPTION: bool({ default: false }),
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
||||
EOD_HISTORICAL_DATA_API_KEY: str({ default: '' }),
|
||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||
GOOGLE_SHEETS_ID: str({ default: '' }),
|
||||
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||
HOST: host({ default: '0.0.0.0' }),
|
||||
JWT_SECRET_KEY: str({}),
|
||||
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
PORT: port({ default: 3333 }),
|
||||
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 }),
|
||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||
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 { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
@ -13,8 +17,8 @@ export class CronService {
|
||||
private readonly twitterBotService: TwitterBotService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
public async runEveryMinute() {
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
public async runEveryHour() {
|
||||
await this.dataGatheringService.gather7Days();
|
||||
}
|
||||
|
||||
@ -30,6 +34,17 @@ export class CronService {
|
||||
|
||||
@Cron(CronExpression.EVERY_WEEKEND)
|
||||
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';
|
||||
|
||||
const cryptocurrencies = require('cryptocurrencies');
|
||||
|
||||
const customCryptocurrencies = require('./custom-cryptocurrencies.json');
|
||||
const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json');
|
||||
const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json');
|
||||
|
||||
@Injectable()
|
||||
export class CryptocurrencyService {
|
||||
@ -18,7 +17,7 @@ export class CryptocurrencyService {
|
||||
private getCryptocurrencies() {
|
||||
if (!this.combinedCryptocurrencies) {
|
||||
this.combinedCryptocurrencies = [
|
||||
...cryptocurrencies.symbols(),
|
||||
...Object.keys(cryptocurrencies),
|
||||
...Object.keys(customCryptocurrencies)
|
||||
];
|
||||
}
|
||||
|
@ -1,12 +0,0 @@
|
||||
{
|
||||
"1INCH": "1inch",
|
||||
"ALGO": "Algorand",
|
||||
"ATOM": "Cosmos",
|
||||
"AVAX": "Avalanche",
|
||||
"DOT": "Polkadot",
|
||||
"MATIC": "Polygon",
|
||||
"MINA": "Mina Protocol",
|
||||
"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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 ms from 'ms';
|
||||
|
||||
import { DataGatheringProcessor } from './data-gathering.processor';
|
||||
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
||||
import { MarketDataModule } from './market-data.module';
|
||||
import { SymbolProfileModule } from './symbol-profile.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
limiter: {
|
||||
duration: ms('5 seconds'),
|
||||
max: 1
|
||||
},
|
||||
name: DATA_GATHERING_QUEUE
|
||||
}),
|
||||
ConfigurationModule,
|
||||
DataEnhancerModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [DataGatheringService],
|
||||
exports: [DataEnhancerModule, DataGatheringService]
|
||||
providers: [DataGatheringProcessor, DataGatheringService],
|
||||
exports: [BullModule, DataEnhancerModule, DataGatheringService]
|
||||
})
|
||||
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 {
|
||||
PROPERTY_LAST_DATA_GATHERING,
|
||||
PROPERTY_LOCKED_DATA_GATHERING
|
||||
DATA_GATHERING_QUEUE,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||
QUEUE_JOB_STATUS_LIST
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
format,
|
||||
getDate,
|
||||
getMonth,
|
||||
getYear,
|
||||
isBefore,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import { JobOptions, Queue } from 'bull';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DataGatheringService {
|
||||
private dataGatheringProgress: number;
|
||||
|
||||
public constructor(
|
||||
@Inject('DataEnhancers')
|
||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||
@InjectQueue(DATA_GATHERING_QUEUE)
|
||||
private readonly dataGatheringQueue: Queue,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async gather7Days() {
|
||||
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
public async addJobToQueue(name: string, data: any, options?: JobOptions) {
|
||||
const hasJob = await this.hasJob(name, data);
|
||||
|
||||
if (hasJob) {
|
||||
Logger.log(
|
||||
'7d data gathering has been completed.',
|
||||
`Job ${name} with data ${JSON.stringify(data)} already exists.`,
|
||||
'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() {
|
||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
const dataGatheringItems = await this.getSymbolsMax();
|
||||
await this.gatherSymbols(dataGatheringItems);
|
||||
}
|
||||
|
||||
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
|
||||
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
|
||||
return (
|
||||
dataGatheringItem.dataSource === dataSource &&
|
||||
dataGatheringItem.symbol === symbol
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
await this.gatherSymbols(symbols);
|
||||
}
|
||||
|
||||
public async gatherSymbolForDate({
|
||||
@ -226,31 +107,24 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
||||
Logger.log(
|
||||
'Profile data gathering has been started.',
|
||||
'DataGatheringService'
|
||||
);
|
||||
console.time('data-gathering-profile');
|
||||
public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
|
||||
let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
|
||||
return dataGatheringItem.dataSource !== 'MANUAL';
|
||||
});
|
||||
|
||||
let dataGatheringItems = aDataGatheringItems?.filter(
|
||||
(dataGatheringItem) => {
|
||||
return dataGatheringItem.dataSource !== 'MANUAL';
|
||||
}
|
||||
);
|
||||
|
||||
if (!dataGatheringItems) {
|
||||
dataGatheringItems = await this.getSymbolsProfileData();
|
||||
if (!uniqueAssets) {
|
||||
uniqueAssets = await this.getUniqueAssets();
|
||||
}
|
||||
|
||||
const assetProfiles = await this.dataProviderService.getAssetProfiles(
|
||||
dataGatheringItems
|
||||
);
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
dataGatheringItems.map(({ symbol }) => {
|
||||
return symbol;
|
||||
})
|
||||
uniqueAssets
|
||||
);
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesBySymbols(
|
||||
uniqueAssets.map(({ symbol }) => {
|
||||
return symbol;
|
||||
})
|
||||
);
|
||||
|
||||
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
|
||||
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
||||
@ -322,136 +196,31 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
console.timeEnd('data-gathering-profile');
|
||||
}
|
||||
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
let hasError = false;
|
||||
let symbolCounter = 0;
|
||||
|
||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||
if (dataSource === 'MANUAL') {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
|
||||
|
||||
try {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[{ dataSource, symbol }],
|
||||
await this.addJobToQueue(
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
date,
|
||||
new Date()
|
||||
);
|
||||
|
||||
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;
|
||||
symbol
|
||||
},
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
|
||||
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[]> {
|
||||
@ -501,17 +270,25 @@ export class DataGatheringService {
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
public async reset() {
|
||||
Logger.log('Data gathering has been reset.', 'DataGatheringService');
|
||||
|
||||
await this.prismaService.property.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ key: PROPERTY_LAST_DATA_GATHERING },
|
||||
{ key: PROPERTY_LOCKED_DATA_GATHERING }
|
||||
]
|
||||
}
|
||||
public async getUniqueAssets(): Promise<UniqueAsset[]> {
|
||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }]
|
||||
});
|
||||
|
||||
return symbolProfiles
|
||||
.filter(({ dataSource }) => {
|
||||
return (
|
||||
dataSource !== DataSource.GHOSTFOLIO &&
|
||||
dataSource !== DataSource.MANUAL &&
|
||||
dataSource !== DataSource.RAKUTEN
|
||||
);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
@ -537,6 +314,7 @@ export class DataGatheringService {
|
||||
await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
where: {
|
||||
date: { gt: startDate }
|
||||
}
|
||||
@ -576,36 +354,17 @@ export class DataGatheringService {
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }]
|
||||
});
|
||||
|
||||
return symbolProfiles
|
||||
.filter((symbolProfile) => {
|
||||
return (
|
||||
symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
|
||||
symbolProfile.dataSource !== DataSource.MANUAL &&
|
||||
symbolProfile.dataSource !== DataSource.RAKUTEN
|
||||
);
|
||||
private async hasJob(name: string, data: any) {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(
|
||||
QUEUE_JOB_STATUS_LIST.filter((status) => {
|
||||
return status !== 'completed';
|
||||
})
|
||||
.map((symbolProfile) => {
|
||||
return {
|
||||
dataSource: symbolProfile.dataSource,
|
||||
symbol: symbolProfile.symbol
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private async isDataGatheringNeeded() {
|
||||
const lastDataGathering = await this.getLastDataGathering();
|
||||
|
||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
||||
return jobs.some((job) => {
|
||||
return (
|
||||
job.name === name && JSON.stringify(job.data) === JSON.stringify(data)
|
||||
);
|
||||
});
|
||||
|
||||
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 { Injectable, Logger } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
@ -76,9 +76,12 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'AlphaVantageService');
|
||||
|
||||
return {};
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,7 +32,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
return response;
|
||||
}
|
||||
|
||||
const holdings = await getJSON(
|
||||
const result = await getJSON(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
|
||||
).catch(() => {
|
||||
return getJSON(
|
||||
@ -42,12 +42,17 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
);
|
||||
});
|
||||
|
||||
if (result.weight < 0.95) {
|
||||
// Skip if data is inaccurate
|
||||
return response;
|
||||
}
|
||||
|
||||
if (
|
||||
!response.countries ||
|
||||
(response.countries as unknown as Country[]).length === 0
|
||||
) {
|
||||
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;
|
||||
|
||||
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 = [];
|
||||
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
|
||||
for (const [name, value] of Object.entries<any>(result.sectors)) {
|
||||
response.sectors.push({
|
||||
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
||||
weight: value.weight
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
@ -9,7 +11,6 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
||||
import { DataProviderService } from './data-provider.service';
|
||||
|
||||
@Module({
|
||||
@ -22,6 +23,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
DataProviderService,
|
||||
EodHistoricalDataService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
@ -30,6 +32,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
{
|
||||
inject: [
|
||||
AlphaVantageService,
|
||||
EodHistoricalDataService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
@ -39,6 +42,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
provide: 'DataProviderInterfaces',
|
||||
useFactory: (
|
||||
alphaVantageService,
|
||||
eodHistoricalDataService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
@ -46,6 +50,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
eodHistoricalDataService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
|
@ -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 {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.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 { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { addDays, format, isBefore } from 'date-fns';
|
||||
|
||||
@ -47,9 +46,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
try {
|
||||
const symbol = aSymbol;
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||
[symbol]
|
||||
);
|
||||
const [symbolProfile] =
|
||||
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
|
||||
const { defaultMarketPrice, selector, url } =
|
||||
symbolProfile.scraperConfiguration;
|
||||
|
||||
@ -89,10 +87,13 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
}
|
||||
};
|
||||
} 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 {
|
||||
@ -109,9 +110,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
try {
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
aSymbols
|
||||
);
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
|
||||
|
||||
const marketData = await this.prismaService.marketData.findMany({
|
||||
distinct: ['symbol'],
|
||||
@ -133,7 +133,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
marketPrice: marketData.find((marketDataItem) => {
|
||||
return marketDataItem.symbol === symbolProfile.symbol;
|
||||
}).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 {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
@ -72,10 +71,13 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
[symbol]: historicalData
|
||||
};
|
||||
} 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 {
|
||||
@ -92,9 +94,8 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
try {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
aSymbols
|
||||
);
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
|
||||
|
||||
const sheet = await this.getSheet({
|
||||
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
|
||||
@ -114,7 +115,7 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
return symbolProfile.symbol === symbol;
|
||||
})?.currency,
|
||||
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 {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
@ -12,13 +11,11 @@ import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class RakutenRapidApiService implements DataProviderInterface {
|
||||
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
@ -93,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 {};
|
||||
}
|
||||
@ -120,7 +124,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
currency: undefined,
|
||||
dataSource: this.getName(),
|
||||
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 { YahooFinanceService } from './yahoo-finance.service';
|
||||
@ -25,13 +26,18 @@ jest.mock(
|
||||
);
|
||||
|
||||
describe('YahooFinanceService', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let cryptocurrencyService: CryptocurrencyService;
|
||||
let yahooFinanceService: YahooFinanceService;
|
||||
|
||||
beforeAll(async () => {
|
||||
configurationService = new ConfigurationService();
|
||||
cryptocurrencyService = new CryptocurrencyService();
|
||||
|
||||
yahooFinanceService = new YahooFinanceService(cryptocurrencyService);
|
||||
yahooFinanceService = new YahooFinanceService(
|
||||
configurationService,
|
||||
cryptocurrencyService
|
||||
);
|
||||
});
|
||||
|
||||
it('convertFromYahooFinanceSymbol', async () => {
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -16,7 +15,6 @@ import {
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import Big from 'big.js';
|
||||
import { countries } from 'countries-list';
|
||||
import { addDays, format, isSameDay } from 'date-fns';
|
||||
@ -25,21 +23,29 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
|
||||
|
||||
@Injectable()
|
||||
export class YahooFinanceService implements DataProviderInterface {
|
||||
private readonly yahooFinanceHostname = 'https://query1.finance.yahoo.com';
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly cryptocurrencyService: CryptocurrencyService
|
||||
) {}
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||
const symbol = aYahooFinanceSymbol.replace(
|
||||
new RegExp(`-${baseCurrency}$`),
|
||||
baseCurrency
|
||||
let symbol = aYahooFinanceSymbol.replace(
|
||||
new RegExp(`-${this.baseCurrency}$`),
|
||||
this.baseCurrency
|
||||
);
|
||||
|
||||
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
|
||||
symbol = `${this.baseCurrency}${symbol}`;
|
||||
}
|
||||
|
||||
return symbol.replace('=X', '');
|
||||
}
|
||||
|
||||
@ -51,12 +57,15 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
* DOGEUSD -> DOGE-USD
|
||||
*/
|
||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||
if (aSymbol.includes(baseCurrency) && aSymbol.length >= 6) {
|
||||
if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) {
|
||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||
return `${aSymbol}=X`;
|
||||
} else if (
|
||||
this.cryptocurrencyService.isCryptocurrency(
|
||||
aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
||||
aSymbol.replace(
|
||||
new RegExp(`-${this.baseCurrency}$`),
|
||||
this.baseCurrency
|
||||
)
|
||||
)
|
||||
) {
|
||||
// Add a dash before the last three characters
|
||||
@ -64,8 +73,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
// DOGEUSD -> DOGE-USD
|
||||
// SOL1USD -> SOL1-USD
|
||||
return aSymbol.replace(
|
||||
new RegExp(`-?${baseCurrency}$`),
|
||||
`-${baseCurrency}`
|
||||
new RegExp(`-?${this.baseCurrency}$`),
|
||||
`-${this.baseCurrency}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -92,8 +101,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
response.assetSubClass = assetSubClass;
|
||||
response.currency = assetProfile.price.currency;
|
||||
response.dataSource = this.getName();
|
||||
response.name =
|
||||
assetProfile.price.longName || assetProfile.price.shortName || symbol;
|
||||
response.name = this.formatName({
|
||||
longName: assetProfile.price.longName,
|
||||
quoteType: assetProfile.price.quoteType,
|
||||
shortName: assetProfile.price.shortName,
|
||||
symbol: assetProfile.price.symbol
|
||||
});
|
||||
response.symbol = aSymbol;
|
||||
|
||||
if (
|
||||
@ -123,7 +136,13 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
if (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;
|
||||
}
|
||||
@ -167,6 +186,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
if (symbol === 'USDGBp') {
|
||||
// Convert GPB to GBp (pence)
|
||||
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)] = {
|
||||
@ -177,12 +199,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
`Skipping yahooFinance2.getHistorical("${aSymbol}"): [${error.name}] ${error.message}`,
|
||||
'YahooFinanceService'
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@ -215,8 +237,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
marketState:
|
||||
quote.marketState === 'REGULAR' ||
|
||||
this.cryptocurrencyService.isCryptocurrency(symbol)
|
||||
? MarketState.open
|
||||
: MarketState.closed,
|
||||
? 'open'
|
||||
: 'closed',
|
||||
marketPrice: quote.regularMarketPrice || 0
|
||||
};
|
||||
|
||||
@ -229,6 +251,18 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
.mul(100)
|
||||
.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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -244,57 +278,63 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
const items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
|
||||
aQuery
|
||||
)}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
|
||||
const searchResult = await get();
|
||||
const searchResult = await yahooFinance.search(aQuery);
|
||||
|
||||
const quotes = searchResult.quotes
|
||||
.filter((quote) => {
|
||||
// filter out undefined symbols
|
||||
// Filter out undefined symbols
|
||||
return quote.symbol;
|
||||
})
|
||||
.filter(({ quoteType, symbol }) => {
|
||||
return (
|
||||
(quoteType === 'CRYPTOCURRENCY' &&
|
||||
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 }) => {
|
||||
if (quoteType === 'CRYPTOCURRENCY') {
|
||||
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
|
||||
// Transactions need to be converted manually to the base currency before
|
||||
return symbol.includes(baseCurrency);
|
||||
return symbol.includes(this.baseCurrency);
|
||||
} else if (quoteType === 'FUTURE') {
|
||||
// Allow GC=F, but not MGC=F
|
||||
return symbol.length === 4;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const marketData = await this.getQuotes(
|
||||
const marketData = await yahooFinance.quote(
|
||||
quotes.map(({ symbol }) => {
|
||||
return symbol;
|
||||
})
|
||||
);
|
||||
|
||||
for (const [symbol, value] of Object.entries(marketData)) {
|
||||
const quote = quotes.find((currentQuote: any) => {
|
||||
return currentQuote.symbol === symbol;
|
||||
for (const marketDataItem of marketData) {
|
||||
const quote = quotes.find((currentQuote) => {
|
||||
return currentQuote.symbol === marketDataItem.symbol;
|
||||
});
|
||||
|
||||
const symbol = this.convertFromYahooFinanceSymbol(
|
||||
marketDataItem.symbol
|
||||
);
|
||||
|
||||
items.push({
|
||||
symbol,
|
||||
currency: value.currency,
|
||||
currency: marketDataItem.currency,
|
||||
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) {
|
||||
@ -304,6 +344,40 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return { items };
|
||||
}
|
||||
|
||||
private formatName({
|
||||
longName,
|
||||
quoteType,
|
||||
shortName,
|
||||
symbol
|
||||
}: {
|
||||
longName: Price['longName'];
|
||||
quoteType: Price['quoteType'];
|
||||
shortName: Price['shortName'];
|
||||
symbol: Price['symbol'];
|
||||
}) {
|
||||
let name = longName;
|
||||
|
||||
if (name) {
|
||||
name = name.replace('iShares ETF (CH) - ', '');
|
||||
name = name.replace('iShares III Public Limited Company - ', '');
|
||||
name = name.replace('iShares VI Public Limited Company - ', '');
|
||||
name = name.replace('iShares VII PLC - ', '');
|
||||
name = name.replace('Multi Units Luxembourg - ', '');
|
||||
name = name.replace('VanEck ETFs N.V. - ', '');
|
||||
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
|
||||
name = name.replace('Vanguard Funds Public Limited Company - ', '');
|
||||
name = name.replace('Vanguard Index Funds - ', '');
|
||||
name = name.replace('Xtrackers (IE) Plc - ', '');
|
||||
}
|
||||
|
||||
if (quoteType === 'FUTURE') {
|
||||
// "Gold Jun 22" -> "Gold"
|
||||
name = shortName?.slice(0, -6);
|
||||
}
|
||||
|
||||
return name || shortName || symbol;
|
||||
}
|
||||
|
||||
private parseAssetClass(aPrice: Price): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
@ -323,6 +397,20 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
case 'etf':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
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;
|
||||
case 'mutualfund':
|
||||
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaModule } from './prisma.module';
|
||||
import { PropertyModule } from './property/property.module';
|
||||
|
||||
@Module({
|
||||
imports: [DataProviderModule, PrismaModule, PropertyModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
PrismaModule,
|
||||
PropertyModule
|
||||
],
|
||||
providers: [ExchangeRateDataService],
|
||||
exports: [ExchangeRateDataService]
|
||||
})
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { isNumber, uniq } from 'lodash';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
@ -11,11 +12,13 @@ import { PropertyService } from './property/property.service';
|
||||
|
||||
@Injectable()
|
||||
export class ExchangeRateDataService {
|
||||
private baseCurrency: string;
|
||||
private currencies: string[] = [];
|
||||
private currencyPairs: IDataGatheringItem[] = [];
|
||||
private exchangeRates: { [currencyPair: string]: number } = {};
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService
|
||||
@ -24,7 +27,7 @@ export class ExchangeRateDataService {
|
||||
}
|
||||
|
||||
public getCurrencies() {
|
||||
return this.currencies?.length > 0 ? this.currencies : [baseCurrency];
|
||||
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
|
||||
}
|
||||
|
||||
public getCurrencyPairs() {
|
||||
@ -32,6 +35,7 @@ export class ExchangeRateDataService {
|
||||
}
|
||||
|
||||
public async initialize() {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
this.currencies = await this.prepareCurrencies();
|
||||
this.currencyPairs = [];
|
||||
this.exchangeRates = {};
|
||||
@ -212,14 +216,14 @@ export class ExchangeRateDataService {
|
||||
private prepareCurrencyPairs(aCurrencies: string[]) {
|
||||
return aCurrencies
|
||||
.filter((currency) => {
|
||||
return currency !== baseCurrency;
|
||||
return currency !== this.baseCurrency;
|
||||
})
|
||||
.map((currency) => {
|
||||
return {
|
||||
currency1: baseCurrency,
|
||||
currency1: this.baseCurrency,
|
||||
currency2: currency,
|
||||
dataSource: this.dataProviderService.getPrimaryDataSource(),
|
||||
symbol: `${baseCurrency}${currency}`
|
||||
symbol: `${this.baseCurrency}${currency}`
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -3,9 +3,10 @@ import { CleanedEnvAccessors } from 'envalid';
|
||||
export interface Environment extends CleanedEnvAccessors {
|
||||
ACCESS_TOKEN_SALT: string;
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
BASE_CURRENCY: string;
|
||||
CACHE_TTL: number;
|
||||
DATA_SOURCE_PRIMARY: string;
|
||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
||||
DATA_SOURCES: string[];
|
||||
ENABLE_FEATURE_BLOG: boolean;
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||
@ -15,17 +16,19 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ENABLE_FEATURE_STATISTICS: boolean;
|
||||
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
||||
EOD_HISTORICAL_DATA_API_KEY: string;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_SECRET: string;
|
||||
GOOGLE_SHEETS_ACCOUNT: string;
|
||||
GOOGLE_SHEETS_ID: string;
|
||||
GOOGLE_SHEETS_PRIVATE_KEY: string;
|
||||
JWT_SECRET_KEY: string;
|
||||
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
MAX_ORDERS_TO_IMPORT: number;
|
||||
PORT: number;
|
||||
RAKUTEN_RAPID_API_KEY: string;
|
||||
REDIS_HOST: string;
|
||||
REDIS_PASSWORD: string;
|
||||
REDIS_PORT: number;
|
||||
ROOT_URL: string;
|
||||
STRIPE_PUBLIC_KEY: string;
|
||||
|
@ -1,18 +1,12 @@
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { MarketState } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Account,
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
SymbolProfile,
|
||||
Type as TypeOfOrder
|
||||
} from '@prisma/client';
|
||||
|
||||
export const MarketState = {
|
||||
closed: 'closed',
|
||||
delayed: 'delayed',
|
||||
open: 'open'
|
||||
};
|
||||
|
||||
export interface IOrder {
|
||||
account: Account;
|
||||
currency: string;
|
||||
@ -39,10 +33,6 @@ export interface IDataProviderResponse {
|
||||
marketState: MarketState;
|
||||
}
|
||||
|
||||
export interface IDataGatheringItem {
|
||||
dataSource: DataSource;
|
||||
export interface IDataGatheringItem extends UniqueAsset {
|
||||
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({
|
||||
dateQuery,
|
||||
symbols
|
||||
|
@ -1,14 +1,21 @@
|
||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import {
|
||||
EnhancedSymbolProfile,
|
||||
ScraperConfiguration,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import {
|
||||
DataSource,
|
||||
Prisma,
|
||||
SymbolProfile,
|
||||
SymbolProfileOverrides
|
||||
} from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
|
||||
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SymbolProfileService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
@ -32,10 +39,40 @@ export class SymbolProfileService {
|
||||
}
|
||||
|
||||
public async getSymbolProfiles(
|
||||
aUniqueAssets: UniqueAsset[]
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
include: { SymbolProfileOverrides: true },
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
dataSource: {
|
||||
in: aUniqueAssets.map(({ dataSource }) => {
|
||||
return dataSource;
|
||||
})
|
||||
},
|
||||
symbol: {
|
||||
in: aUniqueAssets.map(({ symbol }) => {
|
||||
return symbol;
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public async getSymbolProfilesBySymbols(
|
||||
symbols: string[]
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
include: { SymbolProfileOverrides: true },
|
||||
where: {
|
||||
symbol: {
|
||||
in: symbols
|
||||
@ -45,30 +82,66 @@ export class SymbolProfileService {
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
}
|
||||
|
||||
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] {
|
||||
return symbolProfiles.map((symbolProfile) => ({
|
||||
...symbolProfile,
|
||||
countries: this.getCountries(symbolProfile),
|
||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||
sectors: this.getSectors(symbolProfile),
|
||||
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||
}));
|
||||
private getSymbols(
|
||||
symbolProfiles: (SymbolProfile & {
|
||||
SymbolProfileOverrides: SymbolProfileOverrides;
|
||||
})[]
|
||||
): EnhancedSymbolProfile[] {
|
||||
return symbolProfiles.map((symbolProfile) => {
|
||||
const item = {
|
||||
...symbolProfile,
|
||||
countries: this.getCountries(
|
||||
symbolProfile?.countries as unknown as Prisma.JsonArray
|
||||
),
|
||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||
sectors: this.getSectors(symbolProfile),
|
||||
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||
};
|
||||
|
||||
if (item.SymbolProfileOverrides) {
|
||||
item.assetClass =
|
||||
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
||||
item.assetSubClass =
|
||||
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
|
||||
|
||||
if (
|
||||
(item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
|
||||
?.length > 0
|
||||
) {
|
||||
item.countries = this.getCountries(
|
||||
item.SymbolProfileOverrides
|
||||
?.countries as unknown as Prisma.JsonArray
|
||||
);
|
||||
}
|
||||
|
||||
item.name = item.SymbolProfileOverrides?.name ?? item.name;
|
||||
item.sectors =
|
||||
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
|
||||
item.sectors;
|
||||
|
||||
delete item.SymbolProfileOverrides;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
private getCountries(symbolProfile: SymbolProfile): Country[] {
|
||||
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map(
|
||||
(country) => {
|
||||
const { code, weight } = country as Prisma.JsonObject;
|
||||
private getCountries(aCountries: Prisma.JsonArray = []): Country[] {
|
||||
if (aCountries === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
code: code as string,
|
||||
continent:
|
||||
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||
name: countries[code as string]?.name ?? UNKNOWN_KEY,
|
||||
weight: weight as number
|
||||
};
|
||||
}
|
||||
);
|
||||
return aCountries.map((country: Pick<Country, 'code' | 'weight'>) => {
|
||||
const { code, weight } = country;
|
||||
|
||||
return {
|
||||
code,
|
||||
weight,
|
||||
continent:
|
||||
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||
name: countries[code as string]?.name ?? UNKNOWN_KEY
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getScraperConfiguration(
|
||||
|
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 {}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user