Compare commits
236 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
1a8fc5757a | |||
b4848be914 | |||
2b4319454d | |||
e2faaf6faa | |||
86a1589834 | |||
9f67993c03 | |||
32fb3551dc | |||
30411b1502 | |||
eb0444603b | |||
6e582fe505 | |||
402d73a12c | |||
4826a51199 | |||
5356bf568e | |||
d8da574ae4 | |||
e769fabbae | |||
5a369f29d4 | |||
122ba9046f | |||
f781eb207c | |||
7b6893b5ed | |||
07799573cb | |||
9cdef6a7cb | |||
0d897bc461 | |||
e4908b51aa | |||
718b0de0a7 | |||
99655604d9 | |||
b602e7690b | |||
7745dafe48 | |||
50184284e1 | |||
f46533107d | |||
c216ab1d76 | |||
86acbf06f4 | |||
3de7d3f60e | |||
63ed227f3f | |||
5bb20f6d5f | |||
b3e58d182a | |||
93d6746739 | |||
e3f8b0cf52 | |||
c02bcd9bd8 | |||
6a4f1c0188 | |||
745ba978a3 | |||
46b91d3c3b | |||
1dd670a7c3 | |||
68d07cc8d4 | |||
02809a529e | |||
fd60569716 | |||
fed771525e | |||
a5771f601d | |||
2a2a5f4da5 | |||
06d5ec9182 | |||
122107c8a1 | |||
ca46a9827a | |||
4ec351369b | |||
dced06ebb5 | |||
baa6a3d0f0 | |||
d3382f0809 | |||
1eb4041837 | |||
5a869a90da | |||
280030ae7f | |||
52e4504de9 | |||
20356f6931 | |||
e0bb2b1c78 | |||
ec806be45f | |||
809ee97f6f | |||
893ca83d3a | |||
23da1bd293 | |||
fa66cd5bce | |||
9344dcd26e | |||
90ad22cccf | |||
dcc7ef89fe | |||
e355847f40 | |||
76f70598e2 | |||
7af5cd244a | |||
86943a5f5b | |||
6eb4eae4a9 | |||
6ac693dd39 | |||
e29f7f8976 | |||
82069da4e2 | |||
07656c6a95 | |||
16f0743353 | |||
9b5ec0c56d | |||
8d2fcc6b42 | |||
e625e55784 | |||
bed3e5aae2 | |||
65bfe52db4 | |||
48b524de5a | |||
67d40333f6 | |||
48f6b8d353 | |||
f369996912 | |||
dc424a86ec | |||
5d8bde5a70 | |||
16360c0c67 | |||
526a6b2030 | |||
5000e9c79b | |||
161cb82820 | |||
fed28f29d1 | |||
8bd9330acc | |||
155c08d665 | |||
b8ad6d6662 | |||
9d6977e3f7 | |||
919b20197f | |||
62885ea890 | |||
035d8ad9eb | |||
9676f96e97 | |||
65e151151b | |||
5d3bbb8f30 | |||
b464fefc57 | |||
bcb7f5f522 | |||
f15b33e950 | |||
ca64492e77 | |||
761376d72d | |||
9c086edffe | |||
585f99e4df | |||
9d907b5eb5 | |||
ba05f5ba30 | |||
3261e3ee59 | |||
5607c6bb52 | |||
1c6050d3e3 | |||
38f2930ec6 | |||
556be61fff | |||
651b4bcff7 | |||
0a8d159f78 | |||
1a4109ebaa | |||
92e502e1c2 | |||
e344c43a5a | |||
d6b78f3457 | |||
9bbb856f66 | |||
d3707bbb87 | |||
7df53896f3 | |||
b2b3fde80e | |||
a83441b3ba | |||
075431d868 | |||
0168c1c4e8 | |||
07de8f87fc | |||
3e16041c16 | |||
5882b7914d | |||
69c9e259b1 | |||
aca37a27f9 | |||
313d2a2f79 | |||
9ac67b0af2 | |||
1e526852a7 | |||
e54638a684 | |||
0179823ad9 | |||
029b7bed9a | |||
635f10e2d0 | |||
cebf879d67 | |||
124bdc028d | |||
d69a69ce18 | |||
15344513ce | |||
b291d9e031 | |||
bee702302f | |||
bb56e09a13 | |||
0873f539c5 | |||
6dcd801d05 | |||
77065dac50 | |||
438484879d | |||
e37a650c70 | |||
6e8c90b3fc | |||
9e1a7fc981 | |||
ff638adf03 | |||
fa44cee781 | |||
db1d474ddf | |||
994275e093 | |||
ee397c8047 | |||
7203939c42 | |||
9725f16c81 | |||
bb8b1e4f43 | |||
9d3610331a | |||
0043b44670 | |||
bbc4e64cb4 | |||
c7f4825499 | |||
8f583709ef | |||
4c30212a72 | |||
cade2f6a5e | |||
3b9a8fabb5 | |||
3435b3a348 | |||
5d39b267ab | |||
ffaaa14dba | |||
c65746d119 | |||
1a6840f1f6 | |||
fb7fb886f6 | |||
127abb8f4e | |||
ed1136999a | |||
9f545e3e2b | |||
1602f976f0 | |||
4bf4c1a8a3 | |||
e78755c280 | |||
7772684413 | |||
955302666e | |||
ddce8cc7f9 | |||
aca0d77e91 | |||
8b9379f5ce | |||
0806d0dc92 | |||
e518bc3779 | |||
eff807dd9a | |||
155bf67f60 | |||
9aefe3747e | |||
0878febded | |||
f953c6ea64 | |||
76dbf78279 | |||
f12866b9ec | |||
83ba5f3d9f | |||
7439c1bf54 | |||
e255b76053 | |||
ed7209fb53 | |||
008a2ab123 | |||
2aedd74480 | |||
11076592d1 | |||
ebee851b23 | |||
7d3f1832b4 | |||
39e6abfc8c |
31
.travis.yml
31
.travis.yml
@ -3,9 +3,28 @@ git:
|
|||||||
depth: false
|
depth: false
|
||||||
node_js:
|
node_js:
|
||||||
- 14
|
- 14
|
||||||
before_script:
|
|
||||||
- yarn
|
services:
|
||||||
script:
|
- docker
|
||||||
- yarn format:check
|
|
||||||
- yarn test
|
cache: yarn
|
||||||
- yarn build:all
|
|
||||||
|
if: (type = pull_request) OR (tag IS present)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
include:
|
||||||
|
- stage: Install dependencies
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn --frozen-lockfile
|
||||||
|
- stage: Check formatting
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn format:check
|
||||||
|
- stage: Execute tests
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn test
|
||||||
|
- stage: Build application
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn build:all
|
||||||
|
- stage: Build and publish docker image
|
||||||
|
if: tag IS present
|
||||||
|
script: ./publish-docker-image.sh
|
||||||
|
581
CHANGELOG.md
581
CHANGELOG.md
@ -5,6 +5,587 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.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
|
||||||
|
|
||||||
|
- Added the calculation for developed vs. emerging markets to the allocations page
|
||||||
|
- Added a hover effect to the page tabs
|
||||||
|
- Extended the feature overview page by _Bonds_ and _Emergency Fund_
|
||||||
|
|
||||||
|
## 1.128.0 - 19.03.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the attribute `defaultMarketPrice` to the scraper configuration to improve the support for bonds
|
||||||
|
- Added a hover effect to the table style
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the user currency of the public page
|
||||||
|
- Fixed an issue of the performance calculation with recent activities in the new calculation engine
|
||||||
|
|
||||||
|
## 1.127.0 - 16.03.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the error handling in the scraper configuration
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the support for multiple symbols of the data source `GHOSTFOLIO`
|
||||||
|
|
||||||
|
## 1.126.0 - 14.03.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for bonds
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Restructured the portfolio summary tab on the home page
|
||||||
|
- Improved the tooltips in the portfolio proportion chart component by introducing multilines
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.125.0 - 12.03.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for an emergency fund
|
||||||
|
- Added the contexts to the logger commands
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `Nx` from version `13.8.1` to `13.8.5`
|
||||||
|
|
||||||
|
## 1.124.0 - 06.03.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for setting a duration in the coupon system
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `ngx-skeleton-loader` from version `2.9.1` to `5.0.0`
|
||||||
|
- Upgraded `prisma` from version `3.9.1` to `3.10.0`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.1.9` to `2.2.0`
|
||||||
|
|
||||||
|
## 1.123.0 - 05.03.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Included data provider errors in the API response
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the redundant attributes (`currency`, `dataSource`, `symbol`) of the activity model
|
||||||
|
- Removed the prefix for symbols with the data source `GHOSTFOLIO`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the account calculations
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.122.0 - 01.03.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for click in the portfolio proportion chart component
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with undefined currencies after creating an activity
|
||||||
|
|
||||||
|
## 1.121.0 - 27.02.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for mutual funds
|
||||||
|
- Added the url to the symbol profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Migrated from `yahoo-finance` to `yahoo-finance2`
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.120.0 - 25.02.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Distinguished the labels _Other_ and _Unknown_ in the portfolio proportion chart component
|
||||||
|
- Improved the portfolio entry page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the _Zen Mode_
|
||||||
|
|
||||||
|
## 1.119.0 - 21.02.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a trial for the subscription
|
||||||
|
|
||||||
|
## 1.118.0 - 20.02.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the calculation of the overall performance percentage in the new calculation engine
|
||||||
|
- Displayed features in features overview page based on permissions
|
||||||
|
- Extended the data points of historical data in the admin control panel
|
||||||
|
|
||||||
|
## 1.117.0 - 19.02.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the countries and sectors charts in the position detail dialog
|
||||||
|
- Distinguished today's data point of historical data in the admin control panel
|
||||||
|
- Restructured the server modules
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the allocations by account for non-unique account names
|
||||||
|
- Added a fallback to the default account if the `accountId` is invalid in the import functionality for activities
|
||||||
|
|
||||||
|
## 1.116.0 - 16.02.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a service to tweet the current _Fear & Greed Index_ (market mood)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the mobile layout of the position detail dialog (countries and sectors charts)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the `maxItems` attribute of the portfolio proportion chart component
|
||||||
|
- Fixed the time in market display of the portfolio summary tab on the home page
|
||||||
|
|
||||||
|
## 1.115.0 - 13.02.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a feature overview page
|
||||||
|
- Added the asset and asset sub class to the position detail dialog
|
||||||
|
- Added the countries and sectors to the position detail dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `angular` from version `13.1.2` to `13.2.3`
|
||||||
|
- Upgraded `Nx` from version `13.4.1` to `13.8.1`
|
||||||
|
- Upgraded `storybook` from version `6.4.9` to `6.4.18`
|
||||||
|
|
||||||
|
## 1.114.1 - 10.02.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the creation of (wealth) items
|
||||||
|
|
||||||
|
## 1.114.0 - 10.02.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for (wealth) items
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.113.0 - 09.02.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the position of the currency column in the accounts table
|
||||||
|
- Improved the position of the currency column in the activities table
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the performance calculation in connection with fees in the new calculation engine
|
||||||
|
|
||||||
|
## 1.112.1 - 06.02.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the creation of the user account (missing access token)
|
||||||
|
|
||||||
|
## 1.112.0 - 06.02.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the export functionality to the position detail dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the export functionality for activities (respect filtering)
|
||||||
|
- Removed the _Admin_ user from the database seeding
|
||||||
|
- Assigned the role `ADMIN` on sign up (only if there is no admin yet)
|
||||||
|
- Upgraded `prisma` from version `3.8.1` to `3.9.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the performance calculation in connection with a sell activity in the new calculation engine
|
||||||
|
- Fixed the horizontal overflow in the accounts table
|
||||||
|
- Fixed the horizontal overflow in the activities table
|
||||||
|
- Fixed the total value of the activities table in the position detail dialog (absolute value)
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.111.0 - 03.02.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for deleting symbol profile data in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Used `dataSource` and `symbol` from `SymbolProfile` instead of the `order` object (in `ExportService` and `PortfolioService`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the symbol selection of the 7d data gathering
|
||||||
|
|
||||||
|
## 1.110.0 - 02.02.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the data source of the _Fear & Greed Index_ (market mood)
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.109.0 - 01.02.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the (optional) `accountId` in the import functionality for activities
|
||||||
|
- Added support for the (optional) `dataSource` in the import functionality for activities
|
||||||
|
- Added support for the data source transformation
|
||||||
|
- Added support for the cryptocurrency _Mina Protocol_ (`MINA-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the form in the create or edit transaction dialog
|
||||||
|
- Improved the consistent use of `symbol` in combination with `dataSource`
|
||||||
|
- Removed the primary data source from the client
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed the unused endpoint `GET api/order/:id`
|
||||||
|
|
||||||
|
## 1.108.0 - 27.01.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the annualized performance in the new calculation engine
|
||||||
|
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 90 days
|
||||||
|
|
||||||
|
## 1.107.0 - 24.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a new calculation engine (experimental)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the styling in the footer row of the activities table
|
||||||
|
|
||||||
|
## 1.106.0 - 23.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the footer row with total fees and total value to the activities table
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the historical data view in the admin control panel
|
||||||
|
- Upgraded _Stripe_ dependencies
|
||||||
|
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the redirection on logout
|
||||||
|
|
||||||
|
## 1.105.0 - 20.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the data provider with grouping by data source and thereby reducing the number of requests
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the unresolved account names in the _X-ray_ section
|
||||||
|
- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
|
||||||
|
|
||||||
|
## 1.104.0 - 16.01.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the fallback to load currencies directly from the data provider
|
||||||
|
- Fixed the missing symbol profile data connection in the import functionality for activities
|
||||||
|
|
||||||
|
## 1.103.0 - 13.01.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added links to the statistics section on the about page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the currency of the value in the position detail dialog
|
||||||
|
|
||||||
|
## 1.102.0 - 11.01.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Start eliminating `dataSource` from activity
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the support for multiple accounts with the same name
|
||||||
|
- Fixed the preselected default account of the create activity dialog
|
||||||
|
|
||||||
|
## 1.101.0 - 08.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `GOOGLE_SHEETS` as a new data source type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Excluded the url pattern of shared portfolios in the `robots.txt` file
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.100.0 - 05.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
|
||||||
|
- Added a blog post
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the routing of the create activity dialog
|
||||||
|
- Fixed the link color in the blog posts
|
||||||
|
|
||||||
|
## 1.99.0 - 01.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Exposed the profile data gathering by symbol as an endpoint
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
|
||||||
|
- Restructured the about page
|
||||||
|
- Start refactoring _transactions_ to _activities_
|
||||||
|
- Refactored the demo user id
|
||||||
|
- Upgraded `angular` from version `13.0.2` to `13.1.1`
|
||||||
|
- Upgraded `chart.js` from version `3.5.0` to `3.7.0`
|
||||||
|
- Upgraded `Nx` from version `13.3.0` to `13.4.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Hid the data provider warning while loading
|
||||||
|
- Fixed an exception with the market state caused by a failed data provider request
|
||||||
|
- Fixed an exception in the portfolio position endpoint
|
||||||
|
- Fixed the reload of the position detail dialog (with query parameters)
|
||||||
|
- Fixed the missing mapping for Russia in the data enhancer for symbol profile data via _Trackinsight_
|
||||||
|
|
||||||
|
## 1.98.0 - 29.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the date range component to the holdings tab
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the statistics section on the about page (users in Slack community)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
|
||||||
|
- Fixed the scrolling issue in the position detail dialog on mobile
|
||||||
|
|
||||||
|
## 1.97.0 - 28.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the transactions to the position detail dialog
|
||||||
|
- Added support for dividend
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.96.0 - 27.12.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Made the data provider warning more discreet
|
||||||
|
- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
|
||||||
|
- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
|
||||||
|
- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
|
||||||
|
- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
|
||||||
|
- Upgraded `prisma` from version `3.6.0` to `3.7.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the file type detection in the import functionality for transactions
|
||||||
|
|
||||||
|
## 1.95.0 - 26.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a warning to the log if the data gathering fails
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Filtered potential `null` currencies
|
||||||
|
- Improved the 7d data gathering optimization for currencies
|
||||||
|
|
||||||
|
## 1.94.0 - 25.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for cryptocurrencies _Cosmos_ (`ATOM-USD`) and _Polkadot_ (`DOT-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 30 days
|
||||||
|
- Made the import functionality for transactions by `csv` files more flexible
|
||||||
|
- Optimized the 7d data gathering (only consider symbols with incomplete market data)
|
||||||
|
- Upgraded `prettier` from version `2.3.2` to `2.5.1`
|
||||||
|
|
||||||
|
## 1.93.0 - 21.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the cryptocurrency _Solana_ (`SOL-USD`)
|
||||||
|
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Converted errors to warnings in portfolio calculator
|
||||||
|
|
||||||
|
## 1.92.0 - 19.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a line chart to the historical data view in the admin control panel
|
||||||
|
- Supported the update of historical data in the admin control panel
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the redirection on logout
|
||||||
|
- Fixed the permission for the system status page
|
||||||
|
|
||||||
|
## 1.91.0 - 18.12.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the redundant all time high and all time low from the performance endpoint
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the symbol conversion from _Yahoo Finance_ including a hyphen
|
||||||
|
- Fixed hidden values (`0`) in the statistics section on the about page
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.90.0 - 14.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the validation in the import functionality for transactions by checking the currency of the data provider service
|
||||||
|
- Added support for cryptocurrency _Uniswap_
|
||||||
|
- Set up pipeline for docker build
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the default transactions import limit
|
||||||
|
- Improved the landing page in dark mode
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `/bin/sh: prisma: not found` in docker build
|
||||||
|
- Added `apk` in `Dockerfile` (`python3 g++ make openssl`)
|
||||||
|
|
||||||
|
## 1.89.0 - 11.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the data gathering by symbol endpoint with an optional date
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `Nx` from version `13.2.2` to `13.3.0`
|
||||||
|
- Upgraded `storybook` from version `6.4.0-rc.3` to `6.4.9`
|
||||||
|
|
||||||
## 1.88.0 - 09.12.2021
|
## 1.88.0 - 09.12.2021
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -12,7 +12,8 @@ COPY ./package.json package.json
|
|||||||
COPY ./yarn.lock yarn.lock
|
COPY ./yarn.lock yarn.lock
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN yarn
|
RUN apk add --no-cache python3 g++ make openssl
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
|
141
README.md
141
README.md
@ -12,7 +12,7 @@
|
|||||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="#contributing">
|
<a href="#contributing">
|
||||||
@ -34,28 +34,20 @@
|
|||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
|
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).
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
Ghostfolio is for you if you are...
|
Ghostfolio is for you if you are...
|
||||||
|
|
||||||
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
|
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
|
||||||
|
|
||||||
- 🏦 pursuing a buy & hold strategy
|
- 🏦 pursuing a buy & hold strategy
|
||||||
|
|
||||||
- 🎯 interested in getting insights of your portfolio composition
|
- 🎯 interested in getting insights of your portfolio composition
|
||||||
|
|
||||||
- 👻 valuing privacy and data ownership
|
- 👻 valuing privacy and data ownership
|
||||||
|
|
||||||
- 🧘 into minimalism
|
- 🧘 into minimalism
|
||||||
|
|
||||||
- 🧺 caring about diversifying your financial resources
|
- 🧺 caring about diversifying your financial resources
|
||||||
|
|
||||||
- 🆓 interested in financial independence
|
- 🆓 interested in financial independence
|
||||||
|
|
||||||
- 🙅 saying no to spreadsheets in 2021
|
- 🙅 saying no to spreadsheets in 2021
|
||||||
|
|
||||||
- 😎 still reading this list
|
- 😎 still reading this list
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@ -65,6 +57,7 @@ Ghostfolio is for you if you are...
|
|||||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||||
- ✅ Various charts
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
|
- ✅ Import and export transactions
|
||||||
- ✅ Dark Mode
|
- ✅ Dark Mode
|
||||||
- ✅ Zen Mode
|
- ✅ Zen Mode
|
||||||
- ✅ Mobile-first design
|
- ✅ Mobile-first design
|
||||||
@ -81,44 +74,59 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||||
|
|
||||||
## Run with Docker
|
## Run with Docker (self-hosting)
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
- A local copy of this Git repository (clone)
|
||||||
|
|
||||||
### Setup Docker Image
|
### a. Run environment
|
||||||
|
|
||||||
Run the following commands to build and start the Docker image:
|
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose-build-local.yml build
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
docker-compose -f docker/docker-compose-build-local.yml up
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup Database
|
#### Setup Database
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
|
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fetch Historical Data
|
### Fetch Historical Data
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
|
|
||||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
### 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 -f docker/docker-compose.yml up -d`
|
||||||
```bash
|
1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
||||||
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@ -127,16 +135,15 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
|
|||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
|
- A local copy of this Git repository (clone)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `cd docker`
|
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 up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
|
||||||
1. Run `cd -` to go back to the project root directory
|
|
||||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||||
1. Start server and client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
@ -155,10 +162,84 @@ Run `yarn start:client`
|
|||||||
|
|
||||||
Run `yarn start:storybook`
|
Run `yarn start:storybook`
|
||||||
|
|
||||||
|
### Migrate Database
|
||||||
|
|
||||||
|
With the following command you can keep your database schema in sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn database:push
|
||||||
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run `yarn test`
|
Run `yarn test`
|
||||||
|
|
||||||
|
## Public API (experimental)
|
||||||
|
|
||||||
|
### 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
|
## 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.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
@ -167,6 +248,6 @@ Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Sl
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2021 [Ghostfolio](https://ghostfol.io)
|
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
10
angular.json
10
angular.json
@ -9,7 +9,7 @@
|
|||||||
"schematics": {},
|
"schematics": {},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@nrwl/node:build",
|
"builder": "@nrwl/node:webpack",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/apps/api",
|
"outputPath": "dist/apps/api",
|
||||||
"main": "apps/api/src/main.ts",
|
"main": "apps/api/src/main.ts",
|
||||||
@ -33,7 +33,7 @@
|
|||||||
"outputs": ["{options.outputPath}"]
|
"outputs": ["{options.outputPath}"]
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@nrwl/node:execute",
|
"builder": "@nrwl/node:node",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "api:build"
|
"buildTarget": "api:build"
|
||||||
}
|
}
|
||||||
@ -264,7 +264,8 @@
|
|||||||
"port": 4400,
|
"port": 4400,
|
||||||
"config": {
|
"config": {
|
||||||
"configFolder": "libs/ui/.storybook"
|
"configFolder": "libs/ui/.storybook"
|
||||||
}
|
},
|
||||||
|
"projectBuildConfig": "ui:build-storybook"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"ci": {
|
"ci": {
|
||||||
@ -280,7 +281,8 @@
|
|||||||
"outputPath": "dist/storybook/ui",
|
"outputPath": "dist/storybook/ui",
|
||||||
"config": {
|
"config": {
|
||||||
"configFolder": "libs/ui/.storybook"
|
"configFolder": "libs/ui/.storybook"
|
||||||
}
|
},
|
||||||
|
"projectBuildConfig": "ui:build-storybook"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"ci": {
|
"ci": {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccessController } from './access.controller';
|
import { AccessController } from './access.controller';
|
||||||
@ -7,7 +7,7 @@ import { AccessService } from './access.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
exports: [AccessService],
|
exports: [AccessService],
|
||||||
imports: [],
|
imports: [PrismaModule],
|
||||||
providers: [AccessService, PrismaService]
|
providers: [AccessService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import {
|
||||||
nullifyValuesInObject,
|
nullifyValuesInObject,
|
||||||
@ -35,7 +35,7 @@ export class AccountController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
@ -91,10 +91,9 @@ export class AccountController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations =
|
let accountsWithAggregations = await this.portfolioServiceStrategy
|
||||||
await this.portfolioService.getAccountsWithAggregations(
|
.get()
|
||||||
impersonationUserId || this.request.user.id
|
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
@ -102,16 +101,18 @@ export class AccountController {
|
|||||||
) {
|
) {
|
||||||
accountsWithAggregations = {
|
accountsWithAggregations = {
|
||||||
...nullifyValuesInObject(accountsWithAggregations, [
|
...nullifyValuesInObject(accountsWithAggregations, [
|
||||||
'totalBalance',
|
'totalBalanceInBaseCurrency',
|
||||||
'totalValue'
|
'totalValueInBaseCurrency'
|
||||||
]),
|
]),
|
||||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||||
'balance',
|
'balance',
|
||||||
|
'balanceInBaseCurrency',
|
||||||
'convertedBalance',
|
'convertedBalance',
|
||||||
'fee',
|
'fee',
|
||||||
'quantity',
|
'quantity',
|
||||||
'unitPrice',
|
'unitPrice',
|
||||||
'value'
|
'value',
|
||||||
|
'valueInBaseCurrency'
|
||||||
])
|
])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import { AccountService } from './account.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AccountController],
|
controllers: [AccountController],
|
||||||
|
exports: [AccountService],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
@ -2,6 +2,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
|
|
||||||
@ -105,21 +106,26 @@ export class AccountService {
|
|||||||
aUserId: string,
|
aUserId: string,
|
||||||
aCurrency: string
|
aCurrency: string
|
||||||
): Promise<CashDetails> {
|
): Promise<CashDetails> {
|
||||||
let totalCashBalance = 0;
|
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||||
|
|
||||||
const accounts = await this.accounts({
|
const accounts = await this.accounts({
|
||||||
where: { userId: aUserId }
|
where: { userId: aUserId }
|
||||||
});
|
});
|
||||||
|
|
||||||
accounts.forEach((account) => {
|
for (const account of accounts) {
|
||||||
totalCashBalance += this.exchangeRateDataService.toCurrency(
|
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
|
||||||
account.balance,
|
this.exchangeRateDataService.toCurrency(
|
||||||
account.currency,
|
account.balance,
|
||||||
aCurrency
|
account.currency,
|
||||||
|
aCurrency
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
return { accounts, balance: totalCashBalance };
|
return {
|
||||||
|
accounts,
|
||||||
|
balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateAccount(
|
public async updateAccount(
|
||||||
|
@ -2,5 +2,5 @@ import { Account } from '@prisma/client';
|
|||||||
|
|
||||||
export interface CashDetails {
|
export interface CashDetails {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
balance: number;
|
balanceInBaseCurrency: number;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -11,6 +11,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
@ -21,16 +22,19 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
import { isDate } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -73,6 +77,49 @@ export class AdminController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather/profile-data')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherProfileData(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherProfileData();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherProfileDataForSymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherSymbol(
|
public async gatherSymbol(
|
||||||
@ -96,9 +143,13 @@ export class AdminController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data')
|
@Post('gather/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherSymbolForDate(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('dateString') dateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<MarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
this.request.user.permissions,
|
this.request.user.permissions,
|
||||||
@ -111,9 +162,20 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData();
|
const date = new Date(dateString);
|
||||||
|
|
||||||
return;
|
if (!isDate(date)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataGatheringService.gatherSymbolForDate({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@ -134,10 +196,11 @@ export class AdminController {
|
|||||||
return this.adminService.getMarketData();
|
return this.adminService.getMarketData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketDataBySymbol(
|
public async getMarketDataBySymbol(
|
||||||
@Param('symbol') symbol
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
): Promise<AdminMarketDataDetails> {
|
): Promise<AdminMarketDataDetails> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
@ -151,7 +214,61 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.adminService.getMarketDataBySymbol(symbol);
|
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async update(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('dateString') dateString: string,
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Body() data: UpdateMarketDataDto
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
return this.marketDataService.updateMarketData({
|
||||||
|
data: { ...data, dataSource },
|
||||||
|
where: {
|
||||||
|
date_symbol: {
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteProfileData(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
|
@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
|||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
@ -20,7 +21,8 @@ import { AdminService } from './admin.service';
|
|||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
SubscriptionModule
|
SubscriptionModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [AdminService],
|
providers: [AdminService],
|
||||||
|
@ -5,14 +5,17 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
|||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails
|
AdminMarketDataDetails,
|
||||||
|
AdminMarketDataItem,
|
||||||
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Property } from '@prisma/client';
|
import { DataSource, Property } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -24,9 +27,15 @@ export class AdminService {
|
|||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
return {
|
return {
|
||||||
dataGatheringProgress:
|
dataGatheringProgress:
|
||||||
@ -56,25 +65,82 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(): Promise<AdminMarketData> {
|
||||||
return {
|
const marketData = await this.prismaService.marketData.groupBy({
|
||||||
marketData: await (
|
_count: true,
|
||||||
await this.dataGatheringService.getSymbolsMax()
|
by: ['dataSource', 'symbol']
|
||||||
).map((symbol) => {
|
});
|
||||||
return symbol;
|
|
||||||
|
const currencyPairsToGather: AdminMarketDataItem[] =
|
||||||
|
this.exchangeRateDataService
|
||||||
|
.getCurrencyPairs()
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketData.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
marketDataItemCount,
|
||||||
|
symbol
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||||
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy: [{ symbol: 'asc' }],
|
||||||
|
select: {
|
||||||
|
_count: {
|
||||||
|
select: { Order: true }
|
||||||
|
},
|
||||||
|
dataSource: true,
|
||||||
|
Order: {
|
||||||
|
orderBy: [{ date: 'asc' }],
|
||||||
|
select: { date: true },
|
||||||
|
take: 1
|
||||||
|
},
|
||||||
|
scraperConfiguration: true,
|
||||||
|
symbol: true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
).map((symbolProfile) => {
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketData.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === symbolProfile.dataSource &&
|
||||||
|
marketDataItem.symbol === symbolProfile.symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketDataItemCount,
|
||||||
|
activityCount: symbolProfile._count.Order,
|
||||||
|
dataSource: symbolProfile.dataSource,
|
||||||
|
date: symbolProfile.Order?.[0]?.date,
|
||||||
|
symbol: symbolProfile.symbol
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketDataBySymbol(
|
public async getMarketDataBySymbol({
|
||||||
aSymbol: string
|
dataSource,
|
||||||
): Promise<AdminMarketDataDetails> {
|
symbol
|
||||||
|
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||||
return {
|
return {
|
||||||
marketData: await this.marketDataService.marketDataItems({
|
marketData: await this.marketDataService.marketDataItems({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'asc'
|
date: 'asc'
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
symbol: aSymbol
|
dataSource,
|
||||||
|
symbol
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsNumber } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateMarketDataDto {
|
||||||
|
@IsNumber()
|
||||||
|
marketPrice: number;
|
||||||
|
}
|
@ -8,6 +8,7 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
|
|||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
@ -65,6 +66,7 @@ import { UserModule } from './user/user.module';
|
|||||||
}),
|
}),
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
@ -1,18 +1,20 @@
|
|||||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthDeviceController],
|
controllers: [AuthDeviceController],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
})
|
}),
|
||||||
|
PrismaModule
|
||||||
],
|
],
|
||||||
providers: [AuthDeviceService, ConfigurationService, PrismaService]
|
providers: [AuthDeviceService]
|
||||||
})
|
})
|
||||||
export class AuthDeviceModule {}
|
export class AuthDeviceModule {}
|
||||||
|
@ -9,7 +9,9 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
VERSION_NEUTRAL,
|
||||||
|
Version
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -51,6 +53,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Get('google/callback')
|
@Get('google/callback')
|
||||||
@UseGuards(AuthGuard('google'))
|
@UseGuards(AuthGuard('google'))
|
||||||
|
@Version(VERSION_NEUTRAL)
|
||||||
public googleLoginCallback(@Req() req, @Res() res) {
|
public googleLoginCallback(@Req() req, @Res() res) {
|
||||||
// Handles the Google OAuth2 callback
|
// Handles the Google OAuth2 callback
|
||||||
const jwt: string = req.user.jwt;
|
const jwt: string = req.user.jwt;
|
||||||
|
@ -2,8 +2,8 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -15,20 +15,20 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
}),
|
}),
|
||||||
|
PrismaModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AuthDeviceService,
|
AuthDeviceService,
|
||||||
AuthService,
|
AuthService,
|
||||||
ConfigurationService,
|
|
||||||
GoogleStrategy,
|
GoogleStrategy,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
PrismaService,
|
|
||||||
WebAuthService
|
WebAuthService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -42,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
|
|
||||||
done(null, user);
|
done(null, user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'GoogleStrategy');
|
||||||
done(error, false);
|
done(error, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -95,7 +95,7 @@ export class WebAuthService {
|
|||||||
};
|
};
|
||||||
verification = await verifyRegistrationResponse(opts);
|
verification = await verifyRegistrationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'WebAuthService');
|
||||||
throw new InternalServerErrorException(error.message);
|
throw new InternalServerErrorException(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +193,7 @@ export class WebAuthService {
|
|||||||
};
|
};
|
||||||
verification = verifyAuthenticationResponse(opts);
|
verification = verifyAuthenticationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'WebAuthService');
|
||||||
throw new InternalServerErrorException({ error: error.message });
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
17
apps/api/src/app/cache/cache.module.ts
vendored
17
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,30 +1,27 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
exports: [CacheService],
|
||||||
|
controllers: [CacheController],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
controllers: [CacheController],
|
providers: [CacheService]
|
||||||
providers: [
|
|
||||||
CacheService,
|
|
||||||
ConfigurationService,
|
|
||||||
DataGatheringService,
|
|
||||||
PrismaService
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
@ -15,8 +15,11 @@ export class ExportController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async export(): Promise<Export> {
|
public async export(
|
||||||
return await this.exportService.export({
|
@Query('activityIds') activityIds?: string[]
|
||||||
|
): Promise<Export> {
|
||||||
|
return this.exportService.export({
|
||||||
|
activityIds,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -7,25 +7,59 @@ import { Injectable } from '@nestjs/common';
|
|||||||
export class ExportService {
|
export class ExportService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
public async export({ userId }: { userId: string }): Promise<Export> {
|
public async export({
|
||||||
const orders = await this.prismaService.order.findMany({
|
activityIds,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
activityIds?: string[];
|
||||||
|
userId: string;
|
||||||
|
}): Promise<Export> {
|
||||||
|
let activities = await this.prismaService.order.findMany({
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
currency: true,
|
accountId: true,
|
||||||
dataSource: true,
|
|
||||||
date: true,
|
date: true,
|
||||||
fee: true,
|
fee: true,
|
||||||
|
id: true,
|
||||||
quantity: true,
|
quantity: true,
|
||||||
symbol: true,
|
SymbolProfile: true,
|
||||||
type: true,
|
type: true,
|
||||||
unitPrice: true
|
unitPrice: true
|
||||||
},
|
},
|
||||||
where: { userId }
|
where: { userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (activityIds) {
|
||||||
|
activities = activities.filter((activity) => {
|
||||||
|
return activityIds.includes(activity.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: { date: new Date().toISOString(), version: environment.version },
|
meta: { date: new Date().toISOString(), version: environment.version },
|
||||||
orders
|
activities: activities.map(
|
||||||
|
({
|
||||||
|
accountId,
|
||||||
|
date,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
SymbolProfile,
|
||||||
|
type,
|
||||||
|
unitPrice
|
||||||
|
}) => {
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
date,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
currency: SymbolProfile.currency,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Order } from '@prisma/client';
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, ValidateNested } from 'class-validator';
|
import { IsArray, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
@ -7,5 +6,5 @@ export class ImportDataDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@Type(() => CreateOrderDto)
|
@Type(() => CreateOrderDto)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
orders: Order[];
|
activities: CreateOrderDto[];
|
||||||
}
|
}
|
||||||
|
@ -36,11 +36,11 @@ export class ImportController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.importService.import({
|
return await this.importService.import({
|
||||||
orders: importData.orders,
|
activities: importData.activities,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, ImportController);
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
@ -11,14 +12,17 @@ import { ImportController } from './import.controller';
|
|||||||
import { ImportService } from './import.service';
|
import { ImportService } from './import.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [ImportController],
|
||||||
imports: [
|
imports: [
|
||||||
|
AccountModule,
|
||||||
|
CacheModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
OrderModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule
|
RedisCacheModule
|
||||||
],
|
],
|
||||||
controllers: [ImportController],
|
providers: [ImportService]
|
||||||
providers: [CacheService, ImportService, OrderService]
|
|
||||||
})
|
})
|
||||||
export class ImportModule {}
|
export class ImportModule {}
|
||||||
|
@ -1,26 +1,44 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Order } from '@prisma/client';
|
|
||||||
import { isSameDay, parseISO } from 'date-fns';
|
import { isSameDay, parseISO } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
private static MAX_ORDERS_TO_IMPORT = 20;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountService: AccountService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly orderService: OrderService
|
private readonly orderService: OrderService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async import({
|
public async import({
|
||||||
orders,
|
activities,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
orders: Partial<Order>[];
|
activities: Partial<CreateOrderDto>[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await this.validateOrders({ orders, userId });
|
for (const activity of activities) {
|
||||||
|
if (!activity.dataSource) {
|
||||||
|
if (activity.type === 'ITEM') {
|
||||||
|
activity.dataSource = 'MANUAL';
|
||||||
|
} else {
|
||||||
|
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.validateActivities({ activities, userId });
|
||||||
|
|
||||||
|
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
||||||
|
(account) => {
|
||||||
|
return account.id;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
accountId,
|
accountId,
|
||||||
@ -32,38 +50,54 @@ export class ImportService {
|
|||||||
symbol,
|
symbol,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
} of orders) {
|
} of activities) {
|
||||||
await this.orderService.createOrder({
|
await this.orderService.createOrder({
|
||||||
Account: {
|
|
||||||
connect: {
|
|
||||||
id_userId: { userId, id: accountId }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
currency,
|
|
||||||
dataSource,
|
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
|
userId,
|
||||||
|
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
||||||
date: parseISO(<string>(<unknown>date)),
|
date: parseISO(<string>(<unknown>date)),
|
||||||
|
SymbolProfile: {
|
||||||
|
connectOrCreate: {
|
||||||
|
create: {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource_symbol: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
User: { connect: { id: userId } }
|
User: { connect: { id: userId } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateOrders({
|
private async validateActivities({
|
||||||
orders,
|
activities,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
orders: Partial<Order>[];
|
activities: Partial<CreateOrderDto>[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
|
if (
|
||||||
throw new Error('Too many transactions');
|
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Too many activities (${this.configurationService.get(
|
||||||
|
'MAX_ORDERS_TO_IMPORT'
|
||||||
|
)} at most)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingOrders = await this.orderService.orders({
|
const existingActivities = await this.orderService.orders({
|
||||||
|
include: { SymbolProfile: true },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
where: { userId }
|
where: { userId }
|
||||||
});
|
});
|
||||||
@ -71,32 +105,40 @@ export class ImportService {
|
|||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||||
] of orders.entries()) {
|
] of activities.entries()) {
|
||||||
const duplicateOrder = existingOrders.find((order) => {
|
const duplicateActivity = existingActivities.find((activity) => {
|
||||||
return (
|
return (
|
||||||
order.currency === currency &&
|
activity.SymbolProfile.currency === currency &&
|
||||||
order.dataSource === dataSource &&
|
activity.SymbolProfile.dataSource === dataSource &&
|
||||||
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
|
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
|
||||||
order.fee === fee &&
|
activity.fee === fee &&
|
||||||
order.quantity === quantity &&
|
activity.quantity === quantity &&
|
||||||
order.symbol === symbol &&
|
activity.SymbolProfile.symbol === symbol &&
|
||||||
order.type === type &&
|
activity.type === type &&
|
||||||
order.unitPrice === unitPrice
|
activity.unitPrice === unitPrice
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (duplicateOrder) {
|
if (duplicateActivity) {
|
||||||
throw new Error(`orders.${index} is a duplicate transaction`);
|
throw new Error(`activities.${index} is a duplicate activity`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.dataProviderService.get([
|
if (dataSource !== 'MANUAL') {
|
||||||
{ dataSource, symbol }
|
const quotes = await this.dataProviderService.getQuotes([
|
||||||
]);
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
if (result[symbol] === undefined) {
|
if (quotes[symbol] === undefined) {
|
||||||
throw new Error(
|
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(
|
||||||
|
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -14,7 +13,9 @@ import { InfoController } from './info.controller';
|
|||||||
import { InfoService } from './info.service';
|
import { InfoService } from './info.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [InfoController],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
@ -22,16 +23,11 @@ import { InfoService } from './info.service';
|
|||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
}),
|
}),
|
||||||
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
controllers: [InfoController],
|
providers: [InfoService]
|
||||||
providers: [
|
|
||||||
ConfigurationService,
|
|
||||||
DataGatheringService,
|
|
||||||
InfoService,
|
|
||||||
PrismaService
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class InfoModule {}
|
export class InfoModule {}
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import {
|
||||||
|
DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
@ -22,11 +25,9 @@ import { subDays } from 'date-fns';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@ -50,6 +51,12 @@ export class InfoService {
|
|||||||
globalPermissions.push(permissions.enableBlog);
|
globalPermissions.push(permissions.enableBlog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
|
info.fearAndGreedDataSource = encodeDataSource(
|
||||||
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||||
globalPermissions.push(permissions.enableImport);
|
globalPermissions.push(permissions.enableImport);
|
||||||
}
|
}
|
||||||
@ -91,7 +98,6 @@ export class InfoService {
|
|||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: this.getDemoAuthToken(),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
lastDataGathering: await this.getLastDataGathering(),
|
||||||
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
|
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions()
|
subscriptions: await this.getSubscriptions()
|
||||||
};
|
};
|
||||||
@ -138,7 +144,7 @@ export class InfoService {
|
|||||||
const contributors = await get();
|
const contributors = await get();
|
||||||
return contributors?.length;
|
return contributors?.length;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -159,7 +165,7 @@ export class InfoService {
|
|||||||
const { stargazers_count } = await get();
|
const { stargazers_count } = await get();
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -187,9 +193,15 @@ export class InfoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async countSlackCommunityUsers() {
|
||||||
|
return (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_SLACK_COMMUNITY_USERS
|
||||||
|
)) as string;
|
||||||
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: InfoService.DEMO_USER_ID
|
id: DEMO_USER_ID
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,19 +230,19 @@ export class InfoService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const activeUsers1d = await this.countActiveUsers(1);
|
const activeUsers1d = await this.countActiveUsers(1);
|
||||||
const activeUsers7d = await this.countActiveUsers(7);
|
|
||||||
const activeUsers30d = await this.countActiveUsers(30);
|
const activeUsers30d = await this.countActiveUsers(30);
|
||||||
const newUsers30d = await this.countNewUsers(30);
|
const newUsers30d = await this.countNewUsers(30);
|
||||||
const gitHubContributors = await this.countGitHubContributors();
|
const gitHubContributors = await this.countGitHubContributors();
|
||||||
const gitHubStargazers = await this.countGitHubStargazers();
|
const gitHubStargazers = await this.countGitHubStargazers();
|
||||||
|
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||||
|
|
||||||
statistics = {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
activeUsers7d,
|
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d
|
newUsers30d,
|
||||||
|
slackCommunityUsers
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
|
@ -1,14 +1,22 @@
|
|||||||
import { DataSource, Type } from '@prisma/client';
|
import { DataSource, Type } from '@prisma/client';
|
||||||
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsISO8601,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsEnum(DataSource, { each: true })
|
@IsEnum(DataSource, { each: true })
|
||||||
|
@IsOptional()
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
|
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
export interface Activities {
|
||||||
|
activities: Activity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Activity extends OrderWithAccount {
|
||||||
|
feeInBaseCurrency: number;
|
||||||
|
valueInBaseCurrency: number;
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { 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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -14,7 +16,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@ -23,6 +26,7 @@ import { parseISO } from 'date-fns';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { CreateOrderDto } from './create-order.dto';
|
import { CreateOrderDto } from './create-order.dto';
|
||||||
|
import { Activities } from './interfaces/activities.interface';
|
||||||
import { OrderService } from './order.service';
|
import { OrderService } from './order.service';
|
||||||
import { UpdateOrderDto } from './update-order.dto';
|
import { UpdateOrderDto } from './update-order.dto';
|
||||||
|
|
||||||
@ -57,55 +61,43 @@ export class OrderController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<OrderModel[]> {
|
): Promise<Activities> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
|
|
||||||
let orders = await this.orderService.orders({
|
let activities = await this.orderService.getOrders({
|
||||||
include: {
|
userCurrency,
|
||||||
Account: {
|
includeDrafts: true,
|
||||||
include: {
|
userId: impersonationUserId || this.request.user.id
|
||||||
Platform: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
SymbolProfile: {
|
|
||||||
select: {
|
|
||||||
name: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: { date: 'desc' },
|
|
||||||
where: { userId: impersonationUserId || this.request.user.id }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
activities = nullifyValuesInObjects(activities, [
|
||||||
|
'fee',
|
||||||
|
'feeInBaseCurrency',
|
||||||
|
'quantity',
|
||||||
|
'unitPrice',
|
||||||
|
'value',
|
||||||
|
'valueInBaseCurrency'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return orders;
|
return { activities };
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async getOrderById(@Param('id') id: string): Promise<OrderModel> {
|
|
||||||
return this.orderService.order({
|
|
||||||
id_userId: {
|
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||||
@ -116,39 +108,32 @@ export class OrderController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = parseISO(data.date);
|
|
||||||
|
|
||||||
const accountId = data.accountId;
|
|
||||||
delete data.accountId;
|
|
||||||
|
|
||||||
return this.orderService.createOrder({
|
return this.orderService.createOrder({
|
||||||
...data,
|
...data,
|
||||||
Account: {
|
date: parseISO(data.date),
|
||||||
connect: {
|
|
||||||
id_userId: { id: accountId, userId: this.request.user.id }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
date,
|
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
|
create: {
|
||||||
|
currency: data.currency,
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
symbol: data.symbol
|
||||||
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
}
|
}
|
||||||
},
|
|
||||||
create: {
|
|
||||||
dataSource: data.dataSource,
|
|
||||||
symbol: data.symbol
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
User: { connect: { id: this.request.user.id } }
|
User: { connect: { id: this.request.user.id } },
|
||||||
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
||||||
@ -187,6 +172,14 @@ export class OrderController {
|
|||||||
id_userId: { id: accountId, userId: this.request.user.id }
|
id_userId: { id: accountId, userId: this.request.user.id }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
SymbolProfile: {
|
||||||
|
connect: {
|
||||||
|
dataSource_symbol: {
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
symbol: data.symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
User: { connect: { id: this.request.user.id } }
|
User: { connect: { id: this.request.user.id } }
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
|
@ -1,28 +1,34 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { OrderController } from './order.controller';
|
import { OrderController } from './order.controller';
|
||||||
import { OrderService } from './order.service';
|
import { OrderService } from './order.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [OrderController],
|
||||||
|
exports: [OrderService],
|
||||||
imports: [
|
imports: [
|
||||||
|
CacheModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [OrderController],
|
providers: [AccountService, OrderService]
|
||||||
providers: [CacheService, OrderService],
|
|
||||||
exports: [OrderService]
|
|
||||||
})
|
})
|
||||||
export class OrderModule {}
|
export class OrderModule {}
|
||||||
|
@ -1,17 +1,27 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import { Activity } from './interfaces/activities.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountService: AccountService,
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async order(
|
public async order(
|
||||||
@ -42,34 +52,86 @@ export class OrderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
|
public async createOrder(
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
data: Prisma.OrderCreateInput & {
|
||||||
|
accountId?: string;
|
||||||
|
currency?: string;
|
||||||
|
dataSource?: DataSource;
|
||||||
|
symbol?: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
): Promise<Order> {
|
||||||
|
const defaultAccount = (
|
||||||
|
await this.accountService.getAccounts(data.userId)
|
||||||
|
).find((account) => {
|
||||||
|
return account.isDefault === true;
|
||||||
|
});
|
||||||
|
|
||||||
// Convert the symbol to uppercase to avoid case-sensitive duplicates
|
let Account = {
|
||||||
const symbol = data.symbol.toUpperCase();
|
connect: {
|
||||||
|
id_userId: {
|
||||||
|
userId: data.userId,
|
||||||
|
id: data.accountId ?? defaultAccount?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.type === 'ITEM') {
|
||||||
|
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||||
|
const dataSource: DataSource = 'MANUAL';
|
||||||
|
const id = uuidv4();
|
||||||
|
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||||
|
|
||||||
|
Account = undefined;
|
||||||
|
data.id = id;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
||||||
|
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
|
||||||
|
dataSource,
|
||||||
|
symbol: id
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
data.SymbolProfile.connectOrCreate.create.symbol =
|
||||||
|
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataGatheringService.gatherProfileData([
|
||||||
|
{
|
||||||
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
// Gather symbol data of order in the background, if not draft
|
// Gather symbol data of order in the background, if not draft
|
||||||
this.dataGatheringService.gatherSymbols([
|
this.dataGatheringService.gatherSymbols([
|
||||||
{
|
{
|
||||||
symbol,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
dataSource: data.dataSource,
|
date: <Date>data.date,
|
||||||
date: <Date>data.date
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData([
|
|
||||||
{ symbol, dataSource: data.dataSource }
|
|
||||||
]);
|
|
||||||
|
|
||||||
await this.cacheService.flush();
|
await this.cacheService.flush();
|
||||||
|
|
||||||
|
delete data.accountId;
|
||||||
|
delete data.currency;
|
||||||
|
delete data.dataSource;
|
||||||
|
delete data.symbol;
|
||||||
|
delete data.userId;
|
||||||
|
|
||||||
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
return this.prismaService.order.create({
|
return this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...orderData,
|
||||||
isDraft,
|
Account,
|
||||||
symbol
|
isDraft
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -77,57 +139,121 @@ export class OrderService {
|
|||||||
public async deleteOrder(
|
public async deleteOrder(
|
||||||
where: Prisma.OrderWhereUniqueInput
|
where: Prisma.OrderWhereUniqueInput
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
return this.prismaService.order.delete({
|
const order = await this.prismaService.order.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (order.type === 'ITEM') {
|
||||||
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOrders({
|
public async getOrders({
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
|
types,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
|
types?: TypeOfOrder[];
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}): Promise<Activity[]> {
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
if (includeDrafts === false) {
|
if (includeDrafts === false) {
|
||||||
where.isDraft = false;
|
where.isDraft = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.orders({
|
if (types) {
|
||||||
where,
|
where.OR = types.map((type) => {
|
||||||
include: {
|
return {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
type: {
|
||||||
Account: true,
|
equals: type
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
}
|
||||||
SymbolProfile: true
|
};
|
||||||
},
|
});
|
||||||
orderBy: { date: 'asc' }
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
await this.orders({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
Account: {
|
||||||
|
include: {
|
||||||
|
Platform: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
SymbolProfile: true
|
||||||
|
},
|
||||||
|
orderBy: { date: 'asc' }
|
||||||
|
})
|
||||||
|
).map((order) => {
|
||||||
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
value,
|
||||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
order.fee,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateOrder(params: {
|
public async updateOrder({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
data: Prisma.OrderUpdateInput & {
|
||||||
|
currency?: string;
|
||||||
|
dataSource?: DataSource;
|
||||||
|
symbol?: string;
|
||||||
|
};
|
||||||
where: Prisma.OrderWhereUniqueInput;
|
where: Prisma.OrderWhereUniqueInput;
|
||||||
data: Prisma.OrderUpdateInput;
|
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
const { data, where } = params;
|
if (data.Account.connect.id_userId.id === null) {
|
||||||
|
delete data.Account;
|
||||||
|
}
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
let isDraft = false;
|
||||||
|
|
||||||
if (!isDraft) {
|
if (data.type === 'ITEM') {
|
||||||
// Gather symbol data of order in the background, if not draft
|
const name = data.SymbolProfile.connect.dataSource_symbol.symbol;
|
||||||
this.dataGatheringService.gatherSymbols([
|
|
||||||
{
|
data.SymbolProfile = { update: { name } };
|
||||||
dataSource: <DataSource>data.dataSource,
|
} else {
|
||||||
date: <Date>data.date,
|
isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
symbol: <string>data.symbol
|
|
||||||
}
|
if (!isDraft) {
|
||||||
]);
|
// Gather symbol data of order in the background, if not draft
|
||||||
|
this.dataGatheringService.gatherSymbols([
|
||||||
|
{
|
||||||
|
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
||||||
|
date: <Date>data.date,
|
||||||
|
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cacheService.flush();
|
await this.cacheService.flush();
|
||||||
|
|
||||||
|
delete data.currency;
|
||||||
|
delete data.dataSource;
|
||||||
|
delete data.symbol;
|
||||||
|
|
||||||
return this.prismaService.order.update({
|
return this.prismaService.order.update({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { DataSource, Type } from '@prisma/client';
|
import { DataSource, Type } from '@prisma/client';
|
||||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateOrderDto {
|
export class UpdateOrderDto {
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|
||||||
|
60
apps/api/src/app/portfolio/current-rate.service.mock.ts
Normal file
60
apps/api/src/app/portfolio/current-rate.service.mock.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
|
function mockGetValue(symbol: string, date: Date) {
|
||||||
|
switch (symbol) {
|
||||||
|
case 'BALN.SW':
|
||||||
|
if (isSameDay(parseDate('2021-11-12'), date)) {
|
||||||
|
return { marketPrice: 146 };
|
||||||
|
} else if (isSameDay(parseDate('2021-11-22'), date)) {
|
||||||
|
return { marketPrice: 142.9 };
|
||||||
|
} else if (isSameDay(parseDate('2021-11-26'), date)) {
|
||||||
|
return { marketPrice: 139.9 };
|
||||||
|
} else if (isSameDay(parseDate('2021-11-30'), date)) {
|
||||||
|
return { marketPrice: 136.6 };
|
||||||
|
} else if (isSameDay(parseDate('2021-12-18'), date)) {
|
||||||
|
return { marketPrice: 148.9 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentRateServiceMock = {
|
||||||
|
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
|
||||||
|
const result = [];
|
||||||
|
if (dateQuery.lt) {
|
||||||
|
for (
|
||||||
|
let date = resetHours(dateQuery.gte);
|
||||||
|
isBefore(date, endOfDay(dateQuery.lt));
|
||||||
|
date = addDays(date, 1)
|
||||||
|
) {
|
||||||
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
|
result.push({
|
||||||
|
date,
|
||||||
|
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
||||||
|
.marketPrice,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const date of dateQuery.in) {
|
||||||
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
|
result.push({
|
||||||
|
date,
|
||||||
|
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
||||||
|
.marketPrice,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve(result);
|
||||||
|
}
|
||||||
|
};
|
@ -85,19 +85,6 @@ describe('CurrentRateService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getValue', async () => {
|
|
||||||
expect(
|
|
||||||
await currentRateService.getValue({
|
|
||||||
currency: 'USD',
|
|
||||||
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
|
||||||
symbol: 'AMZN',
|
|
||||||
userCurrency: 'CHF'
|
|
||||||
})
|
|
||||||
).toMatchObject({
|
|
||||||
marketPrice: 1847.839966
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getValues', async () => {
|
it('getValues', async () => {
|
||||||
expect(
|
expect(
|
||||||
await currentRateService.getValues({
|
await currentRateService.getValues({
|
||||||
|
@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
|
|||||||
import { flatten } from 'lodash';
|
import { flatten } from 'lodash';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
import { GetValueParams } from './interfaces/get-value-params.interface';
|
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -18,46 +17,6 @@ export class CurrentRateService {
|
|||||||
private readonly marketDataService: MarketDataService
|
private readonly marketDataService: MarketDataService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getValue({
|
|
||||||
currency,
|
|
||||||
date,
|
|
||||||
symbol,
|
|
||||||
userCurrency
|
|
||||||
}: GetValueParams): Promise<GetValueObject> {
|
|
||||||
if (isToday(date)) {
|
|
||||||
const dataProviderResult = await this.dataProviderService.get([
|
|
||||||
{
|
|
||||||
symbol,
|
|
||||||
dataSource: this.dataProviderService.getPrimaryDataSource()
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
symbol,
|
|
||||||
date: resetHours(date),
|
|
||||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const marketData = await this.marketDataService.get({
|
|
||||||
date,
|
|
||||||
symbol
|
|
||||||
});
|
|
||||||
|
|
||||||
if (marketData) {
|
|
||||||
return {
|
|
||||||
date: marketData.date,
|
|
||||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
|
||||||
marketData.marketPrice,
|
|
||||||
currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
symbol: marketData.symbol
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getValues({
|
public async getValues({
|
||||||
currencies,
|
currencies,
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
@ -81,7 +40,7 @@ export class CurrentRateService {
|
|||||||
const today = resetHours(new Date());
|
const today = resetHours(new Date());
|
||||||
promises.push(
|
promises.push(
|
||||||
this.dataProviderService
|
this.dataProviderService
|
||||||
.get(dataGatheringItems)
|
.getQuotes(dataGatheringItems)
|
||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface CurrentPositions {
|
export interface CurrentPositions extends ResponseError {
|
||||||
hasErrors: boolean;
|
|
||||||
positions: TimelinePosition[];
|
positions: TimelinePosition[];
|
||||||
grossPerformance: Big;
|
grossPerformance: Big;
|
||||||
grossPerformancePercentage: Big;
|
grossPerformancePercentage: Big;
|
||||||
netAnnualizedPerformance: Big;
|
netAnnualizedPerformance?: Big;
|
||||||
netPerformance: Big;
|
netPerformance: Big;
|
||||||
netPerformancePercentage: Big;
|
netPerformancePercentage: Big;
|
||||||
currentValue: Big;
|
currentValue: Big;
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
export interface GetValueParams {
|
|
||||||
currency: string;
|
|
||||||
date: Date;
|
|
||||||
symbol: string;
|
|
||||||
userCurrency: string;
|
|
||||||
}
|
|
@ -0,0 +1,5 @@
|
|||||||
|
import { PortfolioOrder } from './portfolio-order.interface';
|
||||||
|
|
||||||
|
export interface PortfolioOrderItem extends PortfolioOrder {
|
||||||
|
itemType?: '' | 'start' | 'end';
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface PortfolioOrder {
|
export interface PortfolioOrder {
|
||||||
@ -10,6 +9,6 @@ export interface PortfolioOrder {
|
|||||||
name: string;
|
name: string;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
type: OrderType;
|
type: TypeOfOrder;
|
||||||
unitPrice: Big;
|
unitPrice: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
assetClass?: AssetClass;
|
|
||||||
assetSubClass?: AssetSubClass;
|
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
currency: string;
|
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
grossPerformancePercent: number;
|
grossPerformancePercent: number;
|
||||||
@ -13,11 +11,11 @@ export interface PortfolioPositionDetail {
|
|||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
maxPrice: number;
|
maxPrice: number;
|
||||||
minPrice: number;
|
minPrice: number;
|
||||||
name: string;
|
|
||||||
netPerformance: number;
|
netPerformance: number;
|
||||||
netPerformancePercent: number;
|
netPerformancePercent: number;
|
||||||
|
orders: OrderWithAccount[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
SymbolProfile: EnhancedSymbolProfile;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,96 @@
|
|||||||
|
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 { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
||||||
|
|
||||||
|
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('PortfolioCalculatorNew', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BALN.SW buy and sell', async () => {
|
||||||
|
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2021-11-22',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(1.55),
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: new Big(142.9)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2021-11-30',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(1.65),
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: new Big(136.6)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculatorNew.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
||||||
|
parseDate('2021-11-22')
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('-12.6'),
|
||||||
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('-15.8'),
|
||||||
|
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('0'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
firstBuyDate: '2021-11-22',
|
||||||
|
grossPerformance: new Big('-12.6'),
|
||||||
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
investment: new Big('0'),
|
||||||
|
netPerformance: new Big('-15.8'),
|
||||||
|
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||||
|
marketPrice: 148.9,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
transactionCount: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('0')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,85 @@
|
|||||||
|
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 { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
||||||
|
|
||||||
|
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('PortfolioCalculatorNew', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BALN.SW buy', async () => {
|
||||||
|
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2021-11-30',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(1.55),
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: new Big(136.6)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculatorNew.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
||||||
|
parseDate('2021-11-30')
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big('297.8'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('24.6'),
|
||||||
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('23.05'),
|
||||||
|
netPerformancePercentage: new Big('0.08437042459736456808'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('136.6'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
firstBuyDate: '2021-11-30',
|
||||||
|
grossPerformance: new Big('24.6'),
|
||||||
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
investment: new Big('273.2'),
|
||||||
|
netPerformance: new Big('23.05'),
|
||||||
|
netPerformancePercentage: new Big('0.08437042459736456808'),
|
||||||
|
marketPrice: 148.9,
|
||||||
|
quantity: new Big('2'),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
transactionCount: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('273.2')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,56 @@
|
|||||||
|
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 { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
||||||
|
|
||||||
|
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('PortfolioCalculatorNew', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it('with no orders', async () => {
|
||||||
|
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: []
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculatorNew.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big(0),
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
positions: [],
|
||||||
|
totalInvestment: new Big(0)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
73
apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts
Normal file
73
apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
997
apps/api/src/app/portfolio/portfolio-calculator-new.ts
Normal file
997
apps/api/src/app/portfolio/portfolio-calculator-new.ts
Normal file
@ -0,0 +1,997 @@
|
|||||||
|
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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,9 @@
|
|||||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
@ -69,7 +69,7 @@ export class PortfolioCalculator {
|
|||||||
: unitPrice
|
: unitPrice
|
||||||
.mul(order.quantity)
|
.mul(order.quantity)
|
||||||
.mul(factor)
|
.mul(factor)
|
||||||
.add(oldAccumulatedSymbol.investment),
|
.plus(oldAccumulatedSymbol.investment),
|
||||||
quantity: newQuantity,
|
quantity: newQuantity,
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||||
@ -238,8 +238,9 @@ export class PortfolioCalculator {
|
|||||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||||
invalidSymbols.push(item.symbol);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
Logger.error(
|
Logger.warn(
|
||||||
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
`Missing value for symbol ${item.symbol} at ${nextDate}`,
|
||||||
|
'PortfolioCalculator'
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -271,8 +272,9 @@ export class PortfolioCalculator {
|
|||||||
if (!initialValue) {
|
if (!initialValue) {
|
||||||
invalidSymbols.push(item.symbol);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
Logger.error(
|
Logger.warn(
|
||||||
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
`Missing value for symbol ${item.symbol} at ${currentDate}`,
|
||||||
|
'PortfolioCalculator'
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -356,7 +358,7 @@ export class PortfolioCalculator {
|
|||||||
date: transactionPoint.date,
|
date: transactionPoint.date,
|
||||||
investment: transactionPoint.items.reduce(
|
investment: transactionPoint.items.reduce(
|
||||||
(investment, transactionPointSymbol) =>
|
(investment, transactionPointSymbol) =>
|
||||||
investment.add(transactionPointSymbol.investment),
|
investment.plus(transactionPointSymbol.investment),
|
||||||
new Big(0)
|
new Big(0)
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@ -477,13 +479,13 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
for (const currentPosition of positions) {
|
for (const currentPosition of positions) {
|
||||||
if (currentPosition.marketPrice) {
|
if (currentPosition.marketPrice) {
|
||||||
currentValue = currentValue.add(
|
currentValue = currentValue.plus(
|
||||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
}
|
}
|
||||||
totalInvestment = totalInvestment.add(currentPosition.investment);
|
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
||||||
if (currentPosition.grossPerformance) {
|
if (currentPosition.grossPerformance) {
|
||||||
grossPerformance = grossPerformance.plus(
|
grossPerformance = grossPerformance.plus(
|
||||||
currentPosition.grossPerformance
|
currentPosition.grossPerformance
|
||||||
@ -515,8 +517,9 @@ export class PortfolioCalculator {
|
|||||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||||
);
|
);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
Logger.error(
|
Logger.warn(
|
||||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
||||||
|
'PortfolioCalculator'
|
||||||
);
|
);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
}
|
}
|
||||||
@ -564,8 +567,8 @@ export class PortfolioCalculator {
|
|||||||
dataSource: item.dataSource,
|
dataSource: item.dataSource,
|
||||||
symbol: item.symbol
|
symbol: item.symbol
|
||||||
});
|
});
|
||||||
investment = investment.add(item.investment);
|
investment = investment.plus(item.investment);
|
||||||
fees = fees.add(item.fee);
|
fees = fees.plus(item.fee);
|
||||||
}
|
}
|
||||||
|
|
||||||
let marketSymbols: GetValueObject[] = [];
|
let marketSymbols: GetValueObject[] = [];
|
||||||
@ -583,7 +586,8 @@ export class PortfolioCalculator {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
`Failed to fetch info for date ${startDate} with exception`,
|
||||||
error
|
error,
|
||||||
|
'PortfolioCalculator'
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -621,7 +625,7 @@ export class PortfolioCalculator {
|
|||||||
invalid = true;
|
invalid = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
value = value.add(
|
value = value.plus(
|
||||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -660,14 +664,14 @@ export class PortfolioCalculator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFactor(type: OrderType) {
|
private getFactor(type: TypeOfOrder) {
|
||||||
let factor: number;
|
let factor: number;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case OrderType.Buy:
|
case 'BUY':
|
||||||
factor = 1;
|
factor = 1;
|
||||||
break;
|
break;
|
||||||
case OrderType.Sell:
|
case 'SELL':
|
||||||
factor = -1;
|
factor = -1;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
26
apps/api/src/app/portfolio/portfolio-service.strategy.ts
Normal file
26
apps/api/src/app/portfolio/portfolio-service.strategy.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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,18 +4,21 @@ import {
|
|||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
|
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioInvestments,
|
||||||
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@ -25,17 +28,17 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
UseGuards,
|
||||||
UseGuards
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Response } from 'express';
|
import { ViewMode } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
@ -43,73 +46,31 @@ export class PortfolioController {
|
|||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('investments')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async findAll(
|
|
||||||
@Headers('impersonation-id') impersonationId,
|
|
||||||
@Res() res: Response
|
|
||||||
): Promise<InvestmentItem[]> {
|
|
||||||
if (
|
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
|
||||||
res.status(StatusCodes.FORBIDDEN);
|
|
||||||
return <any>res.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let investments = await this.portfolioService.getInvestments(
|
|
||||||
impersonationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
const maxInvestment = investments.reduce(
|
|
||||||
(investment, item) => Math.max(investment, item.investment),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
investments = investments.map((item) => ({
|
|
||||||
date: item.date,
|
|
||||||
investment: item.investment / maxInvestment
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return <any>res.json(investments);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('chart')
|
@Get('chart')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getChart(
|
public async getChart(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range
|
||||||
@Res() res: Response
|
|
||||||
): Promise<PortfolioChart> {
|
): Promise<PortfolioChart> {
|
||||||
const historicalDataContainer = await this.portfolioService.getChart(
|
const historicalDataContainer = await this.portfolioServiceStrategy
|
||||||
impersonationId,
|
.get()
|
||||||
range
|
.getChart(impersonationId, range);
|
||||||
);
|
|
||||||
|
|
||||||
let chartData = historicalDataContainer.items;
|
let chartData = historicalDataContainer.items;
|
||||||
|
|
||||||
let hasNullValue = false;
|
let hasError = false;
|
||||||
|
|
||||||
chartData.forEach((chartDataItem) => {
|
chartData.forEach((chartDataItem) => {
|
||||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||||
hasNullValue = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasNullValue) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
@ -130,37 +91,40 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json({
|
return {
|
||||||
|
hasError,
|
||||||
chart: chartData,
|
chart: chartData,
|
||||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range
|
||||||
@Res() res: Response
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
): Promise<PortfolioDetails> {
|
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.subscription.type === 'Basic'
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
res.status(StatusCodes.FORBIDDEN);
|
throw new HttpException(
|
||||||
return <any>res.json({ accounts: {}, holdings: {} });
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioService.getDetails(
|
await this.portfolioServiceStrategy
|
||||||
impersonationId,
|
.get(true)
|
||||||
this.request.user.id,
|
.getDetails(impersonationId, this.request.user.id, range);
|
||||||
range
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
res.status(StatusCodes.ACCEPTED);
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -198,54 +162,81 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json({ accounts, holdings });
|
return { accounts, hasError, holdings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPerformance(
|
public async getInvestments(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string
|
||||||
@Query('range') range,
|
): Promise<PortfolioInvestments> {
|
||||||
@Res() res: Response
|
if (
|
||||||
): Promise<PortfolioPerformance> {
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
const performanceInformation = await this.portfolioService.getPerformance(
|
this.request.user.subscription.type === 'Basic'
|
||||||
impersonationId,
|
) {
|
||||||
range
|
throw new HttpException(
|
||||||
);
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
if (performanceInformation?.hasErrors) {
|
);
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let performance = performanceInformation.performance;
|
let investments = await this.portfolioServiceStrategy
|
||||||
|
.get()
|
||||||
|
.getInvestments(impersonationId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
performance = nullifyValuesInObject(performance, [
|
const maxInvestment = investments.reduce(
|
||||||
'currentGrossPerformance',
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
'currentValue'
|
1
|
||||||
]);
|
);
|
||||||
|
|
||||||
|
investments = investments.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
investment: item.investment / maxInvestment
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(performance);
|
return { firstOrderDate: parseDate(investments[0]?.date), investments };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('performance')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getPerformance(
|
||||||
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('range') range
|
||||||
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
|
const performanceInformation = await this.portfolioServiceStrategy
|
||||||
|
.get()
|
||||||
|
.getPerformance(impersonationId, range);
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.request.user.Settings.viewMode === ViewMode.ZEN ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
performanceInformation.performance = nullifyValuesInObject(
|
||||||
|
performanceInformation.performance,
|
||||||
|
['currentGrossPerformance', 'currentValue']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return performanceInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('positions')
|
@Get('positions')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range
|
||||||
@Res() res: Response
|
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const result = await this.portfolioService.getPositions(
|
const result = await this.portfolioServiceStrategy
|
||||||
impersonationId,
|
.get()
|
||||||
range
|
.getPositions(impersonationId, range);
|
||||||
);
|
|
||||||
|
|
||||||
if (result?.hasErrors) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -261,13 +252,12 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(result);
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('public/:accessId')
|
@Get('public/:accessId')
|
||||||
public async getPublic(
|
public async getPublic(
|
||||||
@Param('accessId') accessId,
|
@Param('accessId') accessId
|
||||||
@Res() res: Response
|
|
||||||
): Promise<PortfolioPublicDetails> {
|
): Promise<PortfolioPublicDetails> {
|
||||||
const access = await this.accessService.access({ id: accessId });
|
const access = await this.accessService.access({ id: accessId });
|
||||||
const user = await this.userService.user({
|
const user = await this.userService.user({
|
||||||
@ -275,8 +265,10 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!access) {
|
if (!access) {
|
||||||
res.status(StatusCodes.NOT_FOUND);
|
throw new HttpException(
|
||||||
return <any>res.json({ accounts: {}, holdings: {} });
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasDetails = true;
|
let hasDetails = true;
|
||||||
@ -284,10 +276,9 @@ export class PortfolioController {
|
|||||||
hasDetails = user.subscription.type === 'Premium';
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { holdings } = await this.portfolioService.getDetails(
|
const { holdings } = await this.portfolioServiceStrategy
|
||||||
access.userId,
|
.get(true)
|
||||||
access.userId
|
.getDetails(access.userId, access.userId);
|
||||||
);
|
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
hasDetails,
|
hasDetails,
|
||||||
@ -313,6 +304,7 @@ export class PortfolioController {
|
|||||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: portfolioPosition.currency,
|
currency: portfolioPosition.currency,
|
||||||
|
markets: portfolioPosition.markets,
|
||||||
name: portfolioPosition.name,
|
name: portfolioPosition.name,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
value: portfolioPosition.value / totalValue
|
value: portfolioPosition.value / totalValue
|
||||||
@ -320,7 +312,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(portfolioPublicDetails);
|
return portfolioPublicDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@ -328,7 +320,19 @@ export class PortfolioController {
|
|||||||
public async getSummary(
|
public async getSummary(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<PortfolioSummary> {
|
): Promise<PortfolioSummary> {
|
||||||
let summary = await this.portfolioService.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.portfolioServiceStrategy
|
||||||
|
.get()
|
||||||
|
.getSummary(impersonationId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -340,7 +344,10 @@ export class PortfolioController {
|
|||||||
'currentGrossPerformance',
|
'currentGrossPerformance',
|
||||||
'currentNetPerformance',
|
'currentNetPerformance',
|
||||||
'currentValue',
|
'currentValue',
|
||||||
|
'dividend',
|
||||||
|
'emergencyFund',
|
||||||
'fees',
|
'fees',
|
||||||
|
'items',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
'totalSell'
|
'totalSell'
|
||||||
@ -350,16 +357,18 @@ export class PortfolioController {
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('position/:symbol')
|
@Get('position/:dataSource/:symbol')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Param('dataSource') dataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioService.getPosition(
|
let position = await this.portfolioServiceStrategy
|
||||||
impersonationId,
|
.get()
|
||||||
symbol
|
.getPosition(dataSource, impersonationId, symbol);
|
||||||
);
|
|
||||||
|
|
||||||
if (position) {
|
if (position) {
|
||||||
if (
|
if (
|
||||||
@ -370,6 +379,7 @@ export class PortfolioController {
|
|||||||
'grossPerformance',
|
'grossPerformance',
|
||||||
'investment',
|
'investment',
|
||||||
'netPerformance',
|
'netPerformance',
|
||||||
|
'orders',
|
||||||
'quantity',
|
'quantity',
|
||||||
'value'
|
'value'
|
||||||
]);
|
]);
|
||||||
@ -387,19 +397,18 @@ export class PortfolioController {
|
|||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string
|
||||||
@Res() res: Response
|
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.subscription.type === 'Basic'
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
res.status(StatusCodes.FORBIDDEN);
|
throw new HttpException(
|
||||||
return <any>res.json({ rules: [] });
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>(
|
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
|
||||||
res.json(await this.portfolioService.getReport(impersonationId))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,12 +13,15 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { PortfolioServiceNew } from './portfolio.service-new';
|
||||||
import { RulesService } from './rules.service';
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: [PortfolioService],
|
controllers: [PortfolioController],
|
||||||
|
exports: [PortfolioServiceStrategy],
|
||||||
imports: [
|
imports: [
|
||||||
AccessModule,
|
AccessModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
@ -32,11 +35,12 @@ import { RulesService } from './rules.service';
|
|||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [PortfolioController],
|
|
||||||
providers: [
|
providers: [
|
||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
|
PortfolioServiceNew,
|
||||||
|
PortfolioServiceStrategy,
|
||||||
RulesService
|
RulesService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
1324
apps/api/src/app/portfolio/portfolio.service-new.ts
Normal file
1324
apps/api/src/app/portfolio/portfolio.service-new.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,8 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
|
|||||||
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
||||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
|
||||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
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';
|
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||||
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
||||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||||
@ -22,15 +23,15 @@ import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
|||||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
|
ASSET_SUB_CLASS_EMERGENCY_FUND,
|
||||||
UNKNOWN_KEY,
|
UNKNOWN_KEY,
|
||||||
baseCurrency,
|
baseCurrency
|
||||||
ghostfolioCashSymbol
|
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
Accounts,
|
Accounts,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
Position,
|
Position,
|
||||||
@ -60,7 +61,7 @@ import {
|
|||||||
subDays,
|
subDays,
|
||||||
subYears
|
subYears
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty, sortBy } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HistoricalDataContainer,
|
HistoricalDataContainer,
|
||||||
@ -80,7 +81,8 @@ export class PortfolioService {
|
|||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly rulesService: RulesService,
|
private readonly rulesService: RulesService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService,
|
||||||
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
|
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
|
||||||
@ -104,15 +106,22 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0;
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
...account,
|
...account,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
convertedBalance: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency,
|
||||||
|
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
account.balance,
|
account.balance,
|
||||||
account.currency,
|
account.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
value: details.accounts[account.name]?.current ?? 0
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
|
valueInBaseCurrency,
|
||||||
|
userCurrency,
|
||||||
|
account.currency
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
delete result.Order;
|
delete result.Order;
|
||||||
@ -123,17 +132,26 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
|
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
|
||||||
const accounts = await this.getAccounts(aUserId);
|
const accounts = await this.getAccounts(aUserId);
|
||||||
let totalBalance = 0;
|
let totalBalanceInBaseCurrency = new Big(0);
|
||||||
let totalValue = 0;
|
let totalValueInBaseCurrency = new Big(0);
|
||||||
let transactionCount = 0;
|
let transactionCount = 0;
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
totalBalance += account.convertedBalance;
|
totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
|
||||||
totalValue += account.value;
|
account.balanceInBaseCurrency
|
||||||
|
);
|
||||||
|
totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
|
||||||
|
account.valueInBaseCurrency
|
||||||
|
);
|
||||||
transactionCount += account.transactionCount;
|
transactionCount += account.transactionCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { accounts, totalBalance, totalValue, transactionCount };
|
return {
|
||||||
|
accounts,
|
||||||
|
transactionCount,
|
||||||
|
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
|
||||||
|
totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@ -155,12 +173,33 @@ export class PortfolioService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolioCalculator.getInvestments().map((item) => {
|
const investments = portfolioCalculator.getInvestments().map((item) => {
|
||||||
return {
|
return {
|
||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment.toNumber()
|
investment: item.investment.toNumber()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
});
|
||||||
|
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
||||||
|
|
||||||
|
investments.push({
|
||||||
|
date: format(new Date(), DATE_FORMAT),
|
||||||
|
investment: lastInvestment?.investment ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(investments, (investment) => {
|
||||||
|
return investment.date;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChart(
|
public async getChart(
|
||||||
@ -254,8 +293,15 @@ export class PortfolioService {
|
|||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||||
const userId = await this.getUserId(aImpersonationId, aUserId);
|
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||||
|
const user = await this.userService.user({ id: userId });
|
||||||
|
|
||||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
const emergencyFund = new Big(
|
||||||
|
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||||
|
);
|
||||||
|
const userCurrency =
|
||||||
|
this.request.user?.Settings?.currency ??
|
||||||
|
user.Settings?.currency ??
|
||||||
|
baseCurrency;
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
userCurrency
|
userCurrency
|
||||||
@ -265,13 +311,11 @@ export class PortfolioService {
|
|||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (transactionPoints?.length <= 0) {
|
|
||||||
return { accounts: {}, holdings: {}, hasErrors: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
const portfolioStart = parseDate(
|
||||||
|
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
|
||||||
|
);
|
||||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
startDate
|
startDate
|
||||||
@ -284,9 +328,11 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const holdings: PortfolioDetails['holdings'] = {};
|
const holdings: PortfolioDetails['holdings'] = {};
|
||||||
const totalInvestment = currentPositions.totalInvestment.plus(
|
const totalInvestment = currentPositions.totalInvestment.plus(
|
||||||
cashDetails.balance
|
cashDetails.balanceInBaseCurrency
|
||||||
|
);
|
||||||
|
const totalValue = currentPositions.currentValue.plus(
|
||||||
|
cashDetails.balanceInBaseCurrency
|
||||||
);
|
);
|
||||||
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
|
|
||||||
|
|
||||||
const dataGatheringItems = currentPositions.positions.map((position) => {
|
const dataGatheringItems = currentPositions.positions.map((position) => {
|
||||||
return {
|
return {
|
||||||
@ -299,7 +345,7 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.get(dataGatheringItems),
|
this.dataProviderService.getQuotes(dataGatheringItems),
|
||||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -329,7 +375,7 @@ export class PortfolioService {
|
|||||||
assetSubClass: symbolProfile.assetSubClass,
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
countries: symbolProfile.countries,
|
countries: symbolProfile.countries,
|
||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
exchange: dataProviderResponse.exchange,
|
dataSource: symbolProfile.dataSource,
|
||||||
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
||||||
grossPerformancePercent:
|
grossPerformancePercent:
|
||||||
item.grossPerformancePercentage?.toNumber() ?? 0,
|
item.grossPerformancePercentage?.toNumber() ?? 0,
|
||||||
@ -349,6 +395,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const cashPositions = await this.getCashPositions({
|
const cashPositions = await this.getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
|
emergencyFund,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
investment: totalInvestment,
|
investment: totalInvestment,
|
||||||
value: totalValue
|
value: totalValue
|
||||||
@ -369,19 +416,25 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
|
aDataSource: DataSource,
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
const orders = (
|
||||||
(order) => order.symbol === aSymbol
|
await this.orderService.getOrders({ userCurrency, userId })
|
||||||
);
|
).filter(({ SymbolProfile }) => {
|
||||||
|
return (
|
||||||
|
SymbolProfile.dataSource === aDataSource &&
|
||||||
|
SymbolProfile.symbol === aSymbol
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return {
|
return {
|
||||||
averagePrice: undefined,
|
averagePrice: undefined,
|
||||||
currency: undefined,
|
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
grossPerformance: undefined,
|
grossPerformance: undefined,
|
||||||
grossPerformancePercent: undefined,
|
grossPerformancePercent: undefined,
|
||||||
@ -390,32 +443,36 @@ export class PortfolioService {
|
|||||||
marketPrice: undefined,
|
marketPrice: undefined,
|
||||||
maxPrice: undefined,
|
maxPrice: undefined,
|
||||||
minPrice: undefined,
|
minPrice: undefined,
|
||||||
name: undefined,
|
|
||||||
netPerformance: undefined,
|
netPerformance: undefined,
|
||||||
netPerformancePercent: undefined,
|
netPerformancePercent: undefined,
|
||||||
|
orders: [],
|
||||||
quantity: undefined,
|
quantity: undefined,
|
||||||
symbol: aSymbol,
|
SymbolProfile: undefined,
|
||||||
transactionCount: undefined,
|
transactionCount: undefined,
|
||||||
value: undefined
|
value: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetClass = orders[0].SymbolProfile?.assetClass;
|
const positionCurrency = orders[0].SymbolProfile.currency;
|
||||||
const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
|
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
const positionCurrency = orders[0].currency;
|
aSymbol
|
||||||
const name = orders[0].SymbolProfile?.name ?? '';
|
]);
|
||||||
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders
|
||||||
currency: order.currency,
|
.filter((order) => {
|
||||||
dataSource: order.dataSource,
|
return order.type === 'BUY' || order.type === 'SELL';
|
||||||
date: format(order.date, DATE_FORMAT),
|
})
|
||||||
fee: new Big(order.fee),
|
.map((order) => ({
|
||||||
name: order.SymbolProfile?.name,
|
currency: order.SymbolProfile.currency,
|
||||||
quantity: new Big(order.quantity),
|
dataSource: order.SymbolProfile.dataSource,
|
||||||
symbol: order.symbol,
|
date: format(order.date, DATE_FORMAT),
|
||||||
type: <OrderType>order.type,
|
fee: new Big(order.fee),
|
||||||
unitPrice: new Big(order.unitPrice)
|
name: order.SymbolProfile?.name,
|
||||||
}));
|
quantity: new Big(order.quantity),
|
||||||
|
symbol: order.SymbolProfile.symbol,
|
||||||
|
type: order.type,
|
||||||
|
unitPrice: new Big(order.unitPrice)
|
||||||
|
}));
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -445,19 +502,18 @@ export class PortfolioService {
|
|||||||
} = position;
|
} = position;
|
||||||
|
|
||||||
// Convert investment, gross and net performance to currency of user
|
// Convert investment, gross and net performance to currency of user
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
|
||||||
const investment = this.exchangeRateDataService.toCurrency(
|
const investment = this.exchangeRateDataService.toCurrency(
|
||||||
position.investment.toNumber(),
|
position.investment?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
const grossPerformance = this.exchangeRateDataService.toCurrency(
|
const grossPerformance = this.exchangeRateDataService.toCurrency(
|
||||||
position.grossPerformance.toNumber(),
|
position.grossPerformance?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
const netPerformance = this.exchangeRateDataService.toCurrency(
|
const netPerformance = this.exchangeRateDataService.toCurrency(
|
||||||
position.netPerformance.toNumber(),
|
position.netPerformance?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
@ -515,24 +571,22 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assetClass,
|
|
||||||
assetSubClass,
|
|
||||||
currency,
|
|
||||||
firstBuyDate,
|
firstBuyDate,
|
||||||
grossPerformance,
|
grossPerformance,
|
||||||
investment,
|
investment,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
maxPrice,
|
maxPrice,
|
||||||
minPrice,
|
minPrice,
|
||||||
name,
|
|
||||||
netPerformance,
|
netPerformance,
|
||||||
|
orders,
|
||||||
|
SymbolProfile,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
averagePrice: averagePrice.toNumber(),
|
averagePrice: averagePrice.toNumber(),
|
||||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
grossPerformancePercent:
|
||||||
|
position.grossPerformancePercentage?.toNumber(),
|
||||||
historicalData: historicalDataArray,
|
historicalData: historicalDataArray,
|
||||||
netPerformancePercent: position.netPerformancePercentage.toNumber(),
|
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
|
||||||
quantity: quantity.toNumber(),
|
quantity: quantity.toNumber(),
|
||||||
symbol: aSymbol,
|
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
quantity.mul(marketPrice).toNumber(),
|
quantity.mul(marketPrice).toNumber(),
|
||||||
currency,
|
currency,
|
||||||
@ -540,7 +594,7 @@ export class PortfolioService {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const currentData = await this.dataProviderService.get([
|
const currentData = await this.dataProviderService.getQuotes([
|
||||||
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
|
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
|
||||||
]);
|
]);
|
||||||
const marketPrice = currentData[aSymbol]?.marketPrice;
|
const marketPrice = currentData[aSymbol]?.marketPrice;
|
||||||
@ -577,14 +631,12 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assetClass,
|
|
||||||
assetSubClass,
|
|
||||||
marketPrice,
|
marketPrice,
|
||||||
maxPrice,
|
maxPrice,
|
||||||
minPrice,
|
minPrice,
|
||||||
name,
|
orders,
|
||||||
|
SymbolProfile,
|
||||||
averagePrice: 0,
|
averagePrice: 0,
|
||||||
currency: currentData[aSymbol]?.currency,
|
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
grossPerformance: undefined,
|
grossPerformance: undefined,
|
||||||
grossPerformancePercent: undefined,
|
grossPerformancePercent: undefined,
|
||||||
@ -593,7 +645,6 @@ export class PortfolioService {
|
|||||||
netPerformance: undefined,
|
netPerformance: undefined,
|
||||||
netPerformancePercent: undefined,
|
netPerformancePercent: undefined,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
symbol: aSymbol,
|
|
||||||
transactionCount: undefined,
|
transactionCount: undefined,
|
||||||
value: 0
|
value: 0
|
||||||
};
|
};
|
||||||
@ -640,7 +691,7 @@ export class PortfolioService {
|
|||||||
const symbols = positions.map((position) => position.symbol);
|
const symbols = positions.map((position) => position.symbol);
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.get(dataGatheringItem),
|
this.dataProviderService.getQuotes(dataGatheringItem),
|
||||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -660,7 +711,9 @@ export class PortfolioService {
|
|||||||
grossPerformancePercentage:
|
grossPerformancePercentage:
|
||||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||||
investment: new Big(position.investment).toNumber(),
|
investment: new Big(position.investment).toNumber(),
|
||||||
marketState: dataProviderResponses[position.symbol].marketState,
|
marketState:
|
||||||
|
dataProviderResponses[position.symbol]?.marketState ??
|
||||||
|
MarketState.delayed,
|
||||||
name: symbolProfileMap[position.symbol].name,
|
name: symbolProfileMap[position.symbol].name,
|
||||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||||
netPerformancePercentage:
|
netPerformancePercentage:
|
||||||
@ -674,7 +727,7 @@ export class PortfolioService {
|
|||||||
public async getPerformance(
|
public async getPerformance(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
@ -693,9 +746,7 @@ export class PortfolioService {
|
|||||||
currentGrossPerformancePercent: 0,
|
currentGrossPerformancePercent: 0,
|
||||||
currentNetPerformance: 0,
|
currentNetPerformance: 0,
|
||||||
currentNetPerformancePercent: 0,
|
currentNetPerformancePercent: 0,
|
||||||
currentValue: 0,
|
currentValue: 0
|
||||||
isAllTimeHigh: false,
|
|
||||||
isAllTimeLow: false
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -728,29 +779,11 @@ export class PortfolioService {
|
|||||||
currentGrossPerformancePercent,
|
currentGrossPerformancePercent,
|
||||||
currentNetPerformance,
|
currentNetPerformance,
|
||||||
currentNetPerformancePercent,
|
currentNetPerformancePercent,
|
||||||
currentValue,
|
currentValue
|
||||||
isAllTimeHigh: true,
|
|
||||||
isAllTimeLow: false
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
|
||||||
return orders
|
|
||||||
.filter((order) => {
|
|
||||||
// Filter out all orders before given date
|
|
||||||
return isBefore(date, new Date(order.date));
|
|
||||||
})
|
|
||||||
.map((order) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
order.fee,
|
|
||||||
order.currency,
|
|
||||||
this.request.user.Settings.currency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce((previous, current) => previous + current, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||||
const currency = this.request.user.Settings.currency;
|
const currency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
@ -831,7 +864,7 @@ export class PortfolioService {
|
|||||||
new FeeRatioInitialInvestment(
|
new FeeRatioInitialInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions.totalInvestment.toNumber(),
|
currentPositions.totalInvestment.toNumber(),
|
||||||
this.getFees(orders)
|
this.getFees(orders).toNumber()
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency: currency }
|
{ baseCurrency: currency }
|
||||||
@ -841,53 +874,73 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||||
const currency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
const user = await this.userService.user({ id: userId });
|
||||||
|
|
||||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||||
|
|
||||||
const { balance } = await this.accountService.getCashDetails(
|
const { balanceInBaseCurrency } = await this.accountService.getCashDetails(
|
||||||
userId,
|
userId,
|
||||||
currency
|
userCurrency
|
||||||
);
|
);
|
||||||
const orders = await this.orderService.getOrders({ userId });
|
const orders = await this.orderService.getOrders({
|
||||||
const fees = this.getFees(orders);
|
userCurrency,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
const dividend = this.getDividend(orders).toNumber();
|
||||||
|
const emergencyFund = new Big(
|
||||||
|
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||||
|
);
|
||||||
|
const fees = this.getFees(orders).toNumber();
|
||||||
const firstOrderDate = orders[0]?.date;
|
const firstOrderDate = orders[0]?.date;
|
||||||
|
const items = this.getItems(orders).toNumber();
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
|
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||||
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
|
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||||
|
|
||||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
|
||||||
|
const committedFunds = new Big(totalBuy).minus(totalSell);
|
||||||
|
|
||||||
const netWorth = new Big(balance)
|
const netWorth = new Big(balanceInBaseCurrency)
|
||||||
.plus(performanceInformation.performance.currentValue)
|
.plus(performanceInformation.performance.currentValue)
|
||||||
|
.plus(items)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...performanceInformation.performance,
|
...performanceInformation.performance,
|
||||||
|
cash,
|
||||||
|
dividend,
|
||||||
fees,
|
fees,
|
||||||
firstOrderDate,
|
firstOrderDate,
|
||||||
|
items,
|
||||||
netWorth,
|
netWorth,
|
||||||
cash: balance,
|
totalBuy,
|
||||||
|
totalSell,
|
||||||
|
annualizedPerformancePercent:
|
||||||
|
performanceInformation.performance.annualizedPerformancePercent,
|
||||||
committedFunds: committedFunds.toNumber(),
|
committedFunds: committedFunds.toNumber(),
|
||||||
ordersCount: orders.length,
|
emergencyFund: emergencyFund.toNumber(),
|
||||||
totalBuy: totalBuy,
|
ordersCount: orders.filter((order) => {
|
||||||
totalSell: totalSell
|
return order.type === 'BUY' || order.type === 'SELL';
|
||||||
|
}).length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getCashPositions({
|
private async getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
|
emergencyFund,
|
||||||
investment,
|
investment,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
value
|
value
|
||||||
}: {
|
}: {
|
||||||
cashDetails: CashDetails;
|
cashDetails: CashDetails;
|
||||||
|
emergencyFund: Big;
|
||||||
investment: Big;
|
investment: Big;
|
||||||
value: Big;
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
|
value: Big;
|
||||||
}) {
|
}) {
|
||||||
const cashPositions = {};
|
const cashPositions: PortfolioDetails['holdings'] = {};
|
||||||
|
|
||||||
for (const account of cashDetails.accounts) {
|
for (const account of cashDetails.accounts) {
|
||||||
const convertedBalance = this.exchangeRateDataService.toCurrency(
|
const convertedBalance = this.exchangeRateDataService.toCurrency(
|
||||||
@ -911,6 +964,7 @@ export class PortfolioService {
|
|||||||
assetSubClass: AssetClass.CASH,
|
assetSubClass: AssetClass.CASH,
|
||||||
countries: [],
|
countries: [],
|
||||||
currency: account.currency,
|
currency: account.currency,
|
||||||
|
dataSource: undefined,
|
||||||
grossPerformance: 0,
|
grossPerformance: 0,
|
||||||
grossPerformancePercent: 0,
|
grossPerformancePercent: 0,
|
||||||
investment: convertedBalance,
|
investment: convertedBalance,
|
||||||
@ -928,6 +982,28 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (emergencyFund.gt(0)) {
|
||||||
|
cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = {
|
||||||
|
...cashPositions[userCurrency],
|
||||||
|
assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND,
|
||||||
|
investment: emergencyFund.toNumber(),
|
||||||
|
name: ASSET_SUB_CLASS_EMERGENCY_FUND,
|
||||||
|
symbol: ASSET_SUB_CLASS_EMERGENCY_FUND,
|
||||||
|
value: emergencyFund.toNumber()
|
||||||
|
};
|
||||||
|
|
||||||
|
cashPositions[userCurrency].investment = new Big(
|
||||||
|
cashPositions[userCurrency].investment
|
||||||
|
)
|
||||||
|
.minus(emergencyFund)
|
||||||
|
.toNumber();
|
||||||
|
cashPositions[userCurrency].value = new Big(
|
||||||
|
cashPositions[userCurrency].value
|
||||||
|
)
|
||||||
|
.minus(emergencyFund)
|
||||||
|
.toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
for (const symbol of Object.keys(cashPositions)) {
|
for (const symbol of Object.keys(cashPositions)) {
|
||||||
// Calculate allocations for each currency
|
// Calculate allocations for each currency
|
||||||
cashPositions[symbol].allocationCurrent = new Big(
|
cashPositions[symbol].allocationCurrent = new Big(
|
||||||
@ -945,6 +1021,69 @@ export class PortfolioService {
|
|||||||
return cashPositions;
|
return cashPositions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
|
||||||
|
return orders
|
||||||
|
.filter((order) => {
|
||||||
|
// Filter out all orders before given date and type dividend
|
||||||
|
return (
|
||||||
|
isBefore(date, new Date(order.date)) &&
|
||||||
|
order.type === TypeOfOrder.DIVIDEND
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||||
|
return orders
|
||||||
|
.filter((order) => {
|
||||||
|
// Filter out all orders before given date
|
||||||
|
return isBefore(date, new Date(order.date));
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
order.fee,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
|
||||||
|
return orders
|
||||||
|
.filter((order) => {
|
||||||
|
// Filter out all orders before given date and type item
|
||||||
|
return (
|
||||||
|
isBefore(date, new Date(order.date)) &&
|
||||||
|
order.type === TypeOfOrder.ITEM
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||||
switch (aDateRange) {
|
switch (aDateRange) {
|
||||||
case '1d':
|
case '1d':
|
||||||
@ -973,32 +1112,38 @@ export class PortfolioService {
|
|||||||
transactionPoints: TransactionPoint[];
|
transactionPoints: TransactionPoint[];
|
||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
}> {
|
}> {
|
||||||
const orders = await this.orderService.getOrders({ includeDrafts, userId });
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
|
|
||||||
|
const orders = await this.orderService.getOrders({
|
||||||
|
includeDrafts,
|
||||||
|
userCurrency,
|
||||||
|
userId,
|
||||||
|
types: ['BUY', 'SELL']
|
||||||
|
});
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return { transactionPoints: [], orders: [] };
|
return { transactionPoints: [], orders: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||||
currency: order.currency,
|
currency: order.SymbolProfile.currency,
|
||||||
dataSource: order.dataSource,
|
dataSource: order.SymbolProfile.dataSource,
|
||||||
date: format(order.date, DATE_FORMAT),
|
date: format(order.date, DATE_FORMAT),
|
||||||
fee: new Big(
|
fee: new Big(
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
order.fee,
|
order.fee,
|
||||||
order.currency,
|
order.SymbolProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
name: order.SymbolProfile?.name,
|
name: order.SymbolProfile?.name,
|
||||||
quantity: new Big(order.quantity),
|
quantity: new Big(order.quantity),
|
||||||
symbol: order.symbol,
|
symbol: order.SymbolProfile.symbol,
|
||||||
type: <OrderType>order.type,
|
type: order.type,
|
||||||
unitPrice: new Big(
|
unitPrice: new Big(
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
order.unitPrice,
|
order.unitPrice,
|
||||||
order.currency,
|
order.SymbolProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -1030,21 +1175,18 @@ export class PortfolioService {
|
|||||||
return accountId === account.id;
|
return accountId === account.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
const convertedBalance = this.exchangeRateDataService.toCurrency(
|
accounts[account.id] = {
|
||||||
account.balance,
|
balance: account.balance,
|
||||||
account.currency,
|
|
||||||
userCurrency
|
|
||||||
);
|
|
||||||
accounts[account.name] = {
|
|
||||||
balance: convertedBalance,
|
|
||||||
currency: account.currency,
|
currency: account.currency,
|
||||||
current: convertedBalance,
|
current: account.balance,
|
||||||
original: convertedBalance
|
name: account.name,
|
||||||
|
original: account.balance
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const order of ordersByAccount) {
|
for (const order of ordersByAccount) {
|
||||||
let currentValueOfSymbol =
|
let currentValueOfSymbol =
|
||||||
order.quantity * portfolioItemsNow[order.symbol].marketPrice;
|
order.quantity *
|
||||||
|
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
|
||||||
let originalValueOfSymbol = order.quantity * order.unitPrice;
|
let originalValueOfSymbol = order.quantity * order.unitPrice;
|
||||||
|
|
||||||
if (order.type === 'SELL') {
|
if (order.type === 'SELL') {
|
||||||
@ -1052,16 +1194,17 @@ export class PortfolioService {
|
|||||||
originalValueOfSymbol *= -1;
|
originalValueOfSymbol *= -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
||||||
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
||||||
currentValueOfSymbol;
|
currentValueOfSymbol;
|
||||||
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
||||||
originalValueOfSymbol;
|
originalValueOfSymbol;
|
||||||
} else {
|
} else {
|
||||||
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||||
balance: 0,
|
balance: 0,
|
||||||
currency: order.Account?.currency,
|
currency: order.Account?.currency,
|
||||||
current: currentValueOfSymbol,
|
current: currentValueOfSymbol,
|
||||||
|
name: account.name,
|
||||||
original: originalValueOfSymbol
|
original: originalValueOfSymbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1093,7 +1236,7 @@ export class PortfolioService {
|
|||||||
.map((order) => {
|
.map((order) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
order.quantity * order.unitPrice,
|
order.quantity * order.unitPrice,
|
||||||
order.currency,
|
order.SymbolProfile.currency,
|
||||||
currency
|
currency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { CacheModule, Module } from '@nestjs/common';
|
import { CacheModule, Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
@ -17,9 +18,10 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
store: redisStore,
|
store: redisStore,
|
||||||
ttl: configurationService.get('CACHE_TTL')
|
ttl: configurationService.get('CACHE_TTL')
|
||||||
})
|
})
|
||||||
})
|
}),
|
||||||
|
ConfigurationModule
|
||||||
],
|
],
|
||||||
providers: [ConfigurationService, RedisCacheService],
|
providers: [RedisCacheService],
|
||||||
exports: [RedisCacheService]
|
exports: [RedisCacheService]
|
||||||
})
|
})
|
||||||
export class RedisCacheModule {}
|
export class RedisCacheModule {}
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
@ -17,7 +18,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Response } from 'express';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -32,11 +32,9 @@ export class SubscriptionController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('redeem-coupon')
|
@Post('redeem-coupon')
|
||||||
|
@HttpCode(StatusCodes.OK)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async redeemCoupon(
|
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
|
||||||
@Body() { couponCode }: { couponCode: string },
|
|
||||||
@Res() res: Response
|
|
||||||
) {
|
|
||||||
if (!this.request.user) {
|
if (!this.request.user) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -48,44 +46,53 @@ export class SubscriptionController {
|
|||||||
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
||||||
[];
|
[];
|
||||||
|
|
||||||
const isValid = coupons.some((coupon) => {
|
const coupon = coupons.find((currentCoupon) => {
|
||||||
return coupon.code === couponCode;
|
return currentCoupon.code === couponCode;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isValid) {
|
if (coupon === undefined) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
StatusCodes.BAD_REQUEST
|
StatusCodes.BAD_REQUEST
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.subscriptionService.createSubscription(this.request.user.id);
|
await this.subscriptionService.createSubscription({
|
||||||
|
duration: coupon.duration,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
// Destroy coupon
|
// Destroy coupon
|
||||||
coupons = coupons.filter((coupon) => {
|
coupons = coupons.filter((currentCoupon) => {
|
||||||
return coupon.code !== couponCode;
|
return currentCoupon.code !== couponCode;
|
||||||
});
|
});
|
||||||
await this.propertyService.put({
|
await this.propertyService.put({
|
||||||
key: PROPERTY_COUPONS,
|
key: PROPERTY_COUPONS,
|
||||||
value: JSON.stringify(coupons)
|
value: JSON.stringify(coupons)
|
||||||
});
|
});
|
||||||
|
|
||||||
Logger.log(`Coupon with code '${couponCode}' has been redeemed`);
|
Logger.log(
|
||||||
|
`Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`,
|
||||||
|
'SubscriptionController'
|
||||||
|
);
|
||||||
|
|
||||||
res.status(StatusCodes.OK);
|
return {
|
||||||
|
|
||||||
return <any>res.json({
|
|
||||||
message: getReasonPhrase(StatusCodes.OK),
|
message: getReasonPhrase(StatusCodes.OK),
|
||||||
statusCode: StatusCodes.OK
|
statusCode: StatusCodes.OK
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('stripe/callback')
|
@Get('stripe/callback')
|
||||||
public async stripeCallback(@Req() req, @Res() res) {
|
public async stripeCallback(@Req() req, @Res() res) {
|
||||||
await this.subscriptionService.createSubscriptionViaStripe(
|
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||||
req.query.checkoutSessionId
|
req.query.checkoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
`Subscription for user '${userId}' has been created via Stripe`,
|
||||||
|
'SubscriptionController'
|
||||||
|
);
|
||||||
|
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,7 +108,7 @@ export class SubscriptionController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'SubscriptionController');
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -7,9 +7,9 @@ import { SubscriptionController } from './subscription.controller';
|
|||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PropertyModule],
|
|
||||||
controllers: [SubscriptionController],
|
controllers: [SubscriptionController],
|
||||||
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
exports: [SubscriptionService],
|
||||||
exports: [SubscriptionService]
|
imports: [ConfigurationModule, PrismaModule, PropertyModule],
|
||||||
|
providers: [SubscriptionService]
|
||||||
})
|
})
|
||||||
export class SubscriptionModule {}
|
export class SubscriptionModule {}
|
||||||
|
@ -2,8 +2,9 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription, User } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
import { addDays, isBefore } from 'date-fns';
|
import { addMilliseconds, isBefore } from 'date-fns';
|
||||||
|
import ms, { StringValue } from 'ms';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -44,7 +45,7 @@ export class SubscriptionService {
|
|||||||
payment_method_types: ['card'],
|
payment_method_types: ['card'],
|
||||||
success_url: `${this.configurationService.get(
|
success_url: `${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
|
)}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (couponId) {
|
if (couponId) {
|
||||||
@ -64,19 +65,23 @@ export class SubscriptionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createSubscription(aUserId: string) {
|
public async createSubscription({
|
||||||
|
duration = '1 year',
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
duration?: StringValue;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
await this.prismaService.subscription.create({
|
await this.prismaService.subscription.create({
|
||||||
data: {
|
data: {
|
||||||
expiresAt: addDays(new Date(), 365),
|
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||||
User: {
|
User: {
|
||||||
connect: {
|
connect: {
|
||||||
id: aUserId
|
id: userId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Logger.log(`Subscription for user '${aUserId}' has been created`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
||||||
@ -85,13 +90,15 @@ export class SubscriptionService {
|
|||||||
aCheckoutSessionId
|
aCheckoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.createSubscription(session.client_reference_id);
|
await this.createSubscription({ userId: session.client_reference_id });
|
||||||
|
|
||||||
await this.stripe.customers.update(session.customer as string, {
|
await this.stripe.customers.update(session.customer as string, {
|
||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return session.client_reference_id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'SubscriptionService');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,20 +1,19 @@
|
|||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
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 { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
DefaultValuePipe,
|
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
|
||||||
Param,
|
Param,
|
||||||
ParseBoolPipe,
|
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
import { isEmpty } from 'lodash';
|
import { isDate, isEmpty } from 'lodash';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||||
@ -22,22 +21,19 @@ import { SymbolService } from './symbol.service';
|
|||||||
|
|
||||||
@Controller('symbol')
|
@Controller('symbol')
|
||||||
export class SymbolController {
|
export class SymbolController {
|
||||||
public constructor(
|
public constructor(private readonly symbolService: SymbolService) {}
|
||||||
private readonly symbolService: SymbolService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be before /:symbol
|
* Must be before /:symbol
|
||||||
*/
|
*/
|
||||||
@Get('lookup')
|
@Get('lookup')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async lookupSymbol(
|
public async lookupSymbol(
|
||||||
@Query() { query = '' }
|
@Query() { query = '' }
|
||||||
): Promise<{ items: LookupItem[] }> {
|
): Promise<{ items: LookupItem[] }> {
|
||||||
try {
|
try {
|
||||||
const encodedQuery = encodeURIComponent(query.toLowerCase());
|
return this.symbolService.lookup(query.toLowerCase());
|
||||||
return this.symbolService.lookup(encodedQuery);
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
@ -51,11 +47,12 @@ export class SymbolController {
|
|||||||
*/
|
*/
|
||||||
@Get(':dataSource/:symbol')
|
@Get(':dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getSymbolData(
|
public async getSymbolData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
|
@Query('includeHistoricalData') includeHistoricalData?: number
|
||||||
includeHistoricalData: boolean
|
|
||||||
): Promise<SymbolItem> {
|
): Promise<SymbolItem> {
|
||||||
if (!DataSource[dataSource]) {
|
if (!DataSource[dataSource]) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -78,4 +75,27 @@ export class SymbolController {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol/:dateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherSymbolForDate(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('dateString') dateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<IDataProviderHistoricalResponse> {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
if (!isDate(date)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.symbolService.getForDate({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,13 +8,14 @@ import { SymbolController } from './symbol.controller';
|
|||||||
import { SymbolService } from './symbol.service';
|
import { SymbolService } from './symbol.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [SymbolController],
|
||||||
|
exports: [SymbolService],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule
|
PrismaModule
|
||||||
],
|
],
|
||||||
controllers: [SymbolController],
|
|
||||||
providers: [SymbolService]
|
providers: [SymbolService]
|
||||||
})
|
})
|
||||||
export class SymbolModule {}
|
export class SymbolModule {}
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import {
|
||||||
|
IDataGatheringItem,
|
||||||
|
IDataProviderHistoricalResponse
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||||
@ -14,35 +17,36 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
|
|||||||
export class SymbolService {
|
export class SymbolService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService
|
||||||
private readonly prismaService: PrismaService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get({
|
public async get({
|
||||||
dataGatheringItem,
|
dataGatheringItem,
|
||||||
includeHistoricalData = false
|
includeHistoricalData
|
||||||
}: {
|
}: {
|
||||||
dataGatheringItem: IDataGatheringItem;
|
dataGatheringItem: IDataGatheringItem;
|
||||||
includeHistoricalData?: boolean;
|
includeHistoricalData?: number;
|
||||||
}): Promise<SymbolItem> {
|
}): Promise<SymbolItem> {
|
||||||
const response = await this.dataProviderService.get([dataGatheringItem]);
|
const quotes = await this.dataProviderService.getQuotes([
|
||||||
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
dataGatheringItem
|
||||||
|
]);
|
||||||
|
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
||||||
|
|
||||||
if (dataGatheringItem.dataSource && marketPrice) {
|
if (dataGatheringItem.dataSource && marketPrice) {
|
||||||
let historicalData: HistoricalDataItem[];
|
let historicalData: HistoricalDataItem[] = [];
|
||||||
|
|
||||||
if (includeHistoricalData) {
|
if (includeHistoricalData > 0) {
|
||||||
const days = 10;
|
const days = includeHistoricalData;
|
||||||
|
|
||||||
const marketData = await this.marketDataService.getRange({
|
const marketData = await this.marketDataService.getRange({
|
||||||
dateQuery: { gte: subDays(new Date(), days) },
|
dateQuery: { gte: subDays(new Date(), days) },
|
||||||
symbols: [dataGatheringItem.symbol]
|
symbols: [dataGatheringItem.symbol]
|
||||||
});
|
});
|
||||||
|
|
||||||
historicalData = marketData.map(({ date, marketPrice }) => {
|
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||||
return {
|
return {
|
||||||
date: date.toISOString(),
|
value,
|
||||||
value: marketPrice
|
date: date.toISOString()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -58,6 +62,27 @@ export class SymbolService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getForDate({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
date: Date;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<IDataProviderHistoricalResponse> {
|
||||||
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
|
[{ dataSource, symbol }],
|
||||||
|
date,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketPrice:
|
||||||
|
historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const results: { items: LookupItem[] } = { items: [] };
|
const results: { items: LookupItem[] } = { items: [] };
|
||||||
|
|
||||||
@ -68,35 +93,9 @@ export class SymbolService {
|
|||||||
try {
|
try {
|
||||||
const { items } = await this.dataProviderService.search(aQuery);
|
const { items } = await this.dataProviderService.search(aQuery);
|
||||||
results.items = items;
|
results.items = items;
|
||||||
|
|
||||||
// Add custom symbols
|
|
||||||
const ghostfolioSymbolProfiles =
|
|
||||||
await this.prismaService.symbolProfile.findMany({
|
|
||||||
select: {
|
|
||||||
currency: true,
|
|
||||||
dataSource: true,
|
|
||||||
name: true,
|
|
||||||
symbol: true
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
AND: [
|
|
||||||
{
|
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
|
||||||
name: {
|
|
||||||
startsWith: aQuery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
|
|
||||||
results.items.push(ghostfolioSymbolProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'SymbolService');
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
export interface UserItem {
|
export interface UserItem {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
authToken: string;
|
authToken: string;
|
||||||
|
role: Role;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
export interface UserSettings {
|
export interface UserSettings {
|
||||||
|
emergencyFund?: number;
|
||||||
|
locale?: string;
|
||||||
|
isNewCalculationEngine?: boolean;
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,19 @@
|
|||||||
import { IsBoolean } from 'class-validator';
|
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingDto {
|
export class UpdateUserSettingDto {
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
emergencyFund?: number;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isNewCalculationEngine?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
@ -2,17 +2,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
|||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
hasPermission,
|
|
||||||
hasRole,
|
|
||||||
permissions
|
|
||||||
} from '@ghostfolio/common/permissions';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
@ -23,7 +20,6 @@ import {
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Provider, Role } from '@prisma/client';
|
|
||||||
import { User as UserModel } from '@prisma/client';
|
import { User as UserModel } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@ -64,8 +60,13 @@ export class UserController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getUser(@Param('id') id: string): Promise<User> {
|
public async getUser(
|
||||||
return this.userService.getUser(this.request.user);
|
@Headers('accept-language') acceptLanguage: string
|
||||||
|
): Promise<User> {
|
||||||
|
return this.userService.getUser(
|
||||||
|
this.request.user,
|
||||||
|
acceptLanguage?.split(',')?.[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ -83,12 +84,15 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, id } = await this.userService.createUser({
|
const hasAdmin = await this.userService.hasAdmin();
|
||||||
provider: Provider.ANONYMOUS
|
|
||||||
|
const { accessToken, id, role } = await this.userService.createUser({
|
||||||
|
role: hasAdmin ? 'USER' : 'ADMIN'
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
|
role,
|
||||||
authToken: this.jwtService.sign({
|
authToken: this.jwtService.sign({
|
||||||
id
|
id
|
||||||
})
|
})
|
||||||
@ -115,6 +119,12 @@ export class UserController {
|
|||||||
...data
|
...data
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for (const key in userSettings) {
|
||||||
|
if (userSettings[key] === false || userSettings[key] === null) {
|
||||||
|
delete userSettings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await this.userService.updateUserSetting({
|
return await this.userService.updateUserSetting({
|
||||||
userSettings,
|
userSettings,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
@ -9,16 +9,18 @@ import { UserController } from './user.controller';
|
|||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [UserController],
|
||||||
|
exports: [UserService],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
}),
|
}),
|
||||||
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
SubscriptionModule
|
SubscriptionModule
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
providers: [UserService]
|
||||||
providers: [ConfigurationService, PrismaService, UserService],
|
|
||||||
exports: [UserService]
|
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
@ -15,7 +15,7 @@ import {
|
|||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client';
|
import { Prisma, Role, User, ViewMode } from '@prisma/client';
|
||||||
|
|
||||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||||
import { UserSettings } from './interfaces/user-settings.interface';
|
import { UserSettings } from './interfaces/user-settings.interface';
|
||||||
@ -33,14 +33,17 @@ export class UserService {
|
|||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getUser({
|
public async getUser(
|
||||||
Account,
|
{
|
||||||
alias,
|
Account,
|
||||||
id,
|
alias,
|
||||||
permissions,
|
id,
|
||||||
Settings,
|
permissions,
|
||||||
subscription
|
Settings,
|
||||||
}: UserWithSettings): Promise<IUser> {
|
subscription
|
||||||
|
}: UserWithSettings,
|
||||||
|
aLocale = locale
|
||||||
|
): Promise<IUser> {
|
||||||
const access = await this.prismaService.access.findMany({
|
const access = await this.prismaService.access.findMany({
|
||||||
include: {
|
include: {
|
||||||
User: true
|
User: true
|
||||||
@ -63,13 +66,25 @@ export class UserService {
|
|||||||
accounts: Account,
|
accounts: Account,
|
||||||
settings: {
|
settings: {
|
||||||
...(<UserSettings>Settings.settings),
|
...(<UserSettings>Settings.settings),
|
||||||
locale,
|
|
||||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||||
|
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
|
||||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async hasAdmin() {
|
||||||
|
const usersWithAdminRole = await this.users({
|
||||||
|
where: {
|
||||||
|
role: {
|
||||||
|
equals: 'ADMIN'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return usersWithAdminRole.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public isRestrictedView(aUser: UserWithSettings) {
|
public isRestrictedView(aUser: UserWithSettings) {
|
||||||
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
|
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
|
||||||
}
|
}
|
||||||
@ -168,7 +183,11 @@ export class UserService {
|
|||||||
return hash.digest('hex');
|
return hash.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
|
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
|
||||||
|
if (!data?.provider) {
|
||||||
|
data.provider = 'ANONYMOUS';
|
||||||
|
}
|
||||||
|
|
||||||
let user = await this.prismaService.user.create({
|
let user = await this.prismaService.user.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
@ -187,7 +206,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.provider === Provider.ANONYMOUS) {
|
if (data.provider === 'ANONYMOUS') {
|
||||||
const accessToken = this.createAccessToken(
|
const accessToken = this.createAccessToken(
|
||||||
user.id,
|
user.id,
|
||||||
this.getRandomString(10)
|
this.getRandomString(10)
|
||||||
|
26
apps/api/src/assets/countries/developed-markets.json
Normal file
26
apps/api/src/assets/countries/developed-markets.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
"AT",
|
||||||
|
"AU",
|
||||||
|
"BE",
|
||||||
|
"CA",
|
||||||
|
"CH",
|
||||||
|
"DE",
|
||||||
|
"DK",
|
||||||
|
"ES",
|
||||||
|
"FI",
|
||||||
|
"FR",
|
||||||
|
"GB",
|
||||||
|
"HK",
|
||||||
|
"IE",
|
||||||
|
"IL",
|
||||||
|
"IT",
|
||||||
|
"JP",
|
||||||
|
"LU",
|
||||||
|
"NL",
|
||||||
|
"NO",
|
||||||
|
"NZ",
|
||||||
|
"PT",
|
||||||
|
"SE",
|
||||||
|
"SG",
|
||||||
|
"US"
|
||||||
|
]
|
28
apps/api/src/assets/countries/emerging-markets.json
Normal file
28
apps/api/src/assets/countries/emerging-markets.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
[
|
||||||
|
"AE",
|
||||||
|
"BR",
|
||||||
|
"CL",
|
||||||
|
"CN",
|
||||||
|
"CO",
|
||||||
|
"CY",
|
||||||
|
"CZ",
|
||||||
|
"EG",
|
||||||
|
"GR",
|
||||||
|
"HK",
|
||||||
|
"HU",
|
||||||
|
"ID",
|
||||||
|
"IN",
|
||||||
|
"KR",
|
||||||
|
"KW",
|
||||||
|
"MX",
|
||||||
|
"MY",
|
||||||
|
"PE",
|
||||||
|
"PH",
|
||||||
|
"PL",
|
||||||
|
"QA",
|
||||||
|
"SA",
|
||||||
|
"TH",
|
||||||
|
"TR",
|
||||||
|
"TW",
|
||||||
|
"ZA"
|
||||||
|
]
|
@ -0,0 +1,39 @@
|
|||||||
|
import { decodeDataSource } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { ConfigurationService } from '../services/configuration.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TransformDataSourceInRequestInterceptor<T>
|
||||||
|
implements NestInterceptor<T, any>
|
||||||
|
{
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler<T>
|
||||||
|
): Observable<any> {
|
||||||
|
const http = context.switchToHttp();
|
||||||
|
const request = http.getRequest();
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
|
||||||
|
if (request.body.dataSource) {
|
||||||
|
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.params.dataSource) {
|
||||||
|
request.params.dataSource = decodeDataSource(request.params.dataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,86 @@
|
|||||||
|
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { ConfigurationService } from '../services/configuration.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TransformDataSourceInResponseInterceptor<T>
|
||||||
|
implements NestInterceptor<T, any>
|
||||||
|
{
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler<T>
|
||||||
|
): Observable<any> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data: any) => {
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||||
|
) {
|
||||||
|
if (data.activities) {
|
||||||
|
data.activities.map((activity) => {
|
||||||
|
activity.SymbolProfile.dataSource = encodeDataSource(
|
||||||
|
activity.SymbolProfile.dataSource
|
||||||
|
);
|
||||||
|
return activity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.dataSource) {
|
||||||
|
data.dataSource = encodeDataSource(data.dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.errors) {
|
||||||
|
for (const error of data.errors) {
|
||||||
|
if (error.dataSource) {
|
||||||
|
error.dataSource = encodeDataSource(error.dataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.holdings) {
|
||||||
|
for (const symbol of Object.keys(data.holdings)) {
|
||||||
|
if (data.holdings[symbol].dataSource) {
|
||||||
|
data.holdings[symbol].dataSource = encodeDataSource(
|
||||||
|
data.holdings[symbol].dataSource
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.items) {
|
||||||
|
data.items.map((item) => {
|
||||||
|
item.dataSource = encodeDataSource(item.dataSource);
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.positions) {
|
||||||
|
data.positions.map((position) => {
|
||||||
|
position.dataSource = encodeDataSource(position.dataSource);
|
||||||
|
return position;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.SymbolProfile) {
|
||||||
|
data.SymbolProfile.dataSource = encodeDataSource(
|
||||||
|
data.SymbolProfile.dataSource
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
@ -7,8 +7,11 @@ import { environment } from './environments/environment';
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
const globalPrefix = 'api';
|
app.enableVersioning({
|
||||||
app.setGlobalPrefix(globalPrefix);
|
defaultVersion: '1',
|
||||||
|
type: VersioningType.URI
|
||||||
|
});
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
forbidNonWhitelisted: true,
|
forbidNonWhitelisted: true,
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
export enum OrderType {
|
|
||||||
CorporateAction = 'CORPORATE_ACTION',
|
|
||||||
Bonus = 'BONUS',
|
|
||||||
Buy = 'BUY',
|
|
||||||
Dividend = 'DIVIDEND',
|
|
||||||
Sell = 'SELL',
|
|
||||||
Split = 'SPLIT'
|
|
||||||
}
|
|
@ -1,8 +1,7 @@
|
|||||||
import { Account, SymbolProfile } from '@prisma/client';
|
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { IOrder } from '../services/interfaces/interfaces';
|
import { IOrder } from '../services/interfaces/interfaces';
|
||||||
import { OrderType } from './order-type';
|
|
||||||
|
|
||||||
export class Order {
|
export class Order {
|
||||||
private account: Account;
|
private account: Account;
|
||||||
@ -15,7 +14,7 @@ export class Order {
|
|||||||
private symbol: string;
|
private symbol: string;
|
||||||
private symbolProfile: SymbolProfile;
|
private symbolProfile: SymbolProfile;
|
||||||
private total: number;
|
private total: number;
|
||||||
private type: OrderType;
|
private type: TypeOfOrder;
|
||||||
private unitPrice: number;
|
private unitPrice: number;
|
||||||
|
|
||||||
public constructor(data: IOrder) {
|
public constructor(data: IOrder) {
|
||||||
|
@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
for (const account of Object.keys(this.accounts)) {
|
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||||
accounts[account] = {
|
accounts[accountId] = {
|
||||||
name: account,
|
name: account.name,
|
||||||
investment: this.accounts[account].current
|
investment: account.current
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxItem;
|
let maxItem;
|
||||||
let totalInvestment = 0;
|
let totalInvestment = 0;
|
||||||
|
|
||||||
Object.values(accounts).forEach((account) => {
|
for (const account of Object.values(accounts)) {
|
||||||
if (!maxItem) {
|
if (!maxItem) {
|
||||||
maxItem = account;
|
maxItem = account;
|
||||||
}
|
}
|
||||||
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
if (account.investment > maxItem?.investment) {
|
if (account.investment > maxItem?.investment) {
|
||||||
maxItem = account;
|
maxItem = account;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||||
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPosition
|
PortfolioPosition
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings?: Settings) {
|
public evaluate(ruleSettings?: Settings) {
|
||||||
const platforms: {
|
const accounts: {
|
||||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||||
investment: number;
|
investment: number;
|
||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
for (const account of Object.keys(this.accounts)) {
|
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||||
platforms[account] = {
|
accounts[accountId] = {
|
||||||
name: account,
|
name: account.name,
|
||||||
investment: this.accounts[account].original
|
investment: account.original
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxItem;
|
let maxItem;
|
||||||
let totalInvestment = 0;
|
let totalInvestment = 0;
|
||||||
|
|
||||||
Object.values(platforms).forEach((platform) => {
|
for (const account of Object.values(accounts)) {
|
||||||
if (!maxItem) {
|
if (!maxItem) {
|
||||||
maxItem = platform;
|
maxItem = account;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total investment
|
// Calculate total investment
|
||||||
totalInvestment += platform.investment;
|
totalInvestment += account.investment;
|
||||||
|
|
||||||
// Find maximum
|
// Find maximum
|
||||||
if (platform.investment > maxItem?.investment) {
|
if (account.investment > maxItem?.investment) {
|
||||||
maxItem = platform;
|
maxItem = account;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-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 { PortfolioDetails } from '@ghostfolio/common/interfaces';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-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';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-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';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-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';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-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';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ export class ConfigurationService {
|
|||||||
ACCESS_TOKEN_SALT: str(),
|
ACCESS_TOKEN_SALT: str(),
|
||||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
|
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||||
@ -25,8 +26,12 @@ export class ConfigurationService {
|
|||||||
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
||||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||||
|
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||||
|
GOOGLE_SHEETS_ID: str({ default: '' }),
|
||||||
|
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||||
JWT_SECRET_KEY: str({}),
|
JWT_SECRET_KEY: str({}),
|
||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
|
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||||
PORT: port({ default: 3333 }),
|
PORT: port({ default: 3333 }),
|
||||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||||
REDIS_HOST: str({ default: 'localhost' }),
|
REDIS_HOST: str({ default: 'localhost' }),
|
||||||
@ -34,6 +39,10 @@ export class ConfigurationService {
|
|||||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||||
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||||
STRIPE_SECRET_KEY: str({ default: '' }),
|
STRIPE_SECRET_KEY: str({ default: '' }),
|
||||||
|
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
|
||||||
|
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
|
||||||
|
TWITTER_API_KEY: str({ default: 'dummyApiKey' }),
|
||||||
|
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }),
|
||||||
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -3,12 +3,14 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
|||||||
|
|
||||||
import { DataGatheringService } from './data-gathering.service';
|
import { DataGatheringService } from './data-gathering.service';
|
||||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||||
|
import { TwitterBotService } from './twitter-bot/twitter-bot.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService {
|
export class CronService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly twitterBotService: TwitterBotService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
@ -21,6 +23,11 @@ export class CronService {
|
|||||||
await this.exchangeRateDataService.loadCurrencies();
|
await this.exchangeRateDataService.loadCurrencies();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_5PM)
|
||||||
|
public async runEveryDayAtFivePM() {
|
||||||
|
this.twitterBotService.tweetFearAndGreedIndex();
|
||||||
|
}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_WEEKEND)
|
@Cron(CronExpression.EVERY_WEEKEND)
|
||||||
public async runEveryWeekend() {
|
public async runEveryWeekend() {
|
||||||
await this.dataGatheringService.gatherProfileData();
|
await this.dataGatheringService.gatherProfileData();
|
||||||
|
@ -10,7 +10,7 @@ export class CryptocurrencyService {
|
|||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
public isCrypto(aSymbol = '') {
|
public isCryptocurrency(aSymbol = '') {
|
||||||
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
|
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
|
||||||
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
|
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"1INCH": "1inch",
|
"1INCH": "1inch",
|
||||||
"ALGO": "Algorand",
|
"ALGO": "Algorand",
|
||||||
|
"ATOM": "Cosmos",
|
||||||
"AVAX": "Avalanche",
|
"AVAX": "Avalanche",
|
||||||
|
"DOT": "Polkadot",
|
||||||
"MATIC": "Polygon",
|
"MATIC": "Polygon",
|
||||||
"SHIB": "Shiba Inu"
|
"MINA": "Mina Protocol",
|
||||||
|
"SHIB": "Shiba Inu",
|
||||||
|
"SOL": "Solana",
|
||||||
|
"UNI3": "Uniswap"
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
PROPERTY_LAST_DATA_GATHERING,
|
PROPERTY_LAST_DATA_GATHERING,
|
||||||
PROPERTY_LOCKED_DATA_GATHERING,
|
PROPERTY_LOCKED_DATA_GATHERING
|
||||||
ghostfolioFearAndGreedIndexSymbol
|
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
@ -17,7 +17,6 @@ import {
|
|||||||
subDays
|
subDays
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
|
|
||||||
import { ConfigurationService } from './configuration.service';
|
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||||
@ -29,7 +28,6 @@ export class DataGatheringService {
|
|||||||
private dataGatheringProgress: number;
|
private dataGatheringProgress: number;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
@Inject('DataEnhancers')
|
@Inject('DataEnhancers')
|
||||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
@ -42,7 +40,7 @@ export class DataGatheringService {
|
|||||||
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
||||||
|
|
||||||
if (isDataGatheringNeeded) {
|
if (isDataGatheringNeeded) {
|
||||||
Logger.log('7d data gathering has been started.');
|
Logger.log('7d data gathering has been started.', 'DataGatheringService');
|
||||||
console.time('data-gathering-7d');
|
console.time('data-gathering-7d');
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
await this.prismaService.property.create({
|
||||||
@ -66,7 +64,7 @@ export class DataGatheringService {
|
|||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'DataGatheringService');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
await this.prismaService.property.delete({
|
||||||
@ -75,7 +73,10 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Logger.log('7d data gathering has been completed.');
|
Logger.log(
|
||||||
|
'7d data gathering has been completed.',
|
||||||
|
'DataGatheringService'
|
||||||
|
);
|
||||||
console.timeEnd('data-gathering-7d');
|
console.timeEnd('data-gathering-7d');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -86,7 +87,10 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isDataGatheringLocked) {
|
if (!isDataGatheringLocked) {
|
||||||
Logger.log('Max data gathering has been started.');
|
Logger.log(
|
||||||
|
'Max data gathering has been started.',
|
||||||
|
'DataGatheringService'
|
||||||
|
);
|
||||||
console.time('data-gathering-max');
|
console.time('data-gathering-max');
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
await this.prismaService.property.create({
|
||||||
@ -110,7 +114,7 @@ export class DataGatheringService {
|
|||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'DataGatheringService');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
await this.prismaService.property.delete({
|
||||||
@ -119,24 +123,24 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Logger.log('Max data gathering has been completed.');
|
Logger.log(
|
||||||
|
'Max data gathering has been completed.',
|
||||||
|
'DataGatheringService'
|
||||||
|
);
|
||||||
console.timeEnd('data-gathering-max');
|
console.timeEnd('data-gathering-max');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbol({
|
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||||
dataSource,
|
|
||||||
symbol
|
|
||||||
}: {
|
|
||||||
dataSource: DataSource;
|
|
||||||
symbol: string;
|
|
||||||
}) {
|
|
||||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isDataGatheringLocked) {
|
if (!isDataGatheringLocked) {
|
||||||
Logger.log(`Symbol data gathering for ${symbol} has been started.`);
|
Logger.log(
|
||||||
|
`Symbol data gathering for ${symbol} has been started.`,
|
||||||
|
'DataGatheringService'
|
||||||
|
);
|
||||||
console.time('data-gathering-symbol');
|
console.time('data-gathering-symbol');
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
await this.prismaService.property.create({
|
||||||
@ -167,7 +171,7 @@ export class DataGatheringService {
|
|||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'DataGatheringService');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
await this.prismaService.property.delete({
|
||||||
@ -176,41 +180,95 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Logger.log(`Symbol data gathering for ${symbol} has been completed.`);
|
Logger.log(
|
||||||
|
`Symbol data gathering for ${symbol} has been completed.`,
|
||||||
|
'DataGatheringService'
|
||||||
|
);
|
||||||
console.timeEnd('data-gathering-symbol');
|
console.timeEnd('data-gathering-symbol');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async gatherSymbolForDate({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
date: Date;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
|
[{ dataSource, symbol }],
|
||||||
|
date,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
const marketPrice =
|
||||||
|
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
|
||||||
|
|
||||||
|
if (marketPrice) {
|
||||||
|
return await this.prismaService.marketData.upsert({
|
||||||
|
create: {
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
marketPrice,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
update: { marketPrice },
|
||||||
|
where: { date_symbol: { date, symbol } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'DataGatheringService');
|
||||||
|
} finally {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
||||||
Logger.log('Profile data gathering has been started.');
|
Logger.log(
|
||||||
|
'Profile data gathering has been started.',
|
||||||
|
'DataGatheringService'
|
||||||
|
);
|
||||||
console.time('data-gathering-profile');
|
console.time('data-gathering-profile');
|
||||||
|
|
||||||
let dataGatheringItems = aDataGatheringItems;
|
let dataGatheringItems = aDataGatheringItems?.filter(
|
||||||
|
(dataGatheringItem) => {
|
||||||
|
return dataGatheringItem.dataSource !== 'MANUAL';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!dataGatheringItems) {
|
if (!dataGatheringItems) {
|
||||||
dataGatheringItems = await this.getSymbolsProfileData();
|
dataGatheringItems = await this.getSymbolsProfileData();
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentData = await this.dataProviderService.get(dataGatheringItems);
|
const assetProfiles = await this.dataProviderService.getAssetProfiles(
|
||||||
|
dataGatheringItems
|
||||||
|
);
|
||||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
dataGatheringItems.map(({ symbol }) => {
|
dataGatheringItems.map(({ symbol }) => {
|
||||||
return symbol;
|
return symbol;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [symbol, response] of Object.entries(currentData)) {
|
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
|
||||||
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
||||||
return symbolProfile.symbol === symbol;
|
return symbolProfile.symbol === symbol;
|
||||||
})?.symbolMapping;
|
})?.symbolMapping;
|
||||||
|
|
||||||
for (const dataEnhancer of this.dataEnhancers) {
|
for (const dataEnhancer of this.dataEnhancers) {
|
||||||
try {
|
try {
|
||||||
currentData[symbol] = await dataEnhancer.enhance({
|
assetProfiles[symbol] = await dataEnhancer.enhance({
|
||||||
response,
|
response: assetProfile,
|
||||||
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
|
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
|
Logger.error(
|
||||||
|
`Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`,
|
||||||
|
error,
|
||||||
|
'DataGatheringService'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,8 +279,9 @@ export class DataGatheringService {
|
|||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
name,
|
name,
|
||||||
sectors
|
sectors,
|
||||||
} = currentData[symbol];
|
url
|
||||||
|
} = assetProfiles[symbol];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.prismaService.symbolProfile.upsert({
|
await this.prismaService.symbolProfile.upsert({
|
||||||
@ -234,7 +293,8 @@ export class DataGatheringService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
name,
|
name,
|
||||||
sectors,
|
sectors,
|
||||||
symbol
|
symbol,
|
||||||
|
url
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
assetClass,
|
assetClass,
|
||||||
@ -242,7 +302,8 @@ export class DataGatheringService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
name,
|
name,
|
||||||
sectors
|
sectors,
|
||||||
|
url
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
@ -252,11 +313,18 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`${symbol}: ${error?.meta?.cause}`);
|
Logger.error(
|
||||||
|
`${symbol}: ${error?.meta?.cause}`,
|
||||||
|
error,
|
||||||
|
'DataGatheringService'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.log('Profile data gathering has been completed.');
|
Logger.log(
|
||||||
|
'Profile data gathering has been completed.',
|
||||||
|
'DataGatheringService'
|
||||||
|
);
|
||||||
console.timeEnd('data-gathering-profile');
|
console.timeEnd('data-gathering-profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,6 +333,10 @@ export class DataGatheringService {
|
|||||||
let symbolCounter = 0;
|
let symbolCounter = 0;
|
||||||
|
|
||||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||||
|
if (dataSource === 'MANUAL') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
|
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -299,16 +371,26 @@ export class DataGatheringService {
|
|||||||
?.marketPrice;
|
?.marketPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (lastMarketPrice) {
|
||||||
await this.prismaService.marketData.create({
|
try {
|
||||||
data: {
|
await this.prismaService.marketData.create({
|
||||||
dataSource,
|
data: {
|
||||||
symbol,
|
dataSource,
|
||||||
date: currentDate,
|
symbol,
|
||||||
marketPrice: lastMarketPrice
|
date: currentDate,
|
||||||
}
|
marketPrice: lastMarketPrice
|
||||||
});
|
}
|
||||||
} catch {}
|
});
|
||||||
|
} 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
|
// Count month one up for iteration
|
||||||
currentDate = new Date(
|
currentDate = new Date(
|
||||||
@ -322,14 +404,15 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
Logger.error(error);
|
Logger.error(error, 'DataGatheringService');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
|
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
|
||||||
Logger.log(
|
Logger.log(
|
||||||
`Data gathering progress: ${(
|
`Data gathering progress: ${(
|
||||||
this.dataGatheringProgress * 100
|
this.dataGatheringProgress * 100
|
||||||
).toFixed(2)}%`
|
).toFixed(2)}%`,
|
||||||
|
'DataGatheringService'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -401,6 +484,11 @@ export class DataGatheringService {
|
|||||||
},
|
},
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource: {
|
||||||
|
not: 'MANUAL'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
).map((symbolProfile) => {
|
||||||
@ -410,15 +498,11 @@ export class DataGatheringService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
...this.getBenchmarksToGather(startDate),
|
|
||||||
...currencyPairsToGather,
|
|
||||||
...symbolProfilesToGather
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async reset() {
|
public async reset() {
|
||||||
Logger.log('Data gathering has been reset.');
|
Logger.log('Data gathering has been reset.', 'DataGatheringService');
|
||||||
|
|
||||||
await this.prismaService.property.deleteMany({
|
await this.prismaService.property.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@ -430,41 +514,57 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
|
||||||
const benchmarksToGather: IDataGatheringItem[] = [];
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
|
||||||
benchmarksToGather.push({
|
|
||||||
dataSource: DataSource.RAKUTEN,
|
|
||||||
date: startDate,
|
|
||||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return benchmarksToGather;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||||
const startDate = subDays(resetHours(new Date()), 7);
|
const startDate = subDays(resetHours(new Date()), 7);
|
||||||
|
|
||||||
const symbolProfilesToGather = (
|
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||||
await this.prismaService.symbolProfile.findMany({
|
orderBy: [{ symbol: 'asc' }],
|
||||||
orderBy: [{ symbol: 'asc' }],
|
select: {
|
||||||
select: {
|
dataSource: true,
|
||||||
dataSource: true,
|
scraperConfiguration: true,
|
||||||
scraperConfiguration: true,
|
symbol: true
|
||||||
symbol: true
|
},
|
||||||
|
where: {
|
||||||
|
dataSource: {
|
||||||
|
not: 'MANUAL'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only consider symbols with incomplete market data for the last
|
||||||
|
// 7 days
|
||||||
|
const symbolsNotToGather = (
|
||||||
|
await this.prismaService.marketData.groupBy({
|
||||||
|
_count: true,
|
||||||
|
by: ['symbol'],
|
||||||
|
where: {
|
||||||
|
date: { gt: startDate }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
)
|
||||||
return {
|
.filter((group) => {
|
||||||
...symbolProfile,
|
return group._count >= 6;
|
||||||
date: startDate
|
})
|
||||||
};
|
.map((group) => {
|
||||||
});
|
return group.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
|
const symbolProfilesToGather = symbolProfiles
|
||||||
|
.filter(({ symbol }) => {
|
||||||
|
return !symbolsNotToGather.includes(symbol);
|
||||||
|
})
|
||||||
|
.map((symbolProfile) => {
|
||||||
|
return {
|
||||||
|
...symbolProfile,
|
||||||
|
date: startDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const currencyPairsToGather = this.exchangeRateDataService
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
|
.filter(({ symbol }) => {
|
||||||
|
return !symbolsNotToGather.includes(symbol);
|
||||||
|
})
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -473,30 +573,28 @@ export class DataGatheringService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
...this.getBenchmarksToGather(startDate),
|
|
||||||
...currencyPairsToGather,
|
|
||||||
...symbolProfilesToGather
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||||
const startDate = subDays(resetHours(new Date()), 7);
|
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy: [{ symbol: 'asc' }]
|
||||||
const distinctOrders = await this.prismaService.order.findMany({
|
|
||||||
distinct: ['symbol'],
|
|
||||||
orderBy: [{ symbol: 'asc' }],
|
|
||||||
select: { dataSource: true, symbol: true }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
|
return symbolProfiles
|
||||||
(distinctOrder) => {
|
.filter((symbolProfile) => {
|
||||||
return (
|
return (
|
||||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
|
||||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
symbolProfile.dataSource !== DataSource.MANUAL &&
|
||||||
|
symbolProfile.dataSource !== DataSource.RAKUTEN
|
||||||
);
|
);
|
||||||
}
|
})
|
||||||
);
|
.map((symbolProfile) => {
|
||||||
|
return {
|
||||||
|
dataSource: symbolProfile.dataSource,
|
||||||
|
symbol: symbolProfile.symbol
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isDataGatheringNeeded() {
|
private async isDataGatheringNeeded() {
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { isAfter, isBefore, parse } from 'date-fns';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '../../interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
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 { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -29,25 +29,23 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
|
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(
|
public async getAssetProfile(
|
||||||
aSymbols: string[]
|
aSymbol: string
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
return {};
|
return {
|
||||||
|
dataSource: this.getName()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbols: string[],
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date
|
to: Date
|
||||||
): Promise<{
|
): Promise<{
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}> {
|
}> {
|
||||||
if (aSymbols.length <= 0) {
|
const symbol = aSymbol;
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const symbol = aSymbols[0];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const historicalData: {
|
const historicalData: {
|
||||||
@ -78,7 +76,7 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, symbol);
|
Logger.error(error, 'AlphaVantageService');
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@ -88,13 +86,19 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
return DataSource.ALPHA_VANTAGE;
|
return DataSource.ALPHA_VANTAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async getQuotes(
|
||||||
const result = await this.alphaVantage.data.search(aSymbol);
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
const result = await this.alphaVantage.data.search(aQuery);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: result?.bestMatches?.map((bestMatch) => {
|
items: result?.bestMatches?.map((bestMatch) => {
|
||||||
return {
|
return {
|
||||||
dataSource: DataSource.ALPHA_VANTAGE,
|
dataSource: this.getName(),
|
||||||
name: bestMatch['2. name'],
|
name: bestMatch['2. name'],
|
||||||
symbol: bestMatch['1. symbol']
|
symbol: bestMatch['1. symbol']
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||||
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import bent from 'bent';
|
import bent from 'bent';
|
||||||
|
|
||||||
const getJSON = bent('json');
|
const getJSON = bent('json');
|
||||||
@ -7,6 +9,9 @@ const getJSON = bent('json');
|
|||||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||||
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
||||||
private static countries = require('countries-list/dist/countries.json');
|
private static countries = require('countries-list/dist/countries.json');
|
||||||
|
private static countriesMapping = {
|
||||||
|
'Russian Federation': 'Russia'
|
||||||
|
};
|
||||||
private static sectorsMapping = {
|
private static sectorsMapping = {
|
||||||
'Consumer Discretionary': 'Consumer Cyclical',
|
'Consumer Discretionary': 'Consumer Cyclical',
|
||||||
'Consumer Defensive': 'Consumer Staples',
|
'Consumer Defensive': 'Consumer Staples',
|
||||||
@ -18,9 +23,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
response,
|
response,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
response: IDataProviderResponse;
|
response: Partial<SymbolProfile>;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}): Promise<IDataProviderResponse> {
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
if (
|
if (
|
||||||
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
|
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
|
||||||
) {
|
) {
|
||||||
@ -37,7 +42,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.countries || response.countries.length === 0) {
|
if (
|
||||||
|
!response.countries ||
|
||||||
|
(response.countries as unknown as Country[]).length === 0
|
||||||
|
) {
|
||||||
response.countries = [];
|
response.countries = [];
|
||||||
for (const [name, value] of Object.entries<any>(holdings.countries)) {
|
for (const [name, value] of Object.entries<any>(holdings.countries)) {
|
||||||
let countryCode: string;
|
let countryCode: string;
|
||||||
@ -45,7 +53,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
for (const [key, country] of Object.entries<any>(
|
for (const [key, country] of Object.entries<any>(
|
||||||
TrackinsightDataEnhancerService.countries
|
TrackinsightDataEnhancerService.countries
|
||||||
)) {
|
)) {
|
||||||
if (country.name === name) {
|
if (
|
||||||
|
country.name === name ||
|
||||||
|
country.name ===
|
||||||
|
TrackinsightDataEnhancerService.countriesMapping[name]
|
||||||
|
) {
|
||||||
countryCode = key;
|
countryCode = key;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -58,7 +70,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.sectors || response.sectors.length === 0) {
|
if (
|
||||||
|
!response.sectors ||
|
||||||
|
(response.sectors as unknown as Sector[]).length === 0
|
||||||
|
) {
|
||||||
response.sectors = [];
|
response.sectors = [];
|
||||||
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
|
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
|
||||||
response.sectors.push({
|
response.sectors.push({
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
|
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||||
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
@ -21,12 +23,16 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
|
GoogleSheetsService,
|
||||||
|
ManualService,
|
||||||
RakutenRapidApiService,
|
RakutenRapidApiService,
|
||||||
YahooFinanceService,
|
YahooFinanceService,
|
||||||
{
|
{
|
||||||
inject: [
|
inject: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
|
GoogleSheetsService,
|
||||||
|
ManualService,
|
||||||
RakutenRapidApiService,
|
RakutenRapidApiService,
|
||||||
YahooFinanceService
|
YahooFinanceService
|
||||||
],
|
],
|
||||||
@ -34,11 +40,15 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
useFactory: (
|
useFactory: (
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
ghostfolioScraperApiService,
|
ghostfolioScraperApiService,
|
||||||
|
googleSheetsService,
|
||||||
|
manualService,
|
||||||
rakutenRapidApiService,
|
rakutenRapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
) => [
|
) => [
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
ghostfolioScraperApiService,
|
ghostfolioScraperApiService,
|
||||||
|
googleSheetsService,
|
||||||
|
manualService,
|
||||||
rakutenRapidApiService,
|
rakutenRapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
]
|
]
|
||||||
|
@ -10,9 +10,9 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||||
import { format, isValid } from 'date-fns';
|
import { format, isValid } from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { groupBy, isEmpty } from 'lodash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataProviderService {
|
export class DataProviderService {
|
||||||
@ -23,33 +23,6 @@ export class DataProviderService {
|
|||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(items: IDataGatheringItem[]): Promise<{
|
|
||||||
[symbol: string]: IDataProviderResponse;
|
|
||||||
}> {
|
|
||||||
const response: {
|
|
||||||
[symbol: string]: IDataProviderResponse;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const dataProvider = this.getDataProvider(item.dataSource);
|
|
||||||
response[item.symbol] = (await dataProvider.get([item.symbol]))[
|
|
||||||
item.symbol
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const promises = [];
|
|
||||||
for (const symbol of Object.keys(response)) {
|
|
||||||
const promise = Promise.resolve(response[symbol]);
|
|
||||||
promises.push(
|
|
||||||
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aItems: IDataGatheringItem[],
|
aItems: IDataGatheringItem[],
|
||||||
aGranularity: Granularity = 'month',
|
aGranularity: Granularity = 'month',
|
||||||
@ -109,7 +82,7 @@ export class DataProviderService {
|
|||||||
return r;
|
return r;
|
||||||
}, {});
|
}, {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'DataProviderService');
|
||||||
} finally {
|
} finally {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -135,7 +108,7 @@ export class DataProviderService {
|
|||||||
if (dataProvider.canHandle(symbol)) {
|
if (dataProvider.canHandle(symbol)) {
|
||||||
promises.push(
|
promises.push(
|
||||||
dataProvider
|
dataProvider
|
||||||
.getHistorical([symbol], undefined, from, to)
|
.getHistorical(symbol, undefined, from, to)
|
||||||
.then((data) => ({ data: data?.[symbol], symbol }))
|
.then((data) => ({ data: data?.[symbol], symbol }))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -149,13 +122,89 @@ export class DataProviderService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public getPrimaryDataSource(): DataSource {
|
||||||
|
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
|
||||||
|
[symbol: string]: Partial<SymbolProfile>;
|
||||||
|
}> {
|
||||||
|
const response: {
|
||||||
|
[symbol: string]: Partial<SymbolProfile>;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||||
|
itemsGroupedByDataSource
|
||||||
|
)) {
|
||||||
|
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||||
|
return dataGatheringItem.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const symbol of symbols) {
|
||||||
|
const promise = Promise.resolve(
|
||||||
|
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol)
|
||||||
|
);
|
||||||
|
|
||||||
|
promises.push(
|
||||||
|
promise.then((symbolProfile) => {
|
||||||
|
response[symbol] = symbolProfile;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getQuotes(items: IDataGatheringItem[]): Promise<{
|
||||||
|
[symbol: string]: IDataProviderResponse;
|
||||||
|
}> {
|
||||||
|
const response: {
|
||||||
|
[symbol: string]: IDataProviderResponse;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||||
|
itemsGroupedByDataSource
|
||||||
|
)) {
|
||||||
|
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||||
|
return dataGatheringItem.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = Promise.resolve(
|
||||||
|
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
|
||||||
|
);
|
||||||
|
|
||||||
|
promises.push(
|
||||||
|
promise.then((result) => {
|
||||||
|
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
|
||||||
|
response[symbol] = dataProviderResponse;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||||
let lookupItems: LookupItem[] = [];
|
let lookupItems: LookupItem[] = [];
|
||||||
|
|
||||||
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
|
this.getDataProvider(DataSource[dataSource]).search(aQuery)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,16 +224,13 @@ export class DataProviderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPrimaryDataSource(): DataSource {
|
|
||||||
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDataProvider(providerName: DataSource) {
|
private getDataProvider(providerName: DataSource) {
|
||||||
for (const dataProviderInterface of this.dataProviderInterfaces) {
|
for (const dataProviderInterface of this.dataProviderInterfaces) {
|
||||||
if (dataProviderInterface.getName() === providerName) {
|
if (dataProviderInterface.getName() === providerName) {
|
||||||
return dataProviderInterface;
|
return dataProviderInterface;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('No data provider has been found.');
|
throw new Error('No data provider has been found.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,19 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
|
||||||
import {
|
|
||||||
DATE_FORMAT,
|
|
||||||
getYesterday,
|
|
||||||
isGhostfolioScraperApiSymbol
|
|
||||||
} from '@ghostfolio/common/helper';
|
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import * as bent from 'bent';
|
|
||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
MarketState
|
MarketState
|
||||||
} from '../../interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
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 * as cheerio from 'cheerio';
|
||||||
|
import { addDays, format, isBefore } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||||
@ -30,73 +25,61 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return isGhostfolioScraperApiSymbol(symbol);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(
|
public async getAssetProfile(
|
||||||
aSymbols: string[]
|
aSymbol: string
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
if (aSymbols.length <= 0) {
|
return {
|
||||||
return {};
|
dataSource: this.getName()
|
||||||
}
|
};
|
||||||
|
|
||||||
try {
|
|
||||||
const [symbol] = aSymbols;
|
|
||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
|
||||||
[symbol]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { marketPrice } = await this.prismaService.marketData.findFirst({
|
|
||||||
orderBy: {
|
|
||||||
date: 'desc'
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
symbol
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
[symbol]: {
|
|
||||||
marketPrice,
|
|
||||||
currency: symbolProfile?.currency,
|
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
|
||||||
marketState: MarketState.delayed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbols: string[],
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date
|
to: Date
|
||||||
): Promise<{
|
): Promise<{
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}> {
|
}> {
|
||||||
if (aSymbols.length <= 0) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [symbol] = aSymbols;
|
const symbol = aSymbol;
|
||||||
|
|
||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||||
[symbol]
|
[symbol]
|
||||||
);
|
);
|
||||||
const scraperConfiguration = symbolProfile?.scraperConfiguration;
|
const { defaultMarketPrice, selector, url } =
|
||||||
|
symbolProfile.scraperConfiguration;
|
||||||
|
|
||||||
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
|
if (defaultMarketPrice) {
|
||||||
|
const historical: {
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
} = {
|
||||||
|
[symbol]: {}
|
||||||
|
};
|
||||||
|
let date = from;
|
||||||
|
|
||||||
|
while (isBefore(date, to)) {
|
||||||
|
historical[symbol][format(date, DATE_FORMAT)] = {
|
||||||
|
marketPrice: defaultMarketPrice
|
||||||
|
};
|
||||||
|
|
||||||
|
date = addDays(date, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return historical;
|
||||||
|
} else if (selector === undefined || url === undefined) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = bent(url, 'GET', 'string', 200, {});
|
||||||
|
|
||||||
const html = await get();
|
const html = await get();
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
const value = this.extractNumberFromString(
|
const value = this.extractNumberFromString($(selector).text());
|
||||||
$(scraperConfiguration?.selector).text()
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[symbol]: {
|
[symbol]: {
|
||||||
@ -106,7 +89,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'GhostfolioScraperApiService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@ -116,8 +99,81 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
return DataSource.GHOSTFOLIO;
|
return DataSource.GHOSTFOLIO;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async getQuotes(
|
||||||
return { items: [] };
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
|
aSymbols
|
||||||
|
);
|
||||||
|
|
||||||
|
const marketData = await this.prismaService.marketData.findMany({
|
||||||
|
distinct: ['symbol'],
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
take: aSymbols.length,
|
||||||
|
where: {
|
||||||
|
symbol: {
|
||||||
|
in: aSymbols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const symbolProfile of symbolProfiles) {
|
||||||
|
response[symbolProfile.symbol] = {
|
||||||
|
currency: symbolProfile.currency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
marketPrice: marketData.find((marketDataItem) => {
|
||||||
|
return marketDataItem.symbol === symbolProfile.symbol;
|
||||||
|
}).marketPrice,
|
||||||
|
marketState: MarketState.delayed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'GhostfolioScraperApiService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
const items = await this.prismaService.symbolProfile.findMany({
|
||||||
|
select: {
|
||||||
|
currency: true,
|
||||||
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
|
symbol: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
name: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractNumberFromString(aString: string): number {
|
private extractNumberFromString(aString: string): number {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
export interface ScraperConfiguration {
|
export interface ScraperConfiguration {
|
||||||
|
defaultMarketPrice?: number;
|
||||||
selector: string;
|
selector: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,185 @@
|
|||||||
|
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,
|
||||||
|
MarketState
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { GoogleSpreadsheet } from 'google-spreadsheet';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleSheetsService implements DataProviderInterface {
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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 symbol = aSymbol;
|
||||||
|
|
||||||
|
const sheet = await this.getSheet({
|
||||||
|
symbol,
|
||||||
|
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await sheet.getRows();
|
||||||
|
|
||||||
|
const historicalData: {
|
||||||
|
[date: string]: IDataProviderHistoricalResponse;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
rows
|
||||||
|
.filter((row, index) => {
|
||||||
|
return index >= 1;
|
||||||
|
})
|
||||||
|
.forEach((row) => {
|
||||||
|
const date = parseDate(row._rawData[0]);
|
||||||
|
const close = parseFloat(row._rawData[1]);
|
||||||
|
|
||||||
|
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
[symbol]: historicalData
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'GoogleSheetsService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.GOOGLE_SHEETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getQuotes(
|
||||||
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
|
aSymbols
|
||||||
|
);
|
||||||
|
|
||||||
|
const sheet = await this.getSheet({
|
||||||
|
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
|
||||||
|
symbol: 'Overview'
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await sheet.getRows();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const marketPrice = parseFloat(row['marketPrice']);
|
||||||
|
const symbol = row['symbol'];
|
||||||
|
|
||||||
|
if (aSymbols.includes(symbol)) {
|
||||||
|
response[symbol] = {
|
||||||
|
marketPrice,
|
||||||
|
currency: symbolProfiles.find((symbolProfile) => {
|
||||||
|
return symbolProfile.symbol === symbol;
|
||||||
|
})?.currency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
marketState: MarketState.delayed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'GoogleSheetsService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
const items = await this.prismaService.symbolProfile.findMany({
|
||||||
|
select: {
|
||||||
|
currency: true,
|
||||||
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
|
symbol: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
name: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSheet({
|
||||||
|
sheetId,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
sheetId: string;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
const doc = new GoogleSpreadsheet(sheetId);
|
||||||
|
|
||||||
|
await doc.useServiceAccountAuth({
|
||||||
|
client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
|
||||||
|
private_key: this.configurationService
|
||||||
|
.get('GOOGLE_SHEETS_PRIVATE_KEY')
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
});
|
||||||
|
|
||||||
|
await doc.loadInfo();
|
||||||
|
|
||||||
|
const sheet = doc.sheetsByTitle[symbol];
|
||||||
|
|
||||||
|
await sheet.loadCells();
|
||||||
|
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +1,13 @@
|
|||||||
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
|
|
||||||
export interface DataEnhancerInterface {
|
export interface DataEnhancerInterface {
|
||||||
enhance({
|
enhance({
|
||||||
response,
|
response,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
response: IDataProviderResponse;
|
response: Partial<SymbolProfile>;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}): Promise<IDataProviderResponse>;
|
}): Promise<Partial<SymbolProfile>>;
|
||||||
|
|
||||||
getName(): string;
|
getName(): string;
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,30 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '../../interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
|
|
||||||
export interface DataProviderInterface {
|
export interface DataProviderInterface {
|
||||||
canHandle(symbol: string): boolean;
|
canHandle(symbol: string): boolean;
|
||||||
|
|
||||||
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
||||||
|
|
||||||
getHistorical(
|
getHistorical(
|
||||||
aSymbols: string[],
|
aSymbol: string,
|
||||||
aGranularity: Granularity,
|
aGranularity: Granularity,
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date
|
to: Date
|
||||||
): Promise<{
|
): Promise<{
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}>;
|
}>; // TODO: Return only one symbol
|
||||||
|
|
||||||
getName(): DataSource;
|
getName(): DataSource;
|
||||||
|
|
||||||
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
getQuotes(
|
||||||
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
||||||
|
|
||||||
|
search(aQuery: string): Promise<{ items: LookupItem[] }>;
|
||||||
}
|
}
|
||||||
|
51
apps/api/src/services/data-provider/manual/manual.service.ts
Normal file
51
apps/api/src/services/data-provider/manual/manual.service.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
import {
|
||||||
|
IDataProviderHistoricalResponse,
|
||||||
|
IDataProviderResponse
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ManualService implements DataProviderInterface {
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public canHandle(symbol: string) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.MANUAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getQuotes(
|
||||||
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
}
|
@ -1,21 +1,20 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
import {
|
||||||
|
IDataProviderHistoricalResponse,
|
||||||
|
IDataProviderResponse,
|
||||||
|
MarketState
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||||
|
|
||||||
import {
|
|
||||||
IDataProviderHistoricalResponse,
|
|
||||||
IDataProviderResponse,
|
|
||||||
MarketState
|
|
||||||
} from '../../interfaces/interfaces';
|
|
||||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RakutenRapidApiService implements DataProviderInterface {
|
export class RakutenRapidApiService implements DataProviderInterface {
|
||||||
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
|
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
|
||||||
@ -29,50 +28,24 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
|
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(
|
public async getAssetProfile(
|
||||||
aSymbols: string[]
|
aSymbol: string
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
if (aSymbols.length <= 0) {
|
return {
|
||||||
return {};
|
dataSource: this.getName()
|
||||||
}
|
};
|
||||||
|
|
||||||
try {
|
|
||||||
const symbol = aSymbols[0];
|
|
||||||
|
|
||||||
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
|
||||||
const fgi = await this.getFearAndGreedIndex();
|
|
||||||
|
|
||||||
return {
|
|
||||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
|
||||||
currency: undefined,
|
|
||||||
dataSource: DataSource.RAKUTEN,
|
|
||||||
marketPrice: fgi.now.value,
|
|
||||||
marketState: MarketState.open,
|
|
||||||
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbols: string[],
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date
|
to: Date
|
||||||
): Promise<{
|
): Promise<{
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}> {
|
}> {
|
||||||
if (aSymbols.length <= 0) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbol = aSymbols[0];
|
const symbol = aSymbol;
|
||||||
|
|
||||||
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||||
const fgi = await this.getFearAndGreedIndex();
|
const fgi = await this.getFearAndGreedIndex();
|
||||||
@ -85,7 +58,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
date: subWeeks(getToday(), 1),
|
date: subWeeks(getToday(), 1),
|
||||||
marketPrice: fgi.oneWeekAgo.value
|
marketPrice: fgi.oneWeekAgo.value
|
||||||
}
|
}
|
||||||
@ -94,7 +67,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
date: subMonths(getToday(), 1),
|
date: subMonths(getToday(), 1),
|
||||||
marketPrice: fgi.oneMonthAgo.value
|
marketPrice: fgi.oneMonthAgo.value
|
||||||
}
|
}
|
||||||
@ -103,7 +76,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
date: subYears(getToday(), 1),
|
date: subYears(getToday(), 1),
|
||||||
marketPrice: fgi.oneYearAgo.value
|
marketPrice: fgi.oneYearAgo.value
|
||||||
}
|
}
|
||||||
@ -129,7 +102,36 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
return DataSource.RAKUTEN;
|
return DataSource.RAKUTEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async getQuotes(
|
||||||
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const symbol = aSymbols[0];
|
||||||
|
|
||||||
|
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||||
|
const fgi = await this.getFearAndGreedIndex();
|
||||||
|
|
||||||
|
return {
|
||||||
|
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||||
|
currency: undefined,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
marketPrice: fgi.now.value,
|
||||||
|
marketState: MarketState.open
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'RakutenRapidApiService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,7 +160,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
const { fgi } = await get();
|
const { fgi } = await get();
|
||||||
return fgi;
|
return fgi;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'RakutenRapidApiService');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
export interface IYahooFinanceHistoricalResponse {
|
|
||||||
adjClose: number;
|
|
||||||
close: number;
|
|
||||||
date: Date;
|
|
||||||
high: number;
|
|
||||||
low: number;
|
|
||||||
open: number;
|
|
||||||
symbol: string;
|
|
||||||
volume: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IYahooFinanceQuoteResponse {
|
|
||||||
price: IYahooFinancePrice;
|
|
||||||
summaryProfile: IYahooFinanceSummaryProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IYahooFinancePrice {
|
|
||||||
currency: string;
|
|
||||||
exchangeName: string;
|
|
||||||
longName: string;
|
|
||||||
marketState: string;
|
|
||||||
quoteType: string;
|
|
||||||
regularMarketPrice: number;
|
|
||||||
shortName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IYahooFinanceSummaryProfile {
|
|
||||||
country?: string;
|
|
||||||
industry?: string;
|
|
||||||
sector?: string;
|
|
||||||
website?: string;
|
|
||||||
}
|
|
@ -0,0 +1,60 @@
|
|||||||
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
|
|
||||||
|
import { YahooFinanceService } from './yahoo-finance.service';
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/cryptocurrency/cryptocurrency.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
CryptocurrencyService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
isCryptocurrency: (symbol: string) => {
|
||||||
|
switch (symbol) {
|
||||||
|
case 'BTCUSD':
|
||||||
|
return true;
|
||||||
|
case 'DOGEUSD':
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('YahooFinanceService', () => {
|
||||||
|
let cryptocurrencyService: CryptocurrencyService;
|
||||||
|
let yahooFinanceService: YahooFinanceService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
cryptocurrencyService = new CryptocurrencyService();
|
||||||
|
|
||||||
|
yahooFinanceService = new YahooFinanceService(cryptocurrencyService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('convertFromYahooFinanceSymbol', async () => {
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertFromYahooFinanceSymbol('BRK-B')
|
||||||
|
).toEqual('BRK-B');
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertFromYahooFinanceSymbol('BTC-USD')
|
||||||
|
).toEqual('BTCUSD');
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertFromYahooFinanceSymbol('EURUSD=X')
|
||||||
|
).toEqual('EURUSD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('convertToYahooFinanceSymbol', async () => {
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertToYahooFinanceSymbol('BTCUSD')
|
||||||
|
).toEqual('BTC-USD');
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD')
|
||||||
|
).toEqual('DOGE-USD');
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF')
|
||||||
|
).toEqual('USDCHF=X');
|
||||||
|
});
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user