Compare commits
356 Commits
Author | SHA1 | Date | |
---|---|---|---|
255af6a6e9 | |||
795a6a6799 | |||
2a854e2574 | |||
52d113e71f | |||
204c7360c3 | |||
fa41e25c8f | |||
ba765b9de6 | |||
fa79196278 | |||
d1230ca3ad | |||
69a1316cfe | |||
a256b783bc | |||
ebbdd47fa2 | |||
3d21e2eac6 | |||
bc117fe601 | |||
65f6bcb166 | |||
b8c43ecf89 | |||
1214127ec0 | |||
e986310302 | |||
6762572658 | |||
eb77652d6a | |||
a7b59f4ec6 | |||
dd71f2be45 | |||
d530cb38fa | |||
16b79a7e60 | |||
7f0c98cae6 | |||
57e4163848 | |||
14773bf1aa | |||
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 | |||
78e0fdb0ca | |||
606350b2ff | |||
d09cad4e05 | |||
069660afe4 | |||
4d9a223491 | |||
aed8f5cf04 | |||
1beb4de62f | |||
9bc3505ded | |||
3e82de6b21 | |||
563f354e7e | |||
e96e6c717c | |||
961774ce9f | |||
49f46e1a1e | |||
050c0a4da7 | |||
4908e6d35d | |||
fe4013830d | |||
11be6f630f | |||
85d123e1b1 | |||
c5e9804c25 | |||
1f042ee791 | |||
da6eaa0d77 | |||
3f31cec859 | |||
6c07759eb7 | |||
fcf07a0fd1 | |||
2f402c0c8e | |||
a24a094407 | |||
dc9b2ce194 | |||
72067459d6 | |||
705441ecf8 | |||
fbd1475402 | |||
4dc4f13f40 | |||
3b857aa8bb | |||
1c2ca5b96b | |||
572bfc59b8 | |||
147f0162b7 | |||
f6acf5207b | |||
80782f1098 | |||
bc58ee86ca | |||
0cb632b165 | |||
fca3a659d0 | |||
904dec040e | |||
fc6c81fe02 | |||
634171e4e3 | |||
f8f36e4f4e | |||
5e7cf9d0b6 | |||
e1932eb5a1 | |||
dba47d59e3 | |||
3032126508 | |||
a50b55da75 | |||
5422df05b3 | |||
d2fabe7ce4 | |||
a42700b9fe | |||
9df8541145 | |||
0b2252755c | |||
239bd09cbd | |||
cd76f89902 | |||
7425ba94f1 | |||
b9522307c4 | |||
e01e039a00 | |||
6d3513c17f | |||
d60b444324 | |||
2873130259 | |||
d999a27159 | |||
b6902e10ea | |||
7f3f75386d | |||
678544748a | |||
632f3e3872 | |||
87301ddbd5 | |||
7d03c373ac | |||
edb66bb166 | |||
54bbc8446b | |||
9933967e42 | |||
5618513d07 | |||
1397cd62a8 | |||
e7fb31d1a6 | |||
51e7b94ad0 | |||
9133ea38f3 | |||
a864c617b9 | |||
442df9d6f8 | |||
2de0e75cb8 | |||
1296f95602 | |||
e98dff877a | |||
964b37af30 | |||
2d89d12d31 | |||
19e2d54791 | |||
e24e5e1c44 | |||
a0d4ff7920 | |||
099ad18aaf | |||
e3cd99f5d2 | |||
6dea9093ba | |||
43104f81d0 | |||
6d2e3b6e40 | |||
1d9a31dbb8 | |||
ea0e92220c | |||
b57301ef50 | |||
67dbc6b014 | |||
2e5176bacf | |||
060846023f | |||
f06a0fbbee | |||
4ab6a1a071 | |||
93dcbeb6c7 | |||
b9f0a57522 | |||
174c1d1a62 | |||
f308ae7a13 | |||
a7a6b0608b | |||
15a61b7a20 | |||
d1eedf9726 | |||
30a592b524 | |||
de94494aa0 | |||
d3c6788ad5 | |||
3ec4a73b35 | |||
1050bfa098 | |||
595ec1d7b4 | |||
c8389599b6 | |||
8769fe4c90 | |||
4219e1121e | |||
f558eb8de8 | |||
fe2bd6eea8 | |||
035052be99 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,6 +23,7 @@
|
||||
!.vscode/settings.json
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
|
@ -1 +1,2 @@
|
||||
/dist
|
||||
/test/import
|
||||
|
31
.travis.yml
31
.travis.yml
@ -3,9 +3,28 @@ git:
|
||||
depth: false
|
||||
node_js:
|
||||
- 14
|
||||
before_script:
|
||||
- yarn
|
||||
script:
|
||||
- yarn format:check
|
||||
- yarn test
|
||||
- yarn build:all
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
cache: yarn
|
||||
|
||||
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
|
||||
|
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -26,7 +26,8 @@
|
||||
"skipFiles": [
|
||||
"${workspaceFolder}/node_modules/**/*.js",
|
||||
"<node_internals>/**/*.js"
|
||||
]
|
||||
],
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
933
CHANGELOG.md
933
CHANGELOG.md
@ -5,6 +5,927 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.132.1 - 06.04.2022
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with percentages in the value component
|
||||
|
||||
## 1.132.0 - 06.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for localization (date and number format) in user settings
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the label of the average price from _Ø Buy Price_ to _Average Unit Price_
|
||||
|
||||
## 1.131.1 - 04.04.2022
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the missing API version in the _Stripe_ success callback url
|
||||
|
||||
## 1.131.0 - 02.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added API versioning
|
||||
- Added more durations in the coupon system
|
||||
|
||||
### Changed
|
||||
|
||||
- Display the value in base currency in the accounts table on mobile
|
||||
- Display the value in base currency in the activities table on mobile
|
||||
- Renamed `orders` to `activities` in import and export functionality
|
||||
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
|
||||
- Improved the pricing page
|
||||
- Upgraded `prisma` from version `3.10.0` to `3.11.1`
|
||||
- Upgraded `yahoo-finance2` from version `2.2.0` to `2.3.0`
|
||||
|
||||
## 1.130.0 - 30.03.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a _FIRE_ (Financial Independence, Retire Early) section including the 4% rule
|
||||
- Added more durations in the coupon system
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the currency conversion (duplicate) in the account calculations
|
||||
|
||||
## 1.129.0 - 26.03.2022
|
||||
|
||||
### Added
|
||||
|
||||
- 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
|
||||
|
||||
### Added
|
||||
|
||||
- Added a coupon system
|
||||
|
||||
## 1.87.0 - 07.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Supported the management of additional currencies in the admin control panel
|
||||
- Introduced the system message
|
||||
- Introduced the read only mode
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 10 days
|
||||
- Upgraded `prisma` from version `2.30.2` to `3.6.0`
|
||||
|
||||
## 1.86.0 - 04.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the historical data chart of the _Fear & Greed Index_ (market mood)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the historical data view in the admin control panel (hide invalid and future dates)
|
||||
- Enabled the import functionality for transactions by default
|
||||
- Converted the symbols to uppercase to avoid case-sensitive duplicates in the symbol profile model
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the allocations by currency in combination with cash balances
|
||||
|
||||
## 1.85.0 - 01.12.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the data gathering of the _Fear & Greed Index_ (market mood)
|
||||
|
||||
## 1.84.0 - 30.11.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Exposed the data gathering by symbol as an endpoint
|
||||
|
||||
## 1.83.0 - 29.11.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the experimental API
|
||||
|
||||
### Fixed
|
||||
|
||||
- Eliminated the redundant storage of historical exchange rates
|
||||
|
||||
## 1.82.0 - 28.11.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added tabs with routing to the admin control panel
|
||||
- Added a new tab to manage historical data to the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Introduced tabs with routing to the home page
|
||||
|
||||
## 1.81.0 - 27.11.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the value to the position detail dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `angular` from version `12.2.4` to `13.0.2`
|
||||
- Upgraded `angular-material-css-vars` from version `2.1.2` to `3.0.0`
|
||||
- Upgraded `nestjs` from version `7.6.18` to `8.2.3`
|
||||
- Upgraded `Nx` from version `12.8.0` to `13.2.2`
|
||||
- Upgraded `rxjs` from version `6.6.7` to `7.4.0`
|
||||
- Upgraded `storybook` from version `6.3.8` to `6.4.0-rc.3`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the broken line charts showing value labels if openend from the allocations page
|
||||
- Fixed the click event for drafts in the transactions table
|
||||
|
||||
## 1.80.0 - 23.11.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Accentuated the all time high and the all time low
|
||||
|
||||
## 1.79.0 - 21.11.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the value column to the positions table
|
||||
- Added support for cryptocurrency _Algorand_
|
||||
|
||||
### Changed
|
||||
|
||||
- Locked the symbol input in the edit transaction dialog
|
||||
- Filtered the account selector by account type (`SECURITIES`) in the create or edit transaction dialog
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the search functionality for cryptocurrency symbols (do not show unsupported symbols)
|
||||
|
||||
## 1.78.0 - 20.11.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a testimonial section to the landing page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the footer row border of the accounts table in dark mode
|
||||
|
||||
## 1.77.0 - 16.11.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Hid the _Get Started_ button on the registration page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the footer row of the accounts table on mobile
|
||||
- Fixed the transactions count calculation in the accounts table (exclude drafts)
|
||||
|
||||
## 1.76.0 - 14.11.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the footer row with buying power and net worth to the accounts table
|
||||
|
||||
## 1.75.0 - 13.11.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a logo to the log on the server start
|
||||
- Added the data gathering progress to the log and the admin control panel
|
||||
- Added the value column to the accounts table
|
||||
|
||||
## 1.74.0 - 11.11.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Adapted the decimal places for cryptocurrencies in the position detail dialog
|
||||
- Moved the _Fear & Greed Index_ (market mood) to a new tab on the home page
|
||||
|
||||
## 1.73.0 - 10.11.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the info messages to add the first transaction
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the skeleton loader of the portfolio holdings
|
||||
|
||||
## 1.72.0 - 08.11.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Cached the statistics section on the about page
|
||||
|
||||
## 1.71.0 - 07.11.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the logger from `console.log()` to `Logger.log()`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an exception in the scraper configuration
|
||||
|
||||
## 1.70.0 - 07.11.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the validation of `json` files in the import functionality for transactions
|
||||
- Moved the scraper configuration to the symbol profile model
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.69.0 - 07.11.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the symbol mapping attribute to the symbol profile model
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the registration page
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.68.0 - 01.11.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Prettified the generic scraper symbols in the portfolio proportion chart component
|
||||
- Extended the statistics section on the about page by the active users count (7d)
|
||||
- Extended the statistics section on the about page by the new users count
|
||||
|
||||
## 1.67.0 - 31.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added more details to the public page (currencies, sectors, continents and regions)
|
||||
- Added a `Dockerfile` and documentation to build a _Docker_ image
|
||||
|
||||
## 1.66.0 - 30.10.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the landing page
|
||||
- Ordered the granted accesses by type
|
||||
|
||||
## 1.65.0 - 25.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the user interface for granting and revoking public access to share the portfolio
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the data enhancer calls from the data provider (`get()`) to the data gathering service to reduce traffic to 3rd party data providers
|
||||
- Changed the profile data gathering from every 12 hours to once every weekend
|
||||
|
||||
## 1.64.0 - 21.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for more cryptocurrency symbols like _Avalanche_, _Polygon_, _Shiba Inu_ etc.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the data provider service to handle a dynamic list of services
|
||||
|
||||
## 1.63.0 - 19.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a public page to share the portfolio
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the skeleton loader size of the portfolio proportion chart component
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.62.0 - 17.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the validation message of the import functionality for transactions
|
||||
|
||||
## 1.61.0 - 15.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the import functionality for transactions by `csv` files
|
||||
- Introduced the primary data source
|
||||
|
||||
### Changed
|
||||
|
||||
- Restricted the file selector of the import functionality for transactions to `csv` and `json`
|
||||
|
||||
## 1.60.0 - 13.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the validation of the import functionality for transactions
|
||||
- Valid data types
|
||||
- Maximum number of orders
|
||||
- No duplicate orders
|
||||
- Data provider service returns data for the `dataSource` / `symbol` pair
|
||||
|
||||
### Changed
|
||||
|
||||
- Harmonized the page layouts
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the broken line charts showing value labels
|
||||
|
||||
## 1.59.0 - 11.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a data enhancer for symbol profile data (countries and sectors) via _Trackinsight_
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the values of the global heat map to fixed-point notation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the links of cryptocurrency assets in the positions table
|
||||
- Fixed various values in the impersonation mode which have not been nullified
|
||||
|
||||
## 1.58.1 - 03.10.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the symbol conversion for _Yahoo Finance_ (for a cryptocurrency with the same code as a currency)
|
||||
|
||||
## 1.58.0 - 02.10.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the symbol conversion for _Yahoo Finance_: Support for _Solana USD_ (`SOL1-USD`)
|
||||
- Improved the tooltips of the allocations page
|
||||
- Upgraded `envalid` from version `7.1.0` to `7.2.1`
|
||||
|
||||
## 1.57.0 - 29.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a protection for endpoints (subscriptions)
|
||||
|
||||
### Changed
|
||||
|
||||
- Reformatted the exchange rates table in the admin control panel
|
||||
|
||||
## 1.56.0 - 25.09.2021
|
||||
|
||||
### Added
|
||||
@ -27,7 +948,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.55.0 - 20.09.2021
|
||||
|
||||
@ -42,7 +963,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.54.0 - 18.09.2021
|
||||
|
||||
@ -63,7 +984,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.53.0 - 13.09.2021
|
||||
|
||||
@ -185,7 +1106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.41.0 - 21.08.2021
|
||||
|
||||
@ -238,7 +1159,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.38.0 - 14.08.2021
|
||||
|
||||
@ -298,7 +1219,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.34.0 - 07.08.2021
|
||||
|
||||
|
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@ -0,0 +1,52 @@
|
||||
FROM node:14-alpine as builder
|
||||
|
||||
# Build application and add additional files
|
||||
|
||||
WORKDIR /ghostfolio
|
||||
|
||||
# Only add basic files without the application itself to avoid rebuilding
|
||||
# layers when files (package.json etc.) have not changed
|
||||
COPY ./CHANGELOG.md CHANGELOG.md
|
||||
COPY ./LICENSE LICENSE
|
||||
COPY ./package.json package.json
|
||||
COPY ./yarn.lock yarn.lock
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN apk add --no-cache python3 g++ make openssl
|
||||
RUN yarn install
|
||||
|
||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
RUN node decorate-angular-cli.js
|
||||
|
||||
COPY ./angular.json angular.json
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.js replace.build.js
|
||||
COPY ./jest.preset.js jest.preset.js
|
||||
COPY ./jest.config.js jest.config.js
|
||||
COPY ./tsconfig.base.json tsconfig.base.json
|
||||
COPY ./libs libs
|
||||
COPY ./apps apps
|
||||
|
||||
RUN yarn build:all
|
||||
|
||||
# Prepare the dist image with additional node_modules
|
||||
WORKDIR /ghostfolio/dist/apps/api
|
||||
# package.json was generated by the build process, however the original
|
||||
# yarn.lock needs to be used to ensure the same versions
|
||||
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||
|
||||
RUN yarn
|
||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||
|
||||
# Overwrite the generated package.json with the original one to ensure having
|
||||
# all the scripts
|
||||
COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:14-alpine
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE 3333
|
||||
CMD [ "node", "main" ]
|
168
README.md
168
README.md
@ -12,7 +12,7 @@
|
||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/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>
|
||||
<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.
|
||||
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
|
||||
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?
|
||||
|
||||
Ghostfolio is for you if you are...
|
||||
|
||||
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
|
||||
|
||||
- 🏦 pursuing a buy & hold strategy
|
||||
|
||||
- 🎯 interested in getting insights of your portfolio composition
|
||||
|
||||
- 👻 valuing privacy and data ownership
|
||||
|
||||
- 🧘 into minimalism
|
||||
|
||||
- 🧺 caring about diversifying your financial resources
|
||||
|
||||
- 🆓 interested in financial independence
|
||||
|
||||
- 🙅 saying no to spreadsheets in 2021
|
||||
|
||||
- 😎 still reading this list
|
||||
|
||||
## Features
|
||||
@ -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`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
- ✅ Dark Mode
|
||||
- ✅ Zen Mode
|
||||
- ✅ Mobile-first design
|
||||
@ -81,38 +74,87 @@ 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).
|
||||
|
||||
## Getting Started
|
||||
## Run with Docker (self-hosting)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- A local copy of this Git repository (clone)
|
||||
|
||||
### a. Run environment
|
||||
|
||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
#### Setup Database
|
||||
|
||||
Run the following command to setup the database once Ghostfolio is running:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
|
||||
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. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
### Upgrade Version
|
||||
|
||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
|
||||
1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- A local copy of this Git repository (clone)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `cd docker`
|
||||
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `cd -` to go back to the project root directory
|
||||
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
||||
1. Start server and client (see [_Development_](#Development))
|
||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
1. 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 `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
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_
|
||||
|
||||
## Development
|
||||
|
||||
Please make sure you have completed the instructions from [_Setup_](#Setup).
|
||||
|
||||
### Start server
|
||||
### Start Server
|
||||
|
||||
<ol type="a">
|
||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
|
||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
||||
<li>Serve: Run <code>yarn start:server</code></li>
|
||||
</ol>
|
||||
|
||||
### Start client
|
||||
### Start Client
|
||||
|
||||
Run `yarn start:client`
|
||||
|
||||
@ -120,10 +162,84 @@ Run `yarn start:client`
|
||||
|
||||
Run `yarn start:storybook`
|
||||
|
||||
### Migrate Database
|
||||
|
||||
With the following command you can keep your database schema in sync:
|
||||
|
||||
```bash
|
||||
yarn database:push
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run `yarn test`
|
||||
|
||||
## Public API (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
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
@ -132,6 +248,6 @@ Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Sl
|
||||
|
||||
## 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).
|
||||
|
52
angular.json
52
angular.json
@ -1,22 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"defaultCollection": "@nrwl/nest"
|
||||
},
|
||||
"defaultProject": "api",
|
||||
"schematics": {
|
||||
"@nrwl/angular:application": {
|
||||
"linter": "eslint",
|
||||
"unitTestRunner": "jest",
|
||||
"e2eTestRunner": "cypress"
|
||||
},
|
||||
"@nrwl/angular:library": {
|
||||
"linter": "eslint",
|
||||
"unitTestRunner": "jest"
|
||||
},
|
||||
"@nrwl/nest": {},
|
||||
"@nrwl/angular:component": {}
|
||||
},
|
||||
"projects": {
|
||||
"api": {
|
||||
"root": "apps/api",
|
||||
@ -26,7 +9,7 @@
|
||||
"schematics": {},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@nrwl/node:build",
|
||||
"builder": "@nrwl/node:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
@ -35,6 +18,7 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
@ -49,7 +33,7 @@
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@nrwl/node:execute",
|
||||
"builder": "@nrwl/node:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
@ -68,7 +52,8 @@
|
||||
},
|
||||
"outputs": ["coverage/apps/api"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client": {
|
||||
"projectType": "application",
|
||||
@ -99,12 +84,12 @@
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "",
|
||||
"output": "./"
|
||||
"output": "./assets"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./"
|
||||
"output": "./assets"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
@ -200,7 +185,8 @@
|
||||
},
|
||||
"outputs": ["coverage/apps/client"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client-e2e": {
|
||||
"root": "apps/client-e2e",
|
||||
@ -220,7 +206,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
},
|
||||
"common": {
|
||||
"root": "libs/common",
|
||||
@ -241,7 +229,8 @@
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui": {
|
||||
"projectType": "library",
|
||||
@ -275,7 +264,8 @@
|
||||
"port": 4400,
|
||||
"config": {
|
||||
"configFolder": "libs/ui/.storybook"
|
||||
}
|
||||
},
|
||||
"projectBuildConfig": "ui:build-storybook"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
@ -291,7 +281,8 @@
|
||||
"outputPath": "dist/storybook/ui",
|
||||
"config": {
|
||||
"configFolder": "libs/ui/.storybook"
|
||||
}
|
||||
},
|
||||
"projectBuildConfig": "ui:build-storybook"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
@ -299,7 +290,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui-e2e": {
|
||||
"root": "apps/ui-e2e",
|
||||
@ -325,7 +317,9 @@
|
||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["ui"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,25 @@
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Access as AccessModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccessModule } from './access.module';
|
||||
import { AccessService } from './access.service';
|
||||
import { CreateAccessDto } from './create-access.dto';
|
||||
|
||||
@Controller('access')
|
||||
export class AccessController {
|
||||
@ -20,13 +35,63 @@ export class AccessController {
|
||||
include: {
|
||||
GranteeUser: true
|
||||
},
|
||||
orderBy: { granteeUserId: 'asc' },
|
||||
where: { userId: this.request.user.id }
|
||||
});
|
||||
|
||||
return accessesWithGranteeUser.map((access) => {
|
||||
if (access.GranteeUser) {
|
||||
return {
|
||||
granteeAlias: access.GranteeUser?.alias,
|
||||
id: access.id,
|
||||
type: 'RESTRICTED_VIEW'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
granteeAlias: access.GranteeUser.alias
|
||||
granteeAlias: 'Public',
|
||||
id: access.id,
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createAccess(
|
||||
@Body() data: CreateAccessDto
|
||||
): Promise<AccessModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accessService.createAccess({
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accessService.deleteAccess({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessController } from './access.controller';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AccessController],
|
||||
providers: [AccessService, PrismaService]
|
||||
exports: [AccessService],
|
||||
imports: [PrismaModule],
|
||||
providers: [AccessService]
|
||||
})
|
||||
export class AccessModule {}
|
||||
|
@ -1,19 +1,30 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Access, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async access(
|
||||
accessWhereInput: Prisma.AccessWhereInput
|
||||
): Promise<AccessWithGranteeUser | null> {
|
||||
return this.prismaService.access.findFirst({
|
||||
include: {
|
||||
GranteeUser: true
|
||||
},
|
||||
where: accessWhereInput
|
||||
});
|
||||
}
|
||||
|
||||
public async accesses(params: {
|
||||
include?: Prisma.AccessInclude;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.AccessWhereUniqueInput;
|
||||
where?: Prisma.AccessWhereInput;
|
||||
orderBy?: Prisma.AccessOrderByInput;
|
||||
orderBy?: Prisma.AccessOrderByWithRelationInput;
|
||||
}): Promise<AccessWithGranteeUser[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
@ -26,4 +37,18 @@ export class AccessService {
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
|
||||
return this.prismaService.access.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAccess(
|
||||
where: Prisma.AccessWhereUniqueInput
|
||||
): Promise<Access> {
|
||||
return this.prismaService.access.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
||||
|
1
apps/api/src/app/access/create-access.dto.ts
Normal file
1
apps/api/src/app/access/create-access.dto.ts
Normal file
@ -0,0 +1 @@
|
||||
export class CreateAccessDto {}
|
@ -1,11 +1,12 @@
|
||||
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
nullifyValuesInObject,
|
||||
nullifyValuesInObjects
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -34,6 +35,7 @@ export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
@ -42,10 +44,7 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteAccount
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -85,30 +84,40 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getAllAccounts(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<AccountModel[]> {
|
||||
): Promise<Accounts> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accounts = await this.accountService.getAccounts(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
let accountsWithAggregations = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
accounts = nullifyValuesInObjects(accounts, [
|
||||
'balance',
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice'
|
||||
]);
|
||||
accountsWithAggregations = {
|
||||
...nullifyValuesInObject(accountsWithAggregations, [
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency'
|
||||
]),
|
||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||
'balance',
|
||||
'balanceInBaseCurrency',
|
||||
'convertedBalance',
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
return accounts;
|
||||
return accountsWithAggregations;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ -128,10 +137,7 @@ export class AccountController {
|
||||
@Body() data: CreateAccountDto
|
||||
): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.createAccount
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -168,10 +174,7 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateAccount
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
@ -11,16 +12,18 @@ import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountController],
|
||||
exports: [AccountService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
RedisCacheModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [AccountController],
|
||||
providers: [AccountService]
|
||||
})
|
||||
export class AccountModule {}
|
||||
|
@ -2,6 +2,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CashDetails } from './interfaces/cash-details.interface';
|
||||
|
||||
@ -40,7 +41,7 @@ export class AccountService {
|
||||
take?: number;
|
||||
cursor?: Prisma.AccountWhereUniqueInput;
|
||||
where?: Prisma.AccountWhereInput;
|
||||
orderBy?: Prisma.AccountOrderByInput;
|
||||
orderBy?: Prisma.AccountOrderByWithRelationInput;
|
||||
}): Promise<
|
||||
(Account & {
|
||||
Order?: Order[];
|
||||
@ -85,7 +86,15 @@ export class AccountService {
|
||||
});
|
||||
|
||||
return accounts.map((account) => {
|
||||
const result = { ...account, transactionCount: account.Order.length };
|
||||
let transactionCount = 0;
|
||||
|
||||
for (const order of account.Order) {
|
||||
if (!order.isDraft) {
|
||||
transactionCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result = { ...account, transactionCount };
|
||||
|
||||
delete result.Order;
|
||||
|
||||
@ -97,21 +106,26 @@ export class AccountService {
|
||||
aUserId: string,
|
||||
aCurrency: string
|
||||
): Promise<CashDetails> {
|
||||
let totalCashBalance = 0;
|
||||
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||
|
||||
const accounts = await this.accounts({
|
||||
where: { userId: aUserId }
|
||||
});
|
||||
|
||||
accounts.forEach((account) => {
|
||||
totalCashBalance += this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
aCurrency
|
||||
for (const account of accounts) {
|
||||
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
aCurrency
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return { accounts, balance: totalCashBalance };
|
||||
return {
|
||||
accounts,
|
||||
balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber()
|
||||
};
|
||||
}
|
||||
|
||||
public async updateAccount(
|
||||
|
@ -2,5 +2,5 @@ import { Account } from '@prisma/client';
|
||||
|
||||
export interface CashDetails {
|
||||
accounts: Account[];
|
||||
balance: number;
|
||||
balanceInBaseCurrency: number;
|
||||
}
|
||||
|
@ -1,30 +1,40 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { isDate } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
public constructor(
|
||||
private readonly adminService: AdminService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@ -33,7 +43,7 @@ export class AdminController {
|
||||
public async getAdminData(): Promise<AdminData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
@ -51,7 +61,7 @@ export class AdminController {
|
||||
public async gatherMax(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
@ -72,7 +82,7 @@ export class AdminController {
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
@ -86,4 +96,199 @@ export class AdminController {
|
||||
|
||||
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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherSymbol(
|
||||
@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.gatherSymbol({ dataSource, symbol });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherSymbolForDate(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<MarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
|
||||
if (!isDate(date)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return this.dataGatheringService.gatherSymbolForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
|
||||
@Get('market-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketData(): Promise<AdminMarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.getMarketData();
|
||||
}
|
||||
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateProperty(
|
||||
@Param('key') key: string,
|
||||
@Body() data: PropertyDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return await this.adminService.putSetting(key, data.value);
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,10 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration.modu
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminController } from './admin.controller';
|
||||
@ -15,8 +18,11 @@ import { AdminService } from './admin.service';
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
SubscriptionModule
|
||||
PropertyModule,
|
||||
SubscriptionModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
|
@ -2,10 +2,20 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||
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 {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Property } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
@ -14,12 +24,22 @@ export class AdminService {
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
private readonly propertyService: PropertyService,
|
||||
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> {
|
||||
return {
|
||||
dataGatheringProgress:
|
||||
await this.dataGatheringService.getDataGatheringProgress(),
|
||||
exchangeRates: this.exchangeRateDataService
|
||||
.getCurrencies()
|
||||
.filter((currency) => {
|
||||
@ -37,12 +57,112 @@ export class AdminService {
|
||||
};
|
||||
}),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
settings: await this.propertyService.get(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
users: await this.getUsersWithAnalytics()
|
||||
};
|
||||
}
|
||||
|
||||
public async getMarketData(): Promise<AdminMarketData> {
|
||||
const marketData = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['dataSource', '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({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||
return {
|
||||
marketData: await this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
where: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
public async putSetting(key: string, value: string) {
|
||||
let response: Property;
|
||||
|
||||
if (value === '') {
|
||||
response = await this.propertyService.delete({ key });
|
||||
} else {
|
||||
response = await this.propertyService.put({ key, value });
|
||||
}
|
||||
|
||||
if (key === PROPERTY_CURRENCIES) {
|
||||
await this.exchangeRateDataService.initialize();
|
||||
await this.dataGatheringService.reset();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering =
|
||||
await this.dataGatheringService.getLastDataGathering();
|
||||
@ -58,7 +178,7 @@ export class AdminService {
|
||||
return 'IN_PROGRESS';
|
||||
}
|
||||
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||
|
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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
@ -19,7 +20,6 @@ import { AdminModule } from './admin/admin.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { ExperimentalModule } from './experimental/experimental.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
@ -42,7 +42,6 @@ import { UserModule } from './user/user.module';
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ExperimentalModule,
|
||||
ExportModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
@ -67,6 +66,7 @@ import { UserModule } from './user/user.module';
|
||||
}),
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
TwitterBotModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
@ -1,9 +1,5 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
@ -29,7 +25,7 @@ export class AuthDeviceController {
|
||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.deleteAuthDevice
|
||||
)
|
||||
) {
|
||||
|
@ -1,18 +1,20 @@
|
||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthDeviceController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
})
|
||||
}),
|
||||
PrismaModule
|
||||
],
|
||||
providers: [AuthDeviceService, ConfigurationService, PrismaService]
|
||||
providers: [AuthDeviceService]
|
||||
})
|
||||
export class AuthDeviceModule {}
|
||||
|
@ -23,7 +23,7 @@ export class AuthDeviceService {
|
||||
take?: number;
|
||||
cursor?: Prisma.AuthDeviceWhereUniqueInput;
|
||||
where?: Prisma.AuthDeviceWhereInput;
|
||||
orderBy?: Prisma.AuthDeviceOrderByInput;
|
||||
orderBy?: Prisma.AuthDeviceOrderByWithRelationInput;
|
||||
}): Promise<AuthDevice[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prismaService.authDevice.findMany({
|
||||
|
@ -9,7 +9,9 @@ import {
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
VERSION_NEUTRAL,
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
@ -51,6 +53,7 @@ export class AuthController {
|
||||
|
||||
@Get('google/callback')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public googleLoginCallback(@Req() req, @Res() res) {
|
||||
// Handles the Google OAuth2 callback
|
||||
const jwt: string = req.user.jwt;
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -15,20 +15,20 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
}),
|
||||
SubscriptionModule
|
||||
PrismaModule,
|
||||
SubscriptionModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
AuthDeviceService,
|
||||
AuthService,
|
||||
ConfigurationService,
|
||||
GoogleStrategy,
|
||||
JwtStrategy,
|
||||
PrismaService,
|
||||
UserService,
|
||||
WebAuthService
|
||||
]
|
||||
})
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { Strategy } from 'passport-google-oauth20';
|
||||
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
done(err, false);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GoogleStrategy');
|
||||
done(error, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException
|
||||
InternalServerErrorException,
|
||||
Logger
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
@ -94,7 +95,7 @@ export class WebAuthService {
|
||||
};
|
||||
verification = await verifyRegistrationResponse(opts);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Logger.error(error, 'WebAuthService');
|
||||
throw new InternalServerErrorException(error.message);
|
||||
}
|
||||
|
||||
@ -192,7 +193,7 @@ export class WebAuthService {
|
||||
};
|
||||
verification = verifyAuthenticationResponse(opts);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Logger.error(error, 'WebAuthService');
|
||||
throw new InternalServerErrorException({ error: error.message });
|
||||
}
|
||||
|
||||
|
36
apps/api/src/app/cache/cache.module.ts
vendored
36
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,31 +1,27 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheController } from './cache.controller';
|
||||
|
||||
@Module({
|
||||
imports: [ExchangeRateDataModule, RedisCacheModule],
|
||||
exports: [CacheService],
|
||||
controllers: [CacheController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [CacheService]
|
||||
})
|
||||
export class CacheModule {}
|
||||
|
@ -1,22 +0,0 @@
|
||||
import { Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
|
||||
@IsNumber()
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsString()
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
unitPrice: number;
|
||||
}
|
@ -1,69 +0,0 @@
|
||||
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { parse } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { ExperimentalService } from './experimental.service';
|
||||
import { Data } from './interfaces/data.interface';
|
||||
|
||||
@Controller('experimental')
|
||||
export class ExperimentalController {
|
||||
public constructor(
|
||||
private readonly experimentalService: ExperimentalService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get('benchmarks')
|
||||
public async getBenchmarks(
|
||||
@Headers('Authorization') apiToken: string
|
||||
): Promise<string[]> {
|
||||
if (!isApiTokenAuthorized(apiToken)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks.map(({ symbol }) => {
|
||||
return symbol;
|
||||
});
|
||||
}
|
||||
|
||||
@Get('benchmarks/:symbol')
|
||||
public async getBenchmark(
|
||||
@Headers('Authorization') apiToken: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<{ date: Date; marketPrice: number }[]> {
|
||||
if (!isApiTokenAuthorized(apiToken)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const marketData = await this.experimentalService.getBenchmark(symbol);
|
||||
|
||||
if (marketData?.length === 0) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return marketData;
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExperimentalController } from './experimental.controller';
|
||||
import { ExperimentalService } from './experimental.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
RedisCacheModule,
|
||||
PrismaModule
|
||||
],
|
||||
controllers: [ExperimentalController],
|
||||
providers: [AccountService, ExperimentalService]
|
||||
})
|
||||
export class ExperimentalModule {}
|
@ -1,23 +0,0 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ExperimentalService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async getBenchmark(aSymbol: string) {
|
||||
return this.prismaService.marketData.findMany({
|
||||
orderBy: { date: 'asc' },
|
||||
select: { date: true, marketPrice: true },
|
||||
where: { symbol: aSymbol }
|
||||
});
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
export interface Data {
|
||||
currency: string;
|
||||
value: number;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
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 { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@ -15,8 +15,11 @@ export class ExportController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async export(): Promise<Export> {
|
||||
return await this.exportService.export({
|
||||
public async export(
|
||||
@Query('activityIds') activityIds?: string[]
|
||||
): Promise<Export> {
|
||||
return this.exportService.export({
|
||||
activityIds,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
@ -7,25 +7,59 @@ import { Injectable } from '@nestjs/common';
|
||||
export class ExportService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async export({ userId }: { userId: string }): Promise<Export> {
|
||||
const orders = await this.prismaService.order.findMany({
|
||||
public async export({
|
||||
activityIds,
|
||||
userId
|
||||
}: {
|
||||
activityIds?: string[];
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
let activities = await this.prismaService.order.findMany({
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
accountId: true,
|
||||
date: true,
|
||||
fee: true,
|
||||
id: true,
|
||||
quantity: true,
|
||||
symbol: true,
|
||||
SymbolProfile: true,
|
||||
type: true,
|
||||
unitPrice: true
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
if (activityIds) {
|
||||
activities = activities.filter((activity) => {
|
||||
return activityIds.includes(activity.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
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,7 +1,10 @@
|
||||
import { Order } from '@prisma/client';
|
||||
import { IsArray } from 'class-validator';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsArray()
|
||||
orders: Partial<Order>[];
|
||||
@Type(() => CreateOrderDto)
|
||||
@ValidateNested({ each: true })
|
||||
activities: CreateOrderDto[];
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
@ -35,14 +36,17 @@ export class ImportController {
|
||||
|
||||
try {
|
||||
return await this.importService.import({
|
||||
orders: importData.orders,
|
||||
activities: importData.activities,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Logger.error(error, ImportController);
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
{
|
||||
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
message: [error.message]
|
||||
},
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
@ -11,14 +12,17 @@ import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ImportController],
|
||||
imports: [
|
||||
AccountModule,
|
||||
CacheModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ImportController],
|
||||
providers: [CacheService, ImportService, OrderService]
|
||||
providers: [ImportService]
|
||||
})
|
||||
export class ImportModule {}
|
||||
|
@ -1,20 +1,47 @@
|
||||
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Order } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { isSameDay, parseISO } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(private readonly orderService: OrderService) {}
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly orderService: OrderService
|
||||
) {}
|
||||
|
||||
public async import({
|
||||
orders,
|
||||
activities,
|
||||
userId
|
||||
}: {
|
||||
orders: Partial<Order>[];
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
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 {
|
||||
accountId,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
@ -23,18 +50,96 @@ export class ImportService {
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
} of orders) {
|
||||
} of activities) {
|
||||
await this.orderService.createOrder({
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async validateActivities({
|
||||
activities,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
userId: string;
|
||||
}) {
|
||||
if (
|
||||
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 existingActivities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
] of activities.entries()) {
|
||||
const duplicateActivity = existingActivities.find((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
|
||||
activity.fee === fee &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === symbol &&
|
||||
activity.type === type &&
|
||||
activity.unitPrice === unitPrice
|
||||
);
|
||||
});
|
||||
|
||||
if (duplicateActivity) {
|
||||
throw new Error(`activities.${index} is a duplicate activity`);
|
||||
}
|
||||
|
||||
if (dataSource !== 'MANUAL') {
|
||||
const quotes = await this.dataProviderService.getQuotes([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (quotes[symbol] === undefined) {
|
||||
throw new Error(
|
||||
`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,12 +1,11 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -14,24 +13,21 @@ import { InfoController } from './info.controller';
|
||||
import { InfoService } from './info.service';
|
||||
|
||||
@Module({
|
||||
controllers: [InfoController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
})
|
||||
}),
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
controllers: [InfoController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
InfoService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [InfoService]
|
||||
})
|
||||
export class InfoModule {}
|
||||
|
@ -1,33 +1,49 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
PROPERTY_SYSTEM_MESSAGE,
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
} from '@ghostfolio/common/config';
|
||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bent from 'bent';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {}
|
||||
|
||||
public async get(): Promise<InfoItem> {
|
||||
const info: Partial<InfoItem> = {};
|
||||
let isReadOnlyMode: boolean;
|
||||
const platforms = await this.prismaService.platform.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
let systemMessage: string;
|
||||
|
||||
const globalPermissions: string[] = [];
|
||||
|
||||
@ -35,10 +51,22 @@ export class InfoService {
|
||||
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')) {
|
||||
globalPermissions.push(permissions.enableImport);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
||||
globalPermissions.push(permissions.enableSocialLogin);
|
||||
}
|
||||
@ -53,10 +81,20 @@ export class InfoService {
|
||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
|
||||
globalPermissions.push(permissions.enableSystemMessage);
|
||||
|
||||
systemMessage = (await this.propertyService.getByKey(
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
)) as string;
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
globalPermissions,
|
||||
isReadOnlyMode,
|
||||
platforms,
|
||||
systemMessage,
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
@ -106,7 +144,7 @@ export class InfoService {
|
||||
const contributors = await get();
|
||||
return contributors?.length;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Logger.error(error, 'InfoService');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@ -127,15 +165,43 @@ export class InfoService {
|
||||
const { stargazers_count } = await get();
|
||||
return stargazers_count;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Logger.error(error, 'InfoService');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async countNewUsers(aDays: number) {
|
||||
return await this.prismaService.user.count({
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
createdAt: {
|
||||
gt: subDays(new Date(), aDays)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async countSlackCommunityUsers() {
|
||||
return (await this.propertyService.getByKey(
|
||||
PROPERTY_SLACK_COMMUNITY_USERS
|
||||
)) as string;
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: InfoService.DEMO_USER_ID
|
||||
id: DEMO_USER_ID
|
||||
});
|
||||
}
|
||||
|
||||
@ -151,17 +217,40 @@ export class InfoService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let statistics: Statistics;
|
||||
|
||||
try {
|
||||
statistics = JSON.parse(
|
||||
await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS)
|
||||
);
|
||||
|
||||
if (statistics) {
|
||||
return statistics;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const newUsers30d = await this.countNewUsers(30);
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||
|
||||
return {
|
||||
statistics = {
|
||||
activeUsers1d,
|
||||
activeUsers30d,
|
||||
gitHubContributors,
|
||||
gitHubStargazers
|
||||
gitHubStargazers,
|
||||
newUsers30d,
|
||||
slackCommunityUsers
|
||||
};
|
||||
|
||||
await this.redisCacheService.set(
|
||||
InfoService.CACHE_KEY_STATISTICS,
|
||||
JSON.stringify(statistics)
|
||||
);
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
private async getSubscriptions(): Promise<Subscription[]> {
|
||||
@ -170,7 +259,7 @@ export class InfoService {
|
||||
}
|
||||
|
||||
const stripeConfig = await this.prismaService.property.findUnique({
|
||||
where: { key: 'STRIPE_CONFIG' }
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
});
|
||||
|
||||
if (stripeConfig) {
|
||||
|
@ -1,14 +1,22 @@
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
import {
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
@IsEnum(DataSource, { each: true })
|
||||
@IsOptional()
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
@ -23,7 +31,7 @@ export class CreateOrderDto {
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsString()
|
||||
@IsEnum(Type, { each: true })
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
|
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,11 +1,9 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
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 {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -18,7 +16,8 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -27,6 +26,7 @@ import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
import { OrderService } from './order.service';
|
||||
import { UpdateOrderDto } from './update-order.dto';
|
||||
|
||||
@ -43,10 +43,7 @@ export class OrderController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteOrder
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -64,61 +61,46 @@ export class OrderController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<OrderModel[]> {
|
||||
): Promise<Activities> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
|
||||
let orders = await this.orderService.orders({
|
||||
include: {
|
||||
Account: {
|
||||
include: {
|
||||
Platform: true
|
||||
}
|
||||
},
|
||||
SymbolProfile: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId: impersonationUserId || this.request.user.id }
|
||||
let activities = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userId: impersonationUserId || this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
||||
activities = nullifyValuesInObjects(activities, [
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
});
|
||||
return { activities };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.createOrder
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -126,45 +108,35 @@ export class OrderController {
|
||||
);
|
||||
}
|
||||
|
||||
const date = parseISO(data.date);
|
||||
|
||||
const accountId = data.accountId;
|
||||
delete data.accountId;
|
||||
|
||||
return this.orderService.createOrder({
|
||||
...data,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
date,
|
||||
date: parseISO(data.date),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency: data.currency,
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource: data.dataSource,
|
||||
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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateOrder
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -200,6 +172,14 @@ export class OrderController {
|
||||
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 } }
|
||||
},
|
||||
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 { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.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 { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OrderController } from './order.controller';
|
||||
import { OrderService } from './order.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OrderController],
|
||||
exports: [OrderService],
|
||||
imports: [
|
||||
CacheModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [OrderController],
|
||||
providers: [CacheService, OrderService],
|
||||
exports: [OrderService]
|
||||
providers: [AccountService, OrderService]
|
||||
})
|
||||
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 { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
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 { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { Activity } from './interfaces/activities.interface';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async order(
|
||||
@ -28,7 +38,7 @@ export class OrderService {
|
||||
take?: number;
|
||||
cursor?: Prisma.OrderWhereUniqueInput;
|
||||
where?: Prisma.OrderWhereInput;
|
||||
orderBy?: Prisma.OrderOrderByInput;
|
||||
orderBy?: Prisma.OrderOrderByWithRelationInput;
|
||||
}): Promise<OrderWithAccount[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
@ -42,29 +52,85 @@ export class OrderService {
|
||||
});
|
||||
}
|
||||
|
||||
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
|
||||
public async createOrder(
|
||||
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;
|
||||
});
|
||||
|
||||
let Account = {
|
||||
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) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.symbol
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData([
|
||||
{ dataSource: data.dataSource, symbol: data.symbol }
|
||||
]);
|
||||
|
||||
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({
|
||||
data: {
|
||||
...data,
|
||||
...orderData,
|
||||
Account,
|
||||
isDraft
|
||||
}
|
||||
});
|
||||
@ -73,57 +139,121 @@ export class OrderService {
|
||||
public async deleteOrder(
|
||||
where: Prisma.OrderWhereUniqueInput
|
||||
): Promise<Order> {
|
||||
return this.prismaService.order.delete({
|
||||
const order = await this.prismaService.order.delete({
|
||||
where
|
||||
});
|
||||
|
||||
if (order.type === 'ITEM') {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
public getOrders({
|
||||
public async getOrders({
|
||||
includeDrafts = false,
|
||||
types,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
includeDrafts?: boolean;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}) {
|
||||
}): Promise<Activity[]> {
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
if (includeDrafts === false) {
|
||||
where.isDraft = false;
|
||||
}
|
||||
|
||||
return this.orders({
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: true,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
if (types) {
|
||||
where.OR = types.map((type) => {
|
||||
return {
|
||||
type: {
|
||||
equals: type
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
data: Prisma.OrderUpdateInput;
|
||||
}): 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) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: <DataSource>data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: <string>data.symbol
|
||||
}
|
||||
]);
|
||||
if (data.type === 'ITEM') {
|
||||
const name = data.SymbolProfile.connect.dataSource_symbol.symbol;
|
||||
|
||||
data.SymbolProfile = { update: { name } };
|
||||
} else {
|
||||
isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
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();
|
||||
|
||||
delete data.currency;
|
||||
delete data.dataSource;
|
||||
delete data.symbol;
|
||||
|
||||
return this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateOrderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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);
|
||||
}
|
||||
};
|
@ -1,11 +1,11 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
|
||||
jest.mock('./market-data.service', () => {
|
||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
return {
|
||||
MarketDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
@ -72,15 +72,8 @@ describe('CurrentRateService', () => {
|
||||
let marketDataService: MarketDataService;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataProviderService = new DataProviderService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null, null);
|
||||
dataProviderService = new DataProviderService(null, [], null);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
@ -92,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 () => {
|
||||
expect(
|
||||
await currentRateService.getValues({
|
||||
|
@ -1,15 +1,13 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
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 { MarketDataService } from './market-data.service';
|
||||
|
||||
@Injectable()
|
||||
export class CurrentRateService {
|
||||
@ -19,43 +17,6 @@ export class CurrentRateService {
|
||||
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: DataSource.YAHOO }
|
||||
]);
|
||||
return {
|
||||
date: resetHours(date),
|
||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
|
||||
symbol: symbol
|
||||
};
|
||||
}
|
||||
|
||||
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({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
@ -79,7 +40,7 @@ export class CurrentRateService {
|
||||
const today = resetHours(new Date());
|
||||
promises.push(
|
||||
this.dataProviderService
|
||||
.get(dataGatheringItems)
|
||||
.getQuotes(dataGatheringItems)
|
||||
.then((dataResultProvider) => {
|
||||
const result = [];
|
||||
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';
|
||||
|
||||
export interface CurrentPositions {
|
||||
hasErrors: boolean;
|
||||
export interface CurrentPositions extends ResponseError {
|
||||
positions: TimelinePosition[];
|
||||
grossPerformance: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
netAnnualizedPerformance: Big;
|
||||
netAnnualizedPerformance?: Big;
|
||||
netPerformance: Big;
|
||||
netPerformancePercentage: 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 } from '@prisma/client';
|
||||
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
@ -10,6 +9,6 @@ export interface PortfolioOrder {
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
type: OrderType;
|
||||
type: TypeOfOrder;
|
||||
unitPrice: Big;
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
currency: string;
|
||||
firstBuyDate: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
@ -9,12 +11,19 @@ export interface PortfolioPositionDetail {
|
||||
marketPrice: number;
|
||||
maxPrice: number;
|
||||
minPrice: number;
|
||||
name: string;
|
||||
netPerformance: number;
|
||||
netPerformancePercent: number;
|
||||
orders: OrderWithAccount[];
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
SymbolProfile: EnhancedSymbolProfile;
|
||||
transactionCount: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface HistoricalDataContainer {
|
||||
isAllTimeHigh: boolean;
|
||||
isAllTimeLow: boolean;
|
||||
items: HistoricalDataItem[];
|
||||
}
|
||||
|
||||
export interface HistoricalDataItem {
|
||||
|
@ -0,0 +1,8 @@
|
||||
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TimelineInfoInterface {
|
||||
maxNetPerformance: Big;
|
||||
minNetPerformance: Big;
|
||||
timelinePeriods: TimelinePeriod[];
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MarketData } from '@prisma/client';
|
||||
|
||||
import { DateQuery } from './interfaces/date-query.interface';
|
||||
|
||||
@Injectable()
|
||||
export class MarketDataService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async get({
|
||||
date,
|
||||
symbol
|
||||
}: {
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<MarketData> {
|
||||
return await this.prismaService.marketData.findFirst({
|
||||
where: {
|
||||
symbol,
|
||||
date: resetHours(date)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getRange({
|
||||
dateQuery,
|
||||
symbols
|
||||
}: {
|
||||
dateQuery: DateQuery;
|
||||
symbols: string[];
|
||||
}): Promise<MarketData[]> {
|
||||
return await this.prismaService.marketData.findMany({
|
||||
orderBy: [
|
||||
{
|
||||
date: 'asc'
|
||||
},
|
||||
{
|
||||
symbol: 'asc'
|
||||
}
|
||||
],
|
||||
where: {
|
||||
date: dateQuery,
|
||||
symbol: {
|
||||
in: symbols
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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,7 +1,9 @@
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
@ -67,7 +69,7 @@ export class PortfolioCalculator {
|
||||
: unitPrice
|
||||
.mul(order.quantity)
|
||||
.mul(factor)
|
||||
.add(oldAccumulatedSymbol.investment),
|
||||
.plus(oldAccumulatedSymbol.investment),
|
||||
quantity: newQuantity,
|
||||
symbol: order.symbol,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
@ -236,8 +238,9 @@ export class PortfolioCalculator {
|
||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
console.error(
|
||||
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
||||
Logger.warn(
|
||||
`Missing value for symbol ${item.symbol} at ${nextDate}`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@ -269,8 +272,9 @@ export class PortfolioCalculator {
|
||||
if (!initialValue) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
console.error(
|
||||
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||
Logger.warn(
|
||||
`Missing value for symbol ${item.symbol} at ${currentDate}`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@ -354,7 +358,7 @@ export class PortfolioCalculator {
|
||||
date: transactionPoint.date,
|
||||
investment: transactionPoint.items.reduce(
|
||||
(investment, transactionPointSymbol) =>
|
||||
investment.add(transactionPointSymbol.investment),
|
||||
investment.plus(transactionPointSymbol.investment),
|
||||
new Big(0)
|
||||
)
|
||||
};
|
||||
@ -364,16 +368,20 @@ export class PortfolioCalculator {
|
||||
public async calculateTimeline(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
endDate: string
|
||||
): Promise<TimelinePeriod[]> {
|
||||
): Promise<TimelineInfoInterface> {
|
||||
if (timelineSpecification.length === 0) {
|
||||
return [];
|
||||
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<TimelinePeriod[]>[] = [];
|
||||
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
||||
let i = 0;
|
||||
let j = -1;
|
||||
for (
|
||||
@ -416,11 +424,40 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
const timelinePeriods: TimelinePeriod[][] = await Promise.all(
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
return flatten(timelinePeriods);
|
||||
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(
|
||||
@ -442,13 +479,13 @@ export class PortfolioCalculator {
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
currentValue = currentValue.add(
|
||||
currentValue = currentValue.plus(
|
||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
totalInvestment = totalInvestment.add(currentPosition.investment);
|
||||
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
||||
if (currentPosition.grossPerformance) {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
@ -480,8 +517,9 @@ export class PortfolioCalculator {
|
||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
console.error(
|
||||
`Initial value is missing for symbol ${currentPosition.symbol}`
|
||||
Logger.warn(
|
||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
@ -512,7 +550,7 @@ export class PortfolioCalculator {
|
||||
j: number,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<TimelinePeriod[]> {
|
||||
): Promise<TimelineInfoInterface> {
|
||||
let investment: Big = new Big(0);
|
||||
let fees: Big = new Big(0);
|
||||
|
||||
@ -529,8 +567,8 @@ export class PortfolioCalculator {
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
investment = investment.add(item.investment);
|
||||
fees = fees.add(item.fee);
|
||||
investment = investment.plus(item.investment);
|
||||
fees = fees.plus(item.fee);
|
||||
}
|
||||
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
@ -546,9 +584,10 @@ export class PortfolioCalculator {
|
||||
userCurrency: this.currency
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
Logger.error(
|
||||
`Failed to fetch info for date ${startDate} with exception`,
|
||||
error
|
||||
error,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@ -568,6 +607,8 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
const results: TimelinePeriod[] = [];
|
||||
let maxNetPerformance: Big = null;
|
||||
let minNetPerformance: Big = null;
|
||||
for (
|
||||
let currentDate = startDate;
|
||||
isBefore(currentDate, endDate);
|
||||
@ -584,35 +625,53 @@ export class PortfolioCalculator {
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
value = value.add(
|
||||
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,
|
||||
netPerformance: grossPerformance.minus(fees)
|
||||
date: currentDateAsString
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
return {
|
||||
maxNetPerformance,
|
||||
minNetPerformance,
|
||||
timelinePeriods: results
|
||||
};
|
||||
}
|
||||
|
||||
private getFactor(type: OrderType) {
|
||||
private getFactor(type: TypeOfOrder) {
|
||||
let factor: number;
|
||||
|
||||
switch (type) {
|
||||
case OrderType.Buy:
|
||||
case 'BUY':
|
||||
factor = 1;
|
||||
break;
|
||||
case OrderType.Sell:
|
||||
case 'SELL':
|
||||
factor = -1;
|
||||
break;
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,16 +1,24 @@
|
||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
} 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
PortfolioPerformance,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
@ -20,81 +28,49 @@ import {
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Response } from 'express';
|
||||
import { ViewMode } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import {
|
||||
HistoricalDataItem,
|
||||
PortfolioPositionDetail
|
||||
} from './interfaces/portfolio-position-detail.interface';
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async findAll(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<InvestmentItem[]> {
|
||||
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 investments;
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getChart(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<HistoricalDataItem[]> {
|
||||
let chartData = await this.portfolioService.getChart(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getChart(impersonationId, range);
|
||||
|
||||
let hasNullValue = false;
|
||||
let chartData = historicalDataContainer.items;
|
||||
|
||||
let hasError = false;
|
||||
|
||||
chartData.forEach((chartDataItem) => {
|
||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||
hasNullValue = true;
|
||||
hasError = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasNullValue) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
@ -115,21 +91,40 @@ export class PortfolioController {
|
||||
});
|
||||
}
|
||||
|
||||
return <any>res.json(chartData);
|
||||
return {
|
||||
hasError,
|
||||
chart: chartData,
|
||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||
};
|
||||
}
|
||||
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getDetails(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioDetails> {
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(impersonationId, range);
|
||||
await this.portfolioServiceStrategy
|
||||
.get(true)
|
||||
.getDetails(impersonationId, this.request.user.id, range);
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (
|
||||
@ -156,8 +151,9 @@ export class PortfolioController {
|
||||
portfolioPosition.grossPerformance = null;
|
||||
portfolioPosition.investment =
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
|
||||
portfolioPosition.netPerformance = null;
|
||||
portfolioPosition.quantity = null;
|
||||
portfolioPosition.value = portfolioPosition.value / totalValue;
|
||||
}
|
||||
|
||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||
@ -166,54 +162,81 @@ export class PortfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
return <any>res.json({ accounts, holdings });
|
||||
return { accounts, hasError, holdings };
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPerformance(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPerformance> {
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
if (performanceInformation?.hasErrors) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string
|
||||
): Promise<PortfolioInvestments> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let performance = performanceInformation.performance;
|
||||
let investments = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getInvestments(impersonationId);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
performance = nullifyValuesInObject(performance, [
|
||||
'currentGrossPerformance',
|
||||
'currentValue'
|
||||
]);
|
||||
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(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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
if (result?.hasErrors) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
const result = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPositions(impersonationId, range);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -229,7 +252,67 @@ export class PortfolioController {
|
||||
});
|
||||
}
|
||||
|
||||
return <any>res.json(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('public/:accessId')
|
||||
public async getPublic(
|
||||
@Param('accessId') accessId
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
const access = await this.accessService.access({ id: accessId });
|
||||
const user = await this.userService.user({
|
||||
id: access.userId
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioServiceStrategy
|
||||
.get(true)
|
||||
.getDetails(access.userId, access.userId);
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
hasDetails,
|
||||
holdings: {}
|
||||
};
|
||||
|
||||
const totalValue = Object.values(holdings)
|
||||
.filter((holding) => {
|
||||
return holding.assetClass === 'EQUITY';
|
||||
})
|
||||
.map((portfolioPosition) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
portfolioPosition.currency,
|
||||
this.request.user?.Settings?.currency ?? baseCurrency
|
||||
);
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
if (portfolioPosition.assetClass === 'EQUITY') {
|
||||
portfolioPublicDetails.holdings[symbol] = {
|
||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: portfolioPosition.currency,
|
||||
markets: portfolioPosition.markets,
|
||||
name: portfolioPosition.name,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
value: portfolioPosition.value / totalValue
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return portfolioPublicDetails;
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@ -237,7 +320,19 @@ export class PortfolioController {
|
||||
public async getSummary(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): 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 (
|
||||
impersonationId ||
|
||||
@ -249,7 +344,10 @@ export class PortfolioController {
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'emergencyFund',
|
||||
'fees',
|
||||
'items',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
@ -259,16 +357,18 @@ export class PortfolioController {
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Get('position/:symbol')
|
||||
@Get('position/:dataSource/:symbol')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
let position = await this.portfolioService.getPosition(
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
let position = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPosition(dataSource, impersonationId, symbol);
|
||||
|
||||
if (position) {
|
||||
if (
|
||||
@ -279,7 +379,9 @@ export class PortfolioController {
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'quantity'
|
||||
'orders',
|
||||
'quantity',
|
||||
'value'
|
||||
]);
|
||||
}
|
||||
|
||||
@ -295,8 +397,18 @@ export class PortfolioController {
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
@Headers('impersonation-id') impersonationId: string
|
||||
): Promise<PortfolioReport> {
|
||||
return await this.portfolioService.getReport(impersonationId);
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
@ -6,35 +7,41 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
|
||||
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 { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PortfolioController],
|
||||
exports: [PortfolioServiceStrategy],
|
||||
imports: [
|
||||
AccessModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [PortfolioController],
|
||||
providers: [
|
||||
AccountService,
|
||||
CurrentRateService,
|
||||
MarketDataService,
|
||||
PortfolioService,
|
||||
RulesService,
|
||||
SymbolProfileService
|
||||
PortfolioServiceNew,
|
||||
PortfolioServiceStrategy,
|
||||
RulesService
|
||||
]
|
||||
})
|
||||
export class PortfolioModule {}
|
||||
|
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
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { CacheModule, Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
@ -17,9 +18,10 @@ import { RedisCacheService } from './redis-cache.service';
|
||||
store: redisStore,
|
||||
ttl: configurationService.get('CACHE_TTL')
|
||||
})
|
||||
})
|
||||
}),
|
||||
ConfigurationModule
|
||||
],
|
||||
providers: [ConfigurationService, RedisCacheService],
|
||||
providers: [RedisCacheService],
|
||||
exports: [RedisCacheService]
|
||||
})
|
||||
export class RedisCacheModule {}
|
||||
|
@ -21,9 +21,9 @@ export class RedisCacheService {
|
||||
await this.cache.reset();
|
||||
}
|
||||
|
||||
public async set(key: string, value: string) {
|
||||
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||
await this.cache.set(key, value, {
|
||||
ttl: this.configurationService.get('CACHE_TTL')
|
||||
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,16 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
|
||||
import { Coupon } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
@ -21,16 +26,73 @@ import { SubscriptionService } from './subscription.service';
|
||||
export class SubscriptionController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
@Post('redeem-coupon')
|
||||
@HttpCode(StatusCodes.OK)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
|
||||
if (!this.request.user) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let coupons =
|
||||
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
||||
[];
|
||||
|
||||
const coupon = coupons.find((currentCoupon) => {
|
||||
return currentCoupon.code === couponCode;
|
||||
});
|
||||
|
||||
if (coupon === undefined) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
await this.subscriptionService.createSubscription({
|
||||
duration: coupon.duration,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
// Destroy coupon
|
||||
coupons = coupons.filter((currentCoupon) => {
|
||||
return currentCoupon.code !== couponCode;
|
||||
});
|
||||
await this.propertyService.put({
|
||||
key: PROPERTY_COUPONS,
|
||||
value: JSON.stringify(coupons)
|
||||
});
|
||||
|
||||
Logger.log(
|
||||
`Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`,
|
||||
'SubscriptionController'
|
||||
);
|
||||
|
||||
return {
|
||||
message: getReasonPhrase(StatusCodes.OK),
|
||||
statusCode: StatusCodes.OK
|
||||
};
|
||||
}
|
||||
|
||||
@Get('stripe/callback')
|
||||
public async stripeCallback(@Req() req, @Res() res) {
|
||||
await this.subscriptionService.createSubscription(
|
||||
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||
req.query.checkoutSessionId
|
||||
);
|
||||
|
||||
Logger.log(
|
||||
`Subscription for user '${userId}' has been created via Stripe`,
|
||||
'SubscriptionController'
|
||||
);
|
||||
|
||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
||||
}
|
||||
|
||||
@ -46,7 +108,7 @@ export class SubscriptionController {
|
||||
userId: this.request.user.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Logger.error(error, 'SubscriptionController');
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SubscriptionController } from './subscription.controller';
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [SubscriptionController],
|
||||
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
||||
exports: [SubscriptionService]
|
||||
exports: [SubscriptionService],
|
||||
imports: [ConfigurationModule, PrismaModule, PropertyModule],
|
||||
providers: [SubscriptionService]
|
||||
})
|
||||
export class SubscriptionModule {}
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
@ -44,7 +45,7 @@ export class SubscriptionService {
|
||||
payment_method_types: ['card'],
|
||||
success_url: `${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
|
||||
)}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
|
||||
};
|
||||
|
||||
if (couponId) {
|
||||
@ -64,28 +65,40 @@ export class SubscriptionService {
|
||||
};
|
||||
}
|
||||
|
||||
public async createSubscription(aCheckoutSessionId: string) {
|
||||
public async createSubscription({
|
||||
duration = '1 year',
|
||||
userId
|
||||
}: {
|
||||
duration?: StringValue;
|
||||
userId: string;
|
||||
}) {
|
||||
await this.prismaService.subscription.create({
|
||||
data: {
|
||||
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||
User: {
|
||||
connect: {
|
||||
id: userId
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
||||
try {
|
||||
const session = await this.stripe.checkout.sessions.retrieve(
|
||||
aCheckoutSessionId
|
||||
);
|
||||
|
||||
await this.prismaService.subscription.create({
|
||||
data: {
|
||||
expiresAt: addDays(new Date(), 365),
|
||||
User: {
|
||||
connect: {
|
||||
id: session.client_reference_id
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
await this.createSubscription({ userId: session.client_reference_id });
|
||||
|
||||
await this.stripe.customers.update(session.customer as string, {
|
||||
description: session.client_reference_id
|
||||
});
|
||||
|
||||
return session.client_reference_id;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Logger.error(error, 'SubscriptionService');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface SymbolItem {
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
historicalData: HistoricalDataItem[];
|
||||
marketPrice: number;
|
||||
}
|
||||
|
@ -1,18 +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 {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { isDate, isEmpty } from 'lodash';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -20,22 +21,19 @@ import { SymbolService } from './symbol.service';
|
||||
|
||||
@Controller('symbol')
|
||||
export class SymbolController {
|
||||
public constructor(
|
||||
private readonly symbolService: SymbolService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly symbolService: SymbolService) {}
|
||||
|
||||
/**
|
||||
* Must be before /:symbol
|
||||
*/
|
||||
@Get('lookup')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async lookupSymbol(
|
||||
@Query() { query = '' }
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
try {
|
||||
const encodedQuery = encodeURIComponent(query.toLowerCase());
|
||||
return this.symbolService.lookup(encodedQuery);
|
||||
return this.symbolService.lookup(query.toLowerCase());
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
@ -49,9 +47,12 @@ export class SymbolController {
|
||||
*/
|
||||
@Get(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getSymbolData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('includeHistoricalData') includeHistoricalData?: number
|
||||
): Promise<SymbolItem> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
@ -60,7 +61,10 @@ export class SymbolController {
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.symbolService.get({ dataSource, symbol });
|
||||
const result = await this.symbolService.get({
|
||||
includeHistoricalData,
|
||||
dataGatheringItem: { dataSource, symbol }
|
||||
});
|
||||
|
||||
if (!result || isEmpty(result)) {
|
||||
throw new HttpException(
|
||||
@ -71,4 +75,27 @@ export class SymbolController {
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@ -7,8 +8,14 @@ import { SymbolController } from './symbol.controller';
|
||||
import { SymbolService } from './symbol.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigurationModule, DataProviderModule, PrismaModule],
|
||||
controllers: [SymbolController],
|
||||
exports: [SymbolService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PrismaModule
|
||||
],
|
||||
providers: [SymbolService]
|
||||
})
|
||||
export class SymbolModule {}
|
||||
|
@ -1,8 +1,14 @@
|
||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -11,16 +17,43 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
export class SymbolService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly marketDataService: MarketDataService
|
||||
) {}
|
||||
|
||||
public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> {
|
||||
const response = await this.dataProviderService.get([dataGatheringItem]);
|
||||
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
||||
public async get({
|
||||
dataGatheringItem,
|
||||
includeHistoricalData
|
||||
}: {
|
||||
dataGatheringItem: IDataGatheringItem;
|
||||
includeHistoricalData?: number;
|
||||
}): Promise<SymbolItem> {
|
||||
const quotes = await this.dataProviderService.getQuotes([
|
||||
dataGatheringItem
|
||||
]);
|
||||
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
||||
|
||||
if (dataGatheringItem.dataSource && marketPrice) {
|
||||
let historicalData: HistoricalDataItem[] = [];
|
||||
|
||||
if (includeHistoricalData > 0) {
|
||||
const days = includeHistoricalData;
|
||||
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: { gte: subDays(new Date(), days) },
|
||||
symbols: [dataGatheringItem.symbol]
|
||||
});
|
||||
|
||||
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||
return {
|
||||
value,
|
||||
date: date.toISOString()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currency,
|
||||
historicalData,
|
||||
marketPrice,
|
||||
dataSource: dataGatheringItem.dataSource
|
||||
};
|
||||
@ -29,6 +62,27 @@ export class SymbolService {
|
||||
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[] }> {
|
||||
const results: { items: LookupItem[] } = { items: [] };
|
||||
|
||||
@ -39,35 +93,9 @@ export class SymbolService {
|
||||
try {
|
||||
const { items } = await this.dataProviderService.search(aQuery);
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Logger.error(error, 'SymbolService');
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export interface UserItem {
|
||||
accessToken?: string;
|
||||
authToken: string;
|
||||
role: Role;
|
||||
}
|
||||
|
@ -1,3 +1,6 @@
|
||||
export interface UserSettings {
|
||||
emergencyFund?: number;
|
||||
locale?: string;
|
||||
isNewCalculationEngine?: boolean;
|
||||
isRestrictedView?: boolean;
|
||||
}
|
||||
|
@ -1,6 +1,19 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingDto {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
emergencyFund?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isNewCalculationEngine?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isRestrictedView?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
locale?: string;
|
||||
}
|
||||
|
@ -1,15 +1,15 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
@ -20,7 +20,6 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -34,7 +33,9 @@ import { UserService } from './user.service';
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private jwtService: JwtService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
@ -43,10 +44,7 @@ export class UserController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteUser
|
||||
) ||
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteUser) ||
|
||||
id === this.request.user.id
|
||||
) {
|
||||
throw new HttpException(
|
||||
@ -62,18 +60,39 @@ export class UserController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getUser(@Param('id') id: string): Promise<User> {
|
||||
return this.userService.getUser(this.request.user);
|
||||
public async getUser(
|
||||
@Headers('accept-language') acceptLanguage: string
|
||||
): Promise<User> {
|
||||
return this.userService.getUser(
|
||||
this.request.user,
|
||||
acceptLanguage?.split(',')?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
public async signupUser(): Promise<UserItem> {
|
||||
const { accessToken, id } = await this.userService.createUser({
|
||||
provider: Provider.ANONYMOUS
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
|
||||
if (isReadOnlyMode) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hasAdmin = await this.userService.hasAdmin();
|
||||
|
||||
const { accessToken, id, role } = await this.userService.createUser({
|
||||
role: hasAdmin ? 'USER' : 'ADMIN'
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
role,
|
||||
authToken: this.jwtService.sign({
|
||||
id
|
||||
})
|
||||
@ -85,7 +104,7 @@ export class UserController {
|
||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.updateUserSettings
|
||||
)
|
||||
) {
|
||||
@ -100,6 +119,12 @@ export class UserController {
|
||||
...data
|
||||
};
|
||||
|
||||
for (const key in userSettings) {
|
||||
if (userSettings[key] === false || userSettings[key] === null) {
|
||||
delete userSettings[key];
|
||||
}
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSetting({
|
||||
userSettings,
|
||||
userId: this.request.user.id
|
||||
@ -111,7 +136,7 @@ export class UserController {
|
||||
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.updateUserSettings
|
||||
)
|
||||
) {
|
||||
@ -127,10 +152,7 @@ export class UserController {
|
||||
};
|
||||
|
||||
if (
|
||||
hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateViewMode
|
||||
)
|
||||
hasPermission(this.request.user.permissions, permissions.updateViewMode)
|
||||
) {
|
||||
userSettings.viewMode = data.viewMode;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -8,15 +9,18 @@ import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UserController],
|
||||
exports: [UserService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
}),
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
SubscriptionModule
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [ConfigurationService, PrismaService, UserService],
|
||||
exports: [UserService]
|
||||
providers: [UserService]
|
||||
})
|
||||
export class UserModule {}
|
||||
|
@ -1,12 +1,21 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { baseCurrency, locale } from '@ghostfolio/common/config';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
baseCurrency,
|
||||
locale
|
||||
} from '@ghostfolio/common/config';
|
||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
||||
import {
|
||||
getPermissions,
|
||||
hasRole,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
import { Prisma, Role, User, ViewMode } from '@prisma/client';
|
||||
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UserSettings } from './interfaces/user-settings.interface';
|
||||
@ -20,17 +29,21 @@ export class UserService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
public async getUser({
|
||||
Account,
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
Settings,
|
||||
subscription
|
||||
}: UserWithSettings): Promise<IUser> {
|
||||
public async getUser(
|
||||
{
|
||||
Account,
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
Settings,
|
||||
subscription
|
||||
}: UserWithSettings,
|
||||
aLocale = locale
|
||||
): Promise<IUser> {
|
||||
const access = await this.prismaService.access.findMany({
|
||||
include: {
|
||||
User: true
|
||||
@ -53,13 +66,25 @@ export class UserService {
|
||||
accounts: Account,
|
||||
settings: {
|
||||
...(<UserSettings>Settings.settings),
|
||||
locale,
|
||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
|
||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async hasAdmin() {
|
||||
const usersWithAdminRole = await this.users({
|
||||
where: {
|
||||
role: {
|
||||
equals: 'ADMIN'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return usersWithAdminRole.length > 0;
|
||||
}
|
||||
|
||||
public isRestrictedView(aUser: UserWithSettings) {
|
||||
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
|
||||
}
|
||||
@ -74,12 +99,32 @@ export class UserService {
|
||||
|
||||
const user: UserWithSettings = userFromDatabase;
|
||||
|
||||
const currentPermissions = getPermissions(userFromDatabase.role);
|
||||
let currentPermissions = getPermissions(userFromDatabase.role);
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
if (hasRole(user, Role.ADMIN)) {
|
||||
currentPermissions.push(permissions.toggleReadOnlyMode);
|
||||
}
|
||||
|
||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
|
||||
if (isReadOnlyMode) {
|
||||
currentPermissions = currentPermissions.filter((permission) => {
|
||||
return !(
|
||||
permission.startsWith('create') ||
|
||||
permission.startsWith('delete') ||
|
||||
permission.startsWith('update')
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
user.permissions = currentPermissions;
|
||||
|
||||
if (userFromDatabase?.Settings) {
|
||||
@ -119,7 +164,7 @@ export class UserService {
|
||||
take?: number;
|
||||
cursor?: Prisma.UserWhereUniqueInput;
|
||||
where?: Prisma.UserWhereInput;
|
||||
orderBy?: Prisma.UserOrderByInput;
|
||||
orderBy?: Prisma.UserOrderByWithRelationInput;
|
||||
}): Promise<User[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prismaService.user.findMany({
|
||||
@ -138,7 +183,11 @@ export class UserService {
|
||||
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({
|
||||
data: {
|
||||
...data,
|
||||
@ -157,7 +206,7 @@ export class UserService {
|
||||
}
|
||||
});
|
||||
|
||||
if (data.provider === Provider.ANONYMOUS) {
|
||||
if (data.provider === 'ANONYMOUS') {
|
||||
const accessToken = this.createAccessToken(
|
||||
user.id,
|
||||
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,13 +1,17 @@
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors();
|
||||
const globalPrefix = 'api';
|
||||
app.setGlobalPrefix(globalPrefix);
|
||||
app.enableVersioning({
|
||||
defaultVersion: '1',
|
||||
type: VersioningType.URI
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
forbidNonWhitelisted: true,
|
||||
@ -18,8 +22,21 @@ async function bootstrap() {
|
||||
|
||||
const port = process.env.PORT || 3333;
|
||||
await app.listen(port, () => {
|
||||
logLogo();
|
||||
Logger.log(`Listening at http://localhost:${port}`);
|
||||
Logger.log('');
|
||||
});
|
||||
}
|
||||
|
||||
function logLogo() {
|
||||
Logger.log(' ________ __ ____ ___');
|
||||
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___');
|
||||
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\');
|
||||
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /');
|
||||
Logger.log(
|
||||
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`
|
||||
);
|
||||
Logger.log('');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
@ -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 { IOrder } from '../services/interfaces/interfaces';
|
||||
import { OrderType } from './order-type';
|
||||
|
||||
export class Order {
|
||||
private account: Account;
|
||||
@ -15,7 +14,7 @@ export class Order {
|
||||
private symbol: string;
|
||||
private symbolProfile: SymbolProfile;
|
||||
private total: number;
|
||||
private type: OrderType;
|
||||
private type: TypeOfOrder;
|
||||
private unitPrice: number;
|
||||
|
||||
public constructor(data: IOrder) {
|
||||
|
@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const account of Object.keys(this.accounts)) {
|
||||
accounts[account] = {
|
||||
name: account,
|
||||
investment: this.accounts[account].current
|
||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||
accounts[accountId] = {
|
||||
name: account.name,
|
||||
investment: account.current
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
|
||||
Object.values(accounts).forEach((account) => {
|
||||
for (const account of Object.values(accounts)) {
|
||||
if (!maxItem) {
|
||||
maxItem = account;
|
||||
}
|
||||
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
if (account.investment > maxItem?.investment) {
|
||||
maxItem = account;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings?: Settings) {
|
||||
const platforms: {
|
||||
const accounts: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||
investment: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const account of Object.keys(this.accounts)) {
|
||||
platforms[account] = {
|
||||
name: account,
|
||||
investment: this.accounts[account].original
|
||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||
accounts[accountId] = {
|
||||
name: account.name,
|
||||
investment: account.original
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
|
||||
Object.values(platforms).forEach((platform) => {
|
||||
for (const account of Object.values(accounts)) {
|
||||
if (!maxItem) {
|
||||
maxItem = platform;
|
||||
maxItem = account;
|
||||
}
|
||||
|
||||
// Calculate total investment
|
||||
totalInvestment += platform.investment;
|
||||
totalInvestment += account.investment;
|
||||
|
||||
// Find maximum
|
||||
if (platform.investment > maxItem?.investment) {
|
||||
maxItem = platform;
|
||||
if (account.investment > maxItem?.investment) {
|
||||
maxItem = account;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { Environment } from './interfaces/environment.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -14,18 +13,25 @@ export class ConfigurationService {
|
||||
ACCESS_TOKEN_SALT: str(),
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }),
|
||||
ENABLE_FEATURE_IMPORT: bool({ default: true }),
|
||||
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
||||
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||
GOOGLE_SHEETS_ID: str({ default: '' }),
|
||||
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||
JWT_SECRET_KEY: str({}),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
PORT: port({ default: 3333 }),
|
||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
@ -33,6 +39,10 @@ export class ConfigurationService {
|
||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||
STRIPE_PUBLIC_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' })
|
||||
});
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user