Compare commits

..

132 Commits

Author SHA1 Message Date
7425ba94f1 Release 1.73.0 (#461) 2021-11-10 21:16:06 +01:00
b9522307c4 Feature/various client improvements (#460)
* Various improvements
  * info messages
  * skeleton loader of portfolio holdings

* Update changelog
2021-11-10 21:03:25 +01:00
e01e039a00 Release 1.72.0 (#459) 2021-11-08 20:55:38 +01:00
6d3513c17f Update version 2021-11-07 21:27:37 +01:00
d60b444324 Release/1.71.0 (#458)
* Refactor logger

* Fix exception

* Update changelog
2021-11-07 21:25:18 +01:00
2873130259 Release 1.70.0 (#457) 2021-11-07 18:38:29 +01:00
d999a27159 Feature/move scraper configuration to symbol profile (#456)
* Move scraper configuration

* Update changelog
2021-11-07 18:36:28 +01:00
b6902e10ea Feature/improve import json file validation (#455)
* Improve import validation

* Update changelog
2021-11-07 17:16:19 +01:00
7f3f75386d Release 1.69.0 (#454) 2021-11-07 09:45:19 +01:00
678544748a Add symbol mapping (#452)
* Add symbol mapping

* Update changelog
2021-11-07 09:42:36 +01:00
632f3e3872 Add ok.csv (#453) 2021-11-06 21:09:06 +01:00
87301ddbd5 Feature/improve registration page (#451)
* Improve registration page

* Update changelog
2021-11-02 21:49:57 +01:00
7d03c373ac Release 1.68.0 (#450) 2021-11-01 21:27:23 +01:00
edb66bb166 Feature/extend statistics (#449)
* Extend statistics

* Update changelog
2021-11-01 21:15:09 +01:00
54bbc8446b Feature/prettify scraper symbol in chart (#448)
* Prettify scraper symbol in chart

* Update changelog
2021-11-01 20:29:16 +01:00
9933967e42 Release 1.67.0 (#447) 2021-11-01 19:33:45 +01:00
5618513d07 Update changelog (#446) 2021-10-31 17:02:41 +01:00
1397cd62a8 Feature/extend public page (#445)
* Extend public page

* Update changelog
2021-10-31 17:00:59 +01:00
e7fb31d1a6 add Dockerfile with all in one docker image (#431)
* Add Dockerfile with all in one docker image

* Change to alpine image and reduce node_modules size

* Improve documentation and fix changelog and license

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-10-30 18:54:14 +02:00
51e7b94ad0 Release 1.66.0 (#443) 2021-10-30 11:22:50 +02:00
9133ea38f3 Feature/order accesses (#441)
* Order accesses

* Update changelog
2021-10-30 10:49:17 +02:00
a864c617b9 Feature/improve landing page (#442)
* Add intro screen

* Update changelog
2021-10-30 10:46:54 +02:00
442df9d6f8 Release 1.65.0 (#440) 2021-10-25 21:03:36 +02:00
2de0e75cb8 Feature/add user interface for granting and revoking public access (#439)
* Add user interface for granting and revoking public access

* Update changelog
2021-10-25 20:46:36 +02:00
1296f95602 Feature/move data enhancer from data provider to data gathering (#437)
* Move data enhancers from data provider to data enhancer module

* Update changelog
2021-10-24 10:49:17 +02:00
e98dff877a Add link to Visual Studio Code (#436) 2021-10-22 20:20:59 +02:00
964b37af30 Add Visual Studio Code link (#435) 2021-10-22 20:17:33 +02:00
2d89d12d31 Release 1.64.0 (#434) 2021-10-21 22:25:58 +02:00
19e2d54791 Refactor custom cryptocurrencies (#433) 2021-10-21 22:11:12 +02:00
e24e5e1c44 Add custom crypto currencies (#432)
* Add custom cryptocurrencies
2021-10-21 21:20:01 +02:00
a0d4ff7920 Clean up ts-node script (#430)
Co-Authored-By: Davin <52686922+Frozies@users.noreply.github.com>
2021-10-20 22:46:58 +02:00
099ad18aaf refactor data-provider.service.ts to have dynamic list of services (#429)
* Refactor data-provider.service.ts to have dynamic list of services

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-10-20 21:41:40 +02:00
e3cd99f5d2 Release 1.63.0 (#427) 2021-10-19 18:49:11 +02:00
6dea9093ba Feature/add public portfolio (#426)
* Setup public portfolio page

* Update changelog
2021-10-19 18:27:50 +02:00
43104f81d0 Add test data (#424) 2021-10-17 11:56:35 +02:00
6d2e3b6e40 Feature/improve skeleton loader size (#425)
* Improve skeleton loader size

* Update changelog
2021-10-17 11:53:27 +02:00
1d9a31dbb8 Improve styling (#423) 2021-10-17 11:42:27 +02:00
ea0e92220c Release 1.62.0 (#422) 2021-10-17 10:52:08 +02:00
b57301ef50 Feature/extend import validation message (#421)
* Extend import validation message

* Update changelog
2021-10-16 21:23:15 +02:00
67dbc6b014 Release 1.61.0 (#420) 2021-10-15 22:30:44 +02:00
2e5176bacf Feature/extend import by csv files (#419)
* Support import of csv files

* Update changelog
2021-10-15 22:22:45 +02:00
060846023f Release 1.60.0 (#418) 2021-10-13 12:10:12 +02:00
f06a0fbbee Feature/validate duplicate orders for import (#416)
* Validate duplicate orders

* Update changelog
2021-10-13 11:52:04 +02:00
4ab6a1a071 Feature/harmonize page layouts (#417)
* Harmonize page layouts

* Update changelog
2021-10-13 11:51:33 +02:00
93dcbeb6c7 Feature/add validation for import (#415)
* Valid data types
* Maximum number of orders
* Data provider service returns data for the dataSource / symbol pair
2021-10-12 22:19:32 +02:00
b9f0a57522 Bugfix/unregister chartjs plugin datalabels (#414)
* Unregister chartjs-plugin-datalabels

* Update changelog
2021-10-12 09:23:37 +02:00
174c1d1a62 Release 1.59.0 (#413) 2021-10-11 20:35:06 +02:00
f308ae7a13 add sectors and countries for ETFs (#410)
* Update changelog

Co-Authored-By: Valentin Zickner <valentin@coderworks.de>

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2021-10-11 19:32:21 +02:00
a7a6b0608b Add ghostfolio-docker (#411)
Co-Authored-By: psychowood <115389+psychowood@users.noreply.github.com>
2021-10-09 10:45:58 +02:00
15a61b7a20 Feature/improve values of global heat map (#408)
* Convert value

* Update changelog
2021-10-04 21:22:42 +02:00
d1eedf9726 Bugfix/various fixes (#407)
* Fix links

* Update column

* Fix impersonation mode

* Update changelog
2021-10-03 22:04:23 +02:00
30a592b524 Release 1.58.1 (#406) 2021-10-03 10:50:01 +02:00
de94494aa0 Bugfix/fix symbol conversion for yahoo finance (#405)
* Fix symbol conversion for Yahoo Finance

* Update changelog
2021-10-03 10:40:21 +02:00
d3c6788ad5 Release 1.58.0 (#404) 2021-10-02 20:45:02 +02:00
3ec4a73b35 Feature/improve tooltips (#403)
* Improve tooltips

* Update changelog
2021-10-02 20:38:41 +02:00
1050bfa098 Feature/improve yahoo finance symbol conversion (#402)
* Improve symbol conversion

* Update changelog
2021-10-02 10:28:06 +02:00
595ec1d7b4 Feature/upgrade envalid to version 7.2.1 (#401)
* Upgrade envalid

* Update changelog
2021-09-30 21:54:58 +02:00
c8389599b6 Release 1.57.0 (#400) 2021-09-29 21:34:50 +02:00
8769fe4c90 Improve styling (#399) 2021-09-29 21:10:04 +02:00
4219e1121e Improve style (#398) 2021-09-29 21:05:01 +02:00
f558eb8de8 Fix template (#397) 2021-09-29 21:04:41 +02:00
fe2bd6eea8 Feature/protect endpoints (#396)
* Protect endpoints

* Update changelog
2021-09-28 21:37:01 +02:00
035052be99 Feature/improve exchange rates table (#394)
* Improve exchange rates table

* Update changelog
2021-09-26 20:57:37 +02:00
bcdd2780b3 Release 1.56.0 (#393) 2021-09-25 18:02:02 +02:00
22d1ed7920 Extend data (#392)
* assetClass
* assetSubClass
* currency
* name
2021-09-25 17:42:45 +02:00
39d9828f9f Feature/respect account currency in exchange rate data service (#391)
* Respect the accounts' currencies

* Update changelog
2021-09-25 16:45:21 +02:00
6333aa972d Bugfix/fix data gathering after seed (#390)
* Fix data gathering after seed

* Update changelog
2021-09-25 16:44:24 +02:00
554f2f861f Upgrade @types (#389) 2021-09-25 09:20:47 +02:00
dcee651098 Feature/support unlimited currencies (#387)
* Support unlimited currencies

* Update changelog
2021-09-24 21:09:48 +02:00
508a48f4c3 Feature/hide actions in presenter view (#380)
* Hide actions if restricted view is active

* Update changelog
2021-09-24 20:15:10 +02:00
8466e3d73f Feature/always show adapted portfolio page (#388)
* Always show adapted portfolio page

* Update changelog
2021-09-24 20:12:17 +02:00
9ae9904389 Feature/add story for line chart component (#385)
* Add story for line chart component

* Update changelog
2021-09-20 21:44:47 +02:00
af022ae316 Feature/add story for portfolio proportion chart component (#384)
* Add story

* Use new component

* Update changelog
2021-09-20 20:45:58 +02:00
5cd6edaf3a Release 1.55.0 (#383) 2021-09-20 11:58:48 +02:00
98be8745d9 Bugfix/fix create or edit transaction dialog (#382)
* Fix create or edit transaction dialog

* Update changelog
2021-09-20 11:49:50 +02:00
861dff9210 Feature/upgrade storybook dependencies (#381)
* Upgrade storybook dependencies
2021-09-19 18:01:41 +02:00
f2364eed10 Feature/remove default value of data source (#379)
* Remove default value of dataSource

* Update changelog
2021-09-19 17:16:29 +02:00
d5392de7c9 Release 1.54.0 (#378) 2021-09-18 20:21:43 +02:00
0f72673ef4 Feature/respect data source in symbol data endpoint (#370)
* Respect data source in symbol data endpoint

* Respect data source in the data provider service

* Combine symbol with data source in get() of data provider service

* Improve search functionality for multiple data sources

* Update changelog
2021-09-18 19:32:22 +02:00
641fe4e8f4 Bugfix/net performance in positions endpoint (#377)
* Nullify net performance

* Update changelog
2021-09-18 19:20:14 +02:00
18e06bb6e6 Feature/hide sign if performance is zero (#376)
* Hide sign if performance is zero

* Update changelog
2021-09-18 13:36:49 +02:00
5b588c2000 Bugfix/hide the current net performance (#373)
* Hide the current net performance

* Update changelog
2021-09-15 22:27:18 +02:00
162d19fa44 Release 1.53.0 (#369) 2021-09-13 21:37:18 +02:00
4a815d2031 Feature/change data gathering selection (#368)
* Change data gathering selection from distinct orders to symbol profiles

* Update changelog
2021-09-13 21:26:23 +02:00
d2aeeb3e88 optimize annual performance calculation (#367)
* Optimize annual performance calculation

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-12 20:08:42 +02:00
ba926ffcf2 Release 1.52.0 (#366) 2021-09-11 21:36:22 +02:00
5ea455b98b Feature/upgrade simplewebauthn dependencies to version 4.1.0 (#365)
* Upgrade @simplewebauthn dependencies to version 4.1.0
  * @simplewebauthn/browser
  * @simplewebauthn/server

* Update changelog
2021-09-11 21:23:06 +02:00
39f315aba0 Feature/add annualized performance (#364)
* Add annualized performance

* Update changelog
2021-09-11 20:16:53 +02:00
df2dfc20a1 Feature/add slack channel (#363)
* Add Slack channel

* Update changelog
2021-09-11 12:06:28 +02:00
81e83d4cea Release 1.51.0 (#362) 2021-09-11 11:25:07 +02:00
5d4156ecec Feature/refactor position detail dialog (#355)
* Add name to portfolio position endpoint

* Update changelog
2021-09-11 11:23:47 +02:00
4693a8baa2 Release 1.50.0 (#361) 2021-09-11 11:21:53 +02:00
773444b1e2 Bugfix/fix home button overlap on ios (#360)
* Fix overlap

* Update changelog
2021-09-11 11:17:49 +02:00
3c46bde8d5 Bugfix/fix fear and greed index (#359)
* Fix fear and greed index
* Refactor fear and greed index symbol
   * GF.FEAR_AND_GREED_INDEX -> _GF_FEAR_AND_GREED_INDEX

* Update changelog
2021-09-11 11:14:55 +02:00
63ee33b685 Use 'import type' to import types, eliminate webpack warnings (#358) 2021-09-11 09:27:22 +02:00
bc87c0a3e1 Add slack (#357) 2021-09-10 18:11:35 +02:00
caa9fc3efa Release 1.49.0 (#354) 2021-09-08 22:19:53 +02:00
9ed82ac82b Feature/improve labels of allocation chart by symbol (#353)
* Improve labels

* Update changelog
2021-09-08 22:03:33 +02:00
9c9ca4ab1e Add labels to allocation piecharts (#337) 2021-09-08 21:31:34 +02:00
b0b0942162 Release 1.48.0 (#352)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:23:07 +02:00
9cbf789c22 Bugfix/fix values in position detail dialog (#351)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:11:38 +02:00
ee5ab05d8a Release 1.47.1 (#350) 2021-09-06 22:55:08 +02:00
20731c67cb Release 1.47.0 (#349) 2021-09-06 22:34:17 +02:00
bf8856ad19 Bugfix/fix search for cryptocurrencies (#348)
* Fix the search for cryptocurrency symbols

* Update changelog
2021-09-06 22:02:49 +02:00
a31d79821d Release 1.46.0 (#347) 2021-09-05 22:15:21 +02:00
48ab862bb6 net performance for current positions (#330)
* implement fees for transaction points #324

* add net performance to current positions #324

* add net performance to calculate timeline #324

* make timeline fee accumulated by default #324

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-05 21:21:22 +02:00
ba234a470e Feature/add storybook story for trend indicator component (#346)
* Add storybook story for trend indicator component

* Update changelog
2021-09-05 08:49:06 +02:00
ccae660104 Feature/add storybook story for no transactions info component (#345)
* Add storybook story for no transactions info component

* Update changelog
2021-09-05 08:34:02 +02:00
21ed91d184 Feature/add storybook story for logo component (#344)
* Add storybook story for logo component

* Update changelog
2021-09-05 08:03:32 +02:00
5fd413e57e Feature/setup storybook (#332)
* Setup ui library with storybook

* Add value component with story

* Update changelog
2021-09-04 22:12:54 +02:00
4c194c938a Feature/add contributors count to statistics (#342)
* Add contributors count to statistics

* Update changelog
2021-09-04 19:46:24 +02:00
a4d049e53d Release 1.45.0 (#340) 2021-09-04 19:20:09 +02:00
f9c4408126 Update yarn start:server to watch for changes (#338) 2021-09-04 19:05:04 +02:00
d046f1d498 Feature/upgrade nx to version 12.8.0 (#331)
* Upgrade angular and Nx

* Update changelog
2021-09-04 11:25:40 +02:00
ad96d6e53e Feature/upgrade prisma from version 2.24.1 to 2.30.2 (#325)
* Upgrade prisma

* Update changelog
2021-09-04 10:49:09 +02:00
747e5b63fa Feature/restructure allocations page including allocations by symbol (#333)
* Restructure allocations page

* Update changelog
2021-09-03 23:20:30 +02:00
b1187cf880 Add a new symbol allocation chart (#326)
* Add a new symbol allocation chart

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-03 17:48:35 +02:00
ba9e6eab58 Feature/add link to transactions below holdings (#329)
* Add link: Manage transactions

* Update changelog
2021-09-02 21:17:01 +02:00
01feead017 Show decimal transactionCount and singular for 1 transaction (#327)
* Show decimal `transactionCount` and singular for 1 transaction

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-02 20:47:27 +02:00
6a0cfb8f77 Release 1.44.0 (#323) 2021-08-30 18:22:41 +02:00
6386786ac0 Bugfix/improve symbol lookup (#322)
* Improve symbol lookup

* Update changelog
2021-08-30 18:08:21 +02:00
d3be6577c8 Add feature: time-weighted rate of return (TWR) (#321) 2021-08-29 17:09:11 +02:00
73a967a7e5 Feature/add cash as asset sub class (#319)
* Add cash as asset sub class

* Update changelog
2021-08-26 21:49:02 +02:00
836ff6ec13 Feature/upgrade svgmap to version 2.6.0 (#318)
* Upgrade svgmap

* Update changelog
2021-08-26 21:23:49 +02:00
c5bb3023d3 Bugfix/filter out positions without quantity (#317)
* Filter out positions without any quantity

* Update changelog
2021-08-26 17:45:04 +02:00
695c378b48 Release 1.43.0 (#316) 2021-08-24 21:31:19 +02:00
fe975945d1 Feature/add fallback for loading currencies (#315)
* Add fallback for loading currencies

* Update changelog
2021-08-24 21:09:02 +02:00
d8782b0d4c Feature/automate countries for stocks in symbol profile data (#314)
* Automate countries for stocks in symbol profile data

* Update changelog
2021-08-24 20:24:18 +02:00
e14f08a8fb Release 1.42.0 (#313) 2021-08-22 22:37:44 +02:00
72c065a59d Feature/introduce asset sub class (#312)
* Introduce asset sub class

* Update changelog
2021-08-22 22:19:10 +02:00
98dac4052a Feature/add subscription type to the admin user table (#311)
* Add the subscription type to the user table in the admin control panel

* Update changelog
2021-08-22 22:11:05 +02:00
2083d28d02 Feature/minor improvements in the page components (#310)
* Move permissions to constructor

* Sort imports
2021-08-22 10:25:34 +02:00
316 changed files with 13388 additions and 5086 deletions

View File

@ -1 +1,2 @@
/dist /dist
/test/import

11
.storybook/main.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
stories: [],
addons: ['@storybook/addon-essentials']
// uncomment the property below if you want to apply some webpack config globally
// webpackFinal: async (config, { configType }) => {
// // Make whatever fine-grained changes you need that should apply to all storybook configs
// // Return the altered config
// return config;
// },
};

10
.storybook/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"exclude": [
"../**/*.spec.js",
"../**/*.spec.ts",
"../**/*.spec.tsx",
"../**/*.spec.jsx"
],
"include": ["../**/*"]
}

3
.vscode/launch.json vendored
View File

@ -26,7 +26,8 @@
"skipFiles": [ "skipFiles": [
"${workspaceFolder}/node_modules/**/*.js", "${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js" "<node_internals>/**/*.js"
] ],
"console": "integratedTerminal"
} }
] ]
} }

View File

@ -5,6 +5,370 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.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
- Added a story for the line chart component
- Added a story for the portfolio proportion chart component
### Changed
- Changed the navigation to always show the portfolio page
- Migrated the data type of currencies from `enum` to `string` in the database
- Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`)
- Respected the accounts' currencies in the exchange rate service
### Fixed
- Hid the actions from the accounts table in the _Presenter View_
- Hid the actions from the transactions table in the _Presenter View_
- Fixed the data gathering of the initial project setup (database seeding)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.55.0 - 20.09.2021
### Changed
- Removed the default value of the data source attribute
- Upgraded `@storybook` dependencies
### Fixed
- Fixed an issue in the create or edit transaction dialog
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.54.0 - 18.09.2021
### Added
- Added the data source attribute to the symbol profile model
### Changed
- Respected the data source attribute in the data provider service
- Respected the data source attribute in the symbol data endpoint
- Improved the search functionality of the data management (multiple data sources)
### Fixed
- Hid the net performance in the _Presenter View_ (portfolio holdings and summary tab on the home page)
- Hid the sign if the performance is zero in the value component
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.53.0 - 13.09.2021
### Changed
- Optimized the annualized performance calculation
- Changed the data gathering selection from distinct orders to symbol profiles
## 1.52.0 - 11.09.2021
### Added
- Added the annualized performance to the portfolio summary tab on the home page
- Added the Ghostfolio Slack channel to the about page
### Changed
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `3.0.0` to `4.1.0`
### Fixed
- Fixed the sign in with fingerprint for some android devices
## 1.51.0 - 11.09.2021
### Changed
- Provided the name in the portfolio position endpoint
## 1.50.0 - 11.09.2021
### Fixed
- Fixed the _Fear & Greed Index_ (market mood)
- Fixed the overlap of the home button with tabs on iOS (_Add to Home Screen_)
## 1.49.0 - 08.09.2021
### Added
- Added labels to the allocation chart by symbol on desktop
## 1.48.0 - 07.09.2021
### Added
- Added the attribute `precision` in the value component
### Fixed
- Hid the performance in the _Presenter View_
## 1.47.1 - 06.09.2021
### Fixed
- Fixed the search functionality for cryptocurrency symbols
## 1.46.0 - 05.09.2021
### Added
- Extended the statistics section on the about page by the _GitHub_ contributors count
- Set up _Storybook_
- Added a story for the logo component
- Added a story for the no transactions info component
- Added a story for the trend indicator component
- Added a story for the value component
### Changed
- Switched from gross to net performance
- Restructured the portfolio summary tab on the home page (fees and net performance)
## 1.45.0 - 04.09.2021
### Added
- Added a link below the holdings to manage the transactions
- Added the allocation chart by symbol
### Changed
- Restructured the allocations page
- Upgraded `angular` from version `12.0.4` to `12.2.4`
- Upgraded `@angular/cdk` and `@angular/material` from version `12.0.6` to `12.2.4`
- Upgraded `Nx` from version `12.5.4` to `12.8.0`
- Upgraded `prisma` from version `2.24.1` to `2.30.2`
### Fixed
- Fixed the value formatting for integers (transactions count)
## 1.44.0 - 30.08.2021
### Changed
- Extended the sub classification of assets by cash
- Upgraded `svgmap` from version `2.1.1` to `2.6.0`
### Fixed
- Filtered out positions without any quantity in the positions table
- Improved the symbol lookup: allow saving with valid symbol in create or edit transaction dialog
## 1.43.0 - 24.08.2021
### Added
- Extended the data management of symbol profile data by countries (automated for stocks)
- Added a fallback for initially loading currencies if historical data is not yet available
## 1.42.0 - 22.08.2021
### Added
- Added the subscription type to the users table of the admin control panel
- Introduced the sub classification of assets
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.41.0 - 21.08.2021 ## 1.41.0 - 21.08.2021
### Added ### Added
@ -56,7 +420,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn database:push`) - Apply data migration (`yarn database:migrate`)
## 1.38.0 - 14.08.2021 ## 1.38.0 - 14.08.2021
@ -116,7 +480,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn database:push`) - Apply data migration (`yarn database:migrate`)
## 1.34.0 - 07.08.2021 ## 1.34.0 - 07.08.2021

51
Dockerfile Normal file
View File

@ -0,0 +1,51 @@
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 yarn
# 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" ]

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong> <strong>Open Source Wealth Management Software made for Humans</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://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/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>
</p> </p>
<p> <p>
<a href="#contributing"> <a href="#contributing">
@ -34,7 +34,7 @@
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
## Why Ghostfolio? ## Why Ghostfolio?
@ -62,7 +62,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ Multi account management
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`) - ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Dark Mode - ✅ Dark Mode
@ -81,13 +81,52 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com). The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Getting Started ## Run with Docker
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
### Setup Docker Image
Run the following commands to build and start the Docker image:
```bash
docker-compose -f docker/docker-compose-build-local.yml build
docker-compose -f docker/docker-compose-build-local.yml up
```
### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
```
### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
### Migrate Database
With the following command you can keep your database schema in sync after a Ghostfolio version update:
```bash
docker-compose -f docker/docker-compose-build-local.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+) - [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- [Docker](https://www.docker.com/products/docker-desktop)
### Setup ### Setup
@ -95,27 +134,27 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
1. Run `cd docker` 1. Run `cd docker`
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `cd -` to go back to the project root directory 1. Run `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. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development)) 1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9` 1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
## Development ### Start Server
Please make sure you have completed the instructions from [_Setup_](#Setup).
### Start server
<ol type="a"> <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> <li>Serve: Run <code>yarn start:server</code></li>
</ol> </ol>
### Start client ### Start Client
Run `yarn start:client` Run `yarn start:client`
### Start _Storybook_
Run `yarn start:storybook`
## Testing ## Testing
Run `yarn test` Run `yarn test`
@ -124,7 +163,7 @@ Run `yarn test`
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
## License ## License

View File

@ -6,13 +6,16 @@
"defaultProject": "api", "defaultProject": "api",
"schematics": { "schematics": {
"@nrwl/angular:application": { "@nrwl/angular:application": {
"linter": "eslint",
"unitTestRunner": "jest", "unitTestRunner": "jest",
"e2eTestRunner": "cypress" "e2eTestRunner": "cypress"
}, },
"@nrwl/angular:library": { "@nrwl/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest" "unitTestRunner": "jest"
}, },
"@nrwl/nest": {} "@nrwl/nest": {},
"@nrwl/angular:component": {}
}, },
"projects": { "projects": {
"api": { "api": {
@ -32,6 +35,7 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"generatePackageJson": true,
"optimization": true, "optimization": true,
"extractLicenses": true, "extractLicenses": true,
"inspect": false, "inspect": false,
@ -96,12 +100,12 @@
{ {
"glob": "CHANGELOG.md", "glob": "CHANGELOG.md",
"input": "", "input": "",
"output": "./" "output": "./assets"
}, },
{ {
"glob": "LICENSE", "glob": "LICENSE",
"input": "", "input": "",
"output": "./" "output": "./assets"
}, },
{ {
"glob": "robots.txt", "glob": "robots.txt",
@ -239,6 +243,90 @@
} }
} }
} }
},
"ui": {
"projectType": "library",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "libs/ui",
"sourceRoot": "libs/ui/src",
"prefix": "gf",
"architect": {
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"],
"options": {
"jestConfig": "libs/ui/jest.config.js",
"passWithNoTests": true
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
}
},
"storybook": {
"builder": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/angular",
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"builder": "@nrwl/storybook:build",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/angular",
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
},
"ui-e2e": {
"root": "apps/ui-e2e",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/ui-e2e/cypress.json",
"devServerTarget": "ui:storybook",
"tsConfig": "apps/ui-e2e/tsconfig.json"
},
"configurations": {
"ci": {
"devServerTarget": "ui:storybook:ci"
}
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
}
}
}
} }
} }
} }

View File

@ -1,10 +1,29 @@
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types'; import {
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessModule } from './access.module';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {
@ -20,13 +39,69 @@ export class AccessController {
include: { include: {
GranteeUser: true GranteeUser: true
}, },
orderBy: { granteeUserId: 'asc' },
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
return accessesWithGranteeUser.map((access) => { return accessesWithGranteeUser.map((access) => {
if (access.GranteeUser) {
return {
granteeAlias: access.GranteeUser?.alias,
id: access.id,
type: 'RESTRICTED_VIEW'
};
}
return { 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(
getPermissions(this.request.user.role),
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(
getPermissions(this.request.user.role),
permissions.deleteAccess
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accessService.deleteAccess({
id_userId: {
id,
userId: this.request.user.id
}
});
}
} }

View File

@ -5,8 +5,9 @@ import { AccessController } from './access.controller';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
@Module({ @Module({
imports: [],
controllers: [AccessController], controllers: [AccessController],
exports: [AccessService],
imports: [],
providers: [AccessService, PrismaService] providers: [AccessService, PrismaService]
}) })
export class AccessModule {} export class AccessModule {}

View File

@ -1,12 +1,23 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Access, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class AccessService { export class AccessService {
public constructor(private readonly prismaService: PrismaService) {} 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: { public async accesses(params: {
include?: Prisma.AccessInclude; include?: Prisma.AccessInclude;
skip?: number; skip?: number;
@ -26,4 +37,18 @@ export class AccessService {
where 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
});
}
} }

View File

@ -0,0 +1 @@
export class CreateAccessDto {}

View File

@ -6,7 +6,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -1,7 +1,7 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Account, Currency, Order, Platform, Prisma } from '@prisma/client'; import { Account, Order, Platform, Prisma } from '@prisma/client';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -95,7 +95,7 @@ export class AccountService {
public async getCashDetails( public async getCashDetails(
aUserId: string, aUserId: string,
aCurrency: Currency aCurrency: string
): Promise<CashDetails> { ): Promise<CashDetails> {
let totalCashBalance = 0; let totalCashBalance = 0;

View File

@ -1,4 +1,4 @@
import { AccountType, Currency } from '@prisma/client'; import { AccountType } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto { export class CreateAccountDto {
@ -9,7 +9,7 @@ export class CreateAccountDto {
balance: number; balance: number;
@IsString() @IsString()
currency: Currency; currency: string;
@IsString() @IsString()
name: string; name: string;

View File

@ -1,4 +1,4 @@
import { AccountType, Currency } from '@prisma/client'; import { AccountType } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto { export class UpdateAccountDto {
@ -9,7 +9,7 @@ export class UpdateAccountDto {
balance: number; balance: number;
@IsString() @IsString()
currency: Currency; currency: string;
@IsString() @IsString()
id: string; id: string;

View File

@ -5,7 +5,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,

View File

@ -1,3 +1,4 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -14,9 +15,11 @@ import { AdminService } from './admin.service';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
PrismaModule PrismaModule,
SubscriptionModule
], ],
controllers: [AdminController], controllers: [AdminController],
providers: [AdminService] providers: [AdminService],
exports: [AdminService]
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -1,68 +1,41 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { AdminData } from '@ghostfolio/common/interfaces'; import { AdminData } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {} ) {}
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
return { return {
exchangeRates: [ exchangeRates: this.exchangeRateDataService
{ .getCurrencies()
label1: Currency.EUR, .filter((currency) => {
label2: Currency.CHF, return currency !== baseCurrency;
value: await this.exchangeRateDataService.toCurrency( })
1, .map((currency) => {
Currency.EUR, return {
Currency.CHF label1: baseCurrency,
) label2: currency,
}, value: this.exchangeRateDataService.toCurrency(
{ 1,
label1: Currency.GBP, baseCurrency,
label2: Currency.CHF, currency
value: await this.exchangeRateDataService.toCurrency( )
1, };
Currency.GBP, }),
Currency.CHF
)
},
{
label1: Currency.USD,
label2: Currency.CHF,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.USD,
Currency.CHF
)
},
{
label1: Currency.USD,
label2: Currency.EUR,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.USD,
Currency.EUR
)
},
{
label1: Currency.USD,
label2: Currency.GBP,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.USD,
Currency.GBP
)
}
],
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
transactionCount: await this.prismaService.order.count(), transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(), userCount: await this.prismaService.user.count(),
@ -107,7 +80,8 @@ export class AdminService {
} }
}, },
createdAt: true, createdAt: true,
id: true id: true,
Subscription: true
}, },
take: 30, take: 30,
where: { where: {
@ -118,16 +92,23 @@ export class AdminService {
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id }) => { ({ _count, alias, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration; const engagement = Analytics.activityCount / daysSinceRegistration;
const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.subscriptionService.getSubscription(Subscription)
: undefined;
return { return {
alias, alias,
createdAt, createdAt,
engagement, engagement,
id, id,
subscription,
accountCount: _count.Account || 0, accountCount: _count.Account || 0,
lastActivity: Analytics.updatedAt, lastActivity: Analytics.updatedAt,
transactionCount: _count.Order || 0 transactionCount: _count.Order || 0

View File

@ -4,7 +4,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Delete, Delete,

View File

@ -62,10 +62,10 @@ export class AuthController {
} }
} }
@Get('webauthn/generate-attestation-options') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async generateAttestationOptions() { public async generateRegistrationOptions() {
return this.webAuthService.generateAttestationOptions(); return this.webAuthService.generateRegistrationOptions();
} }
@Post('webauthn/verify-attestation') @Post('webauthn/verify-attestation')

View File

@ -1,5 +1,6 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
@ -17,7 +18,8 @@ import { JwtStrategy } from './jwt.strategy';
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}) }),
SubscriptionModule
], ],
providers: [ providers: [
AuthDeviceService, AuthDeviceService,

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; 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 { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
import { Strategy } from 'passport-google-oauth20'; import { Strategy } from 'passport-google-oauth20';
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
}; };
done(null, user); done(null, user);
} catch (err) { } catch (error) {
console.error(err); Logger.error(error);
done(err, false); done(error, false);
} }
} }
} }

View File

@ -2,25 +2,26 @@ import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Inject, Inject,
Injectable, Injectable,
InternalServerErrorException InternalServerErrorException,
Logger
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { import {
GenerateAssertionOptionsOpts, GenerateAuthenticationOptionsOpts,
GenerateAttestationOptionsOpts, GenerateRegistrationOptionsOpts,
VerifiedAssertion, VerifiedAuthenticationResponse,
VerifiedAttestation, VerifiedRegistrationResponse,
VerifyAssertionResponseOpts, VerifyAuthenticationResponseOpts,
VerifyAttestationResponseOpts, VerifyRegistrationResponseOpts,
generateAssertionOptions, generateAuthenticationOptions,
generateAttestationOptions, generateRegistrationOptions,
verifyAssertionResponse, verifyAuthenticationResponse,
verifyAttestationResponse verifyRegistrationResponse
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { import {
@ -46,10 +47,10 @@ export class WebAuthService {
return this.configurationService.get('ROOT_URL'); return this.configurationService.get('ROOT_URL');
} }
public async generateAttestationOptions() { public async generateRegistrationOptions() {
const user = this.request.user; const user = this.request.user;
const opts: GenerateAttestationOptionsOpts = { const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio', rpName: 'Ghostfolio',
rpID: this.rpID, rpID: this.rpID,
userID: user.id, userID: user.id,
@ -63,7 +64,7 @@ export class WebAuthService {
} }
}; };
const options = generateAttestationOptions(opts); const options = generateRegistrationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -84,27 +85,27 @@ export class WebAuthService {
const user = this.request.user; const user = this.request.user;
const expectedChallenge = user.authChallenge; const expectedChallenge = user.authChallenge;
let verification: VerifiedAttestation; let verification: VerifiedRegistrationResponse;
try { try {
const opts: VerifyAttestationResponseOpts = { const opts: VerifyRegistrationResponseOpts = {
credential, credential,
expectedChallenge, expectedChallenge,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID expectedRPID: this.rpID
}; };
verification = await verifyAttestationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
const { verified, attestationInfo } = verification; const { registrationInfo, verified } = verification;
const devices = await this.deviceService.authDevices({ const devices = await this.deviceService.authDevices({
where: { userId: user.id } where: { userId: user.id }
}); });
if (verified && attestationInfo) { if (registrationInfo && verified) {
const { credentialPublicKey, credentialID, counter } = attestationInfo; const { counter, credentialID, credentialPublicKey } = registrationInfo;
let existingDevice = devices.find( let existingDevice = devices.find(
(device) => device.credentialId === credentialID (device) => device.credentialId === credentialID
@ -115,9 +116,9 @@ export class WebAuthService {
* Add the returned device to the user's list of devices * Add the returned device to the user's list of devices
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialPublicKey, credentialPublicKey,
credentialId: credentialID, credentialId: credentialID,
counter,
User: { connect: { id: user.id } } User: { connect: { id: user.id } }
}); });
} }
@ -138,20 +139,20 @@ export class WebAuthService {
throw new Error('Device not found'); throw new Error('Device not found');
} }
const opts: GenerateAssertionOptionsOpts = { const opts: GenerateAuthenticationOptionsOpts = {
timeout: 60000,
allowCredentials: [ allowCredentials: [
{ {
id: device.credentialId, id: device.credentialId,
type: 'public-key', transports: ['internal'],
transports: ['internal'] type: 'public-key'
} }
], ],
userVerification: 'preferred', rpID: this.rpID,
rpID: this.rpID timeout: 60000,
userVerification: 'preferred'
}; };
const options = generateAssertionOptions(opts); const options = generateAuthenticationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -177,29 +178,29 @@ export class WebAuthService {
const user = await this.userService.user({ id: device.userId }); const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAssertion; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAssertionResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
credential, credential,
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
authenticator: { authenticator: {
credentialID: device.credentialId, credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey, credentialPublicKey: device.credentialPublicKey,
counter: device.counter counter: device.counter
} },
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
}; };
verification = verifyAssertionResponse(opts); verification = verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }
const { verified, assertionInfo } = verification; const { verified, authenticationInfo } = verification;
if (verified) { if (verified) {
device.counter = assertionInfo.newCounter; device.counter = authenticationInfo.newCounter;
await this.deviceService.updateAuthDevice({ await this.deviceService.updateAuthDevice({
data: device, data: device,

View File

@ -1,6 +1,6 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -1,30 +1,30 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheController } from './cache.controller'; import { CacheController } from './cache.controller';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule,
SymbolProfileModule
],
controllers: [CacheController], controllers: [CacheController],
providers: [ providers: [
AlphaVantageService,
CacheService, CacheService,
ConfigurationService, ConfigurationService,
DataGatheringService, DataGatheringService,
DataProviderService, PrismaService
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
] ]
}) })
export class CacheModule {} export class CacheModule {}

View File

@ -1,9 +1,9 @@
import { Currency, Type } from '@prisma/client'; import { Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto { export class CreateOrderDto {
@IsString() @IsString()
currency: Currency; currency: string;
@IsISO8601() @IsISO8601()
date: string; date: string;

View File

@ -1,7 +1,7 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config'; import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions'; import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -1,6 +1,4 @@
import { Currency } from '@prisma/client';
export interface Data { export interface Data {
currency: Currency; currency: string;
value: number; value: number;
} }

View File

@ -1,5 +1,5 @@
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -1,4 +1,3 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@ -18,6 +17,6 @@ import { ExportService } from './export.service';
RedisCacheModule RedisCacheModule
], ],
controllers: [ExportController], controllers: [ExportController],
providers: [CacheService, ExportService] providers: [ExportService]
}) })
export class ExportModule {} export class ExportModule {}

View File

@ -1,7 +1,11 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Order } from '@prisma/client'; import { Order } from '@prisma/client';
import { IsArray } from 'class-validator'; import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
export class ImportDataDto { export class ImportDataDto {
@IsArray() @IsArray()
orders: Partial<Order>[]; @Type(() => CreateOrderDto)
@ValidateNested({ each: true })
orders: Order[];
} }

View File

@ -1,10 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
HttpException, HttpException,
Inject, Inject,
Logger,
Post, Post,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
@ -39,10 +40,13 @@ export class ImportController {
userId: this.request.user.id userId: this.request.user.id
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), {
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
message: [error.message]
},
StatusCodes.BAD_REQUEST StatusCodes.BAD_REQUEST
); );
} }

View File

@ -1,11 +1,17 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client'; import { Order } from '@prisma/client';
import { parseISO } from 'date-fns'; import { isSameDay, parseISO } from 'date-fns';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor(private readonly orderService: OrderService) {} private static MAX_ORDERS_TO_IMPORT = 20;
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService
) {}
public async import({ public async import({
orders, orders,
@ -14,7 +20,10 @@ export class ImportService {
orders: Partial<Order>[]; orders: Partial<Order>[];
userId: string; userId: string;
}): Promise<void> { }): Promise<void> {
await this.validateOrders({ orders, userId });
for (const { for (const {
accountId,
currency, currency,
dataSource, dataSource,
date, date,
@ -25,6 +34,11 @@ export class ImportService {
unitPrice unitPrice
} of orders) { } of orders) {
await this.orderService.createOrder({ await this.orderService.createOrder({
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
currency, currency,
dataSource, dataSource,
fee, fee,
@ -37,4 +51,53 @@ export class ImportService {
}); });
} }
} }
private async validateOrders({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}) {
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
throw new Error('Too many transactions');
}
const existingOrders = await this.orderService.orders({
orderBy: { date: 'desc' },
where: { userId }
});
for (const [
index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
] of orders.entries()) {
const duplicateOrder = existingOrders.find((order) => {
return (
order.currency === currency &&
order.dataSource === dataSource &&
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
order.fee === fee &&
order.quantity === quantity &&
order.symbol === symbol &&
order.type === type &&
order.unitPrice === unitPrice
);
});
if (duplicateOrder) {
throw new Error(`orders.${index} is a duplicate transaction`);
}
const result = await this.dataProviderService.get([
{ dataSource, symbol }
]);
if (result[symbol] === undefined) {
throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
}
}
} }

View File

@ -1,11 +1,11 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -14,22 +14,22 @@ import { InfoService } from './info.service';
@Module({ @Module({
imports: [ imports: [
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}) }),
RedisCacheModule,
SymbolProfileModule
], ],
controllers: [InfoController], controllers: [InfoController],
providers: [ providers: [
AlphaVantageService,
ConfigurationService, ConfigurationService,
DataGatheringService, DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
InfoService, InfoService,
PrismaService, PrismaService
RakutenRapidApiService,
YahooFinanceService
] ]
}) })
export class InfoModule {} export class InfoModule {}

View File

@ -1,24 +1,31 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f'; private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
private static CACHE_KEY_STATISTICS = 'STATISTICS';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly redisCacheService: RedisCacheService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
@ -56,9 +63,10 @@ export class InfoService {
...info, ...info,
globalPermissions, globalPermissions,
platforms, platforms,
currencies: Object.values(Currency), currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
statistics: await this.getStatistics(), statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions() subscriptions: await this.getSubscriptions()
}; };
@ -90,6 +98,27 @@ export class InfoService {
}); });
} }
private async countGitHubContributors(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
'GET',
'json',
200,
{
'User-Agent': 'request'
}
);
const contributors = await get();
return contributors?.length;
} catch (error) {
Logger.error(error);
return undefined;
}
}
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const get = bent( const get = bent(
@ -105,12 +134,34 @@ export class InfoService {
const { stargazers_count } = await get(); const { stargazers_count } = await get();
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
return undefined; 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 getDemoAuthToken() { private getDemoAuthToken() {
return this.jwtService.sign({ return this.jwtService.sign({
id: InfoService.DEMO_USER_ID id: InfoService.DEMO_USER_ID
@ -129,15 +180,40 @@ export class InfoService {
return undefined; 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 activeUsers1d = await this.countActiveUsers(1);
const activeUsers7d = await this.countActiveUsers(7);
const activeUsers30d = await this.countActiveUsers(30); const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30);
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers(); const gitHubStargazers = await this.countGitHubStargazers();
return { statistics = {
activeUsers1d, activeUsers1d,
activeUsers7d,
activeUsers30d, activeUsers30d,
gitHubStargazers gitHubContributors,
gitHubStargazers,
newUsers30d
}; };
await this.redisCacheService.set(
InfoService.CACHE_KEY_STATISTICS,
JSON.stringify(statistics)
);
return statistics;
} }
private async getSubscriptions(): Promise<Subscription[]> { private async getSubscriptions(): Promise<Subscription[]> {

View File

@ -1,14 +1,14 @@
import { Currency, DataSource, Type } from '@prisma/client'; import { DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator'; import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto { export class CreateOrderDto {
@IsString() @IsString()
accountId: string; accountId: string;
@IsString() @IsString()
currency: Currency; currency: string;
@IsString() @IsEnum(DataSource, { each: true })
dataSource: DataSource; dataSource: DataSource;
@IsISO8601() @IsISO8601()
@ -23,7 +23,7 @@ export class CreateOrderDto {
@IsString() @IsString()
symbol: string; symbol: string;
@IsString() @IsEnum(Type, { each: true })
type: Type; type: Type;
@IsNumber() @IsNumber()

View File

@ -6,7 +6,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -56,7 +56,9 @@ export class OrderService {
]); ]);
} }
this.dataGatheringService.gatherProfileData([data.symbol]); this.dataGatheringService.gatherProfileData([
{ dataSource: data.dataSource, symbol: data.symbol }
]);
await this.cacheService.flush(); await this.cacheService.flush();

View File

@ -1,12 +1,12 @@
import { Currency, DataSource, Type } from '@prisma/client'; import { DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class UpdateOrderDto { export class UpdateOrderDto {
@IsString() @IsString()
accountId: string; accountId: string;
@IsString() @IsString()
currency: Currency; currency: string;
@IsString() @IsString()
dataSource: DataSource; dataSource: DataSource;

View File

@ -1,6 +1,6 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Currency, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service'; import { MarketDataService } from './market-data.service';
@ -14,6 +14,7 @@ jest.mock('./market-data.service', () => {
date, date,
symbol, symbol,
createdAt: date, createdAt: date,
dataSource: DataSource.YAHOO,
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584', id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
marketPrice: 1847.839966 marketPrice: 1847.839966
}); });
@ -30,6 +31,7 @@ jest.mock('./market-data.service', () => {
return Promise.resolve<MarketData[]>([ return Promise.resolve<MarketData[]>([
{ {
createdAt: dateRangeStart, createdAt: dateRangeStart,
dataSource: DataSource.YAHOO,
date: dateRangeStart, date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902, marketPrice: 1841.823902,
@ -37,6 +39,7 @@ jest.mock('./market-data.service', () => {
}, },
{ {
createdAt: dateRangeEnd, createdAt: dateRangeEnd,
dataSource: DataSource.YAHOO,
date: dateRangeEnd, date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56', id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966, marketPrice: 1847.839966,
@ -69,15 +72,8 @@ describe('CurrentRateService', () => {
let marketDataService: MarketDataService; let marketDataService: MarketDataService;
beforeAll(async () => { beforeAll(async () => {
dataProviderService = new DataProviderService( dataProviderService = new DataProviderService(null, [], null);
null, exchangeRateDataService = new ExchangeRateDataService(null, null);
null,
null,
null,
null,
null
);
exchangeRateDataService = new ExchangeRateDataService(null);
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize(); await exchangeRateDataService.initialize();
@ -92,10 +88,10 @@ describe('CurrentRateService', () => {
it('getValue', async () => { it('getValue', async () => {
expect( expect(
await currentRateService.getValue({ await currentRateService.getValue({
currency: Currency.USD, currency: 'USD',
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)), date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
symbol: 'AMZN', symbol: 'AMZN',
userCurrency: Currency.CHF userCurrency: 'CHF'
}) })
).toMatchObject({ ).toMatchObject({
marketPrice: 1847.839966 marketPrice: 1847.839966
@ -105,13 +101,13 @@ describe('CurrentRateService', () => {
it('getValues', async () => { it('getValues', async () => {
expect( expect(
await currentRateService.getValues({ await currentRateService.getValues({
currencies: { AMZN: Currency.USD }, currencies: { AMZN: 'USD' },
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
dateQuery: { dateQuery: {
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)), lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
}, },
symbols: ['AMZN'], userCurrency: 'CHF'
userCurrency: Currency.CHF
}) })
).toMatchObject([ ).toMatchObject([
{ {

View File

@ -25,11 +25,16 @@ export class CurrentRateService {
userCurrency userCurrency
}: GetValueParams): Promise<GetValueObject> { }: GetValueParams): Promise<GetValueObject> {
if (isToday(date)) { if (isToday(date)) {
const dataProviderResult = await this.dataProviderService.get([symbol]); const dataProviderResult = await this.dataProviderService.get([
{
symbol,
dataSource: this.dataProviderService.getPrimaryDataSource()
}
]);
return { return {
symbol,
date: resetHours(date), date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0, marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
symbol: symbol
}; };
} }
@ -55,8 +60,8 @@ export class CurrentRateService {
public async getValues({ public async getValues({
currencies, currencies,
dataGatheringItems,
dateQuery, dateQuery,
symbols,
userCurrency userCurrency
}: GetValuesParams): Promise<GetValueObject[]> { }: GetValuesParams): Promise<GetValueObject[]> {
const includeToday = const includeToday =
@ -75,24 +80,31 @@ export class CurrentRateService {
if (includeToday) { if (includeToday) {
const today = resetHours(new Date()); const today = resetHours(new Date());
promises.push( promises.push(
this.dataProviderService.get(symbols).then((dataResultProvider) => { this.dataProviderService
const result = []; .get(dataGatheringItems)
for (const symbol of symbols) { .then((dataResultProvider) => {
result.push({ const result = [];
symbol, for (const dataGatheringItem of dataGatheringItems) {
date: today, result.push({
marketPrice: this.exchangeRateDataService.toCurrency( date: today,
dataResultProvider?.[symbol]?.marketPrice ?? 0, marketPrice: this.exchangeRateDataService.toCurrency(
dataResultProvider?.[symbol]?.currency, dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
userCurrency 0,
) dataResultProvider?.[dataGatheringItem.symbol]?.currency,
}); userCurrency
} ),
return result; symbol: dataGatheringItem.symbol
}) });
}
return result;
})
); );
} }
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
promises.push( promises.push(
this.marketDataService this.marketDataService
.getRange({ .getRange({

View File

@ -6,6 +6,9 @@ export interface CurrentPositions {
positions: TimelinePosition[]; positions: TimelinePosition[];
grossPerformance: Big; grossPerformance: Big;
grossPerformancePercentage: Big; grossPerformancePercentage: Big;
netAnnualizedPerformance: Big;
netPerformance: Big;
netPerformancePercentage: Big;
currentValue: Big; currentValue: Big;
totalInvestment: Big; totalInvestment: Big;
} }

View File

@ -1,8 +1,6 @@
import { Currency } from '@prisma/client';
export interface GetValueParams { export interface GetValueParams {
currency: Currency; currency: string;
date: Date; date: Date;
symbol: string; symbol: string;
userCurrency: Currency; userCurrency: string;
} }

View File

@ -1,10 +1,10 @@
import { Currency } from '@prisma/client'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DateQuery } from './date-query.interface'; import { DateQuery } from './date-query.interface';
export interface GetValuesParams { export interface GetValuesParams {
currencies: { [symbol: string]: Currency }; currencies: { [symbol: string]: string };
dataGatheringItems: IDataGatheringItem[];
dateQuery: DateQuery; dateQuery: DateQuery;
symbols: string[]; userCurrency: string;
userCurrency: Currency;
} }

View File

@ -1,10 +1,12 @@
import { OrderType } from '@ghostfolio/api/models/order-type'; import { OrderType } from '@ghostfolio/api/models/order-type';
import { Currency } from '@prisma/client'; import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
export interface PortfolioOrder { export interface PortfolioOrder {
currency: Currency; currency: string;
date: string; date: string;
dataSource: DataSource;
fee: Big;
name: string; name: string;
quantity: Big; quantity: Big;
symbol: string; symbol: string;

View File

@ -1,8 +1,6 @@
import { Currency } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
averagePrice: number; averagePrice: number;
currency: Currency; currency: string;
firstBuyDate: string; firstBuyDate: string;
grossPerformance: number; grossPerformance: number;
grossPerformancePercent: number; grossPerformancePercent: number;
@ -11,6 +9,9 @@ export interface PortfolioPositionDetail {
marketPrice: number; marketPrice: number;
maxPrice: number; maxPrice: number;
minPrice: number; minPrice: number;
name: string;
netPerformance: number;
netPerformancePercent: number;
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number; transactionCount: number;

View File

@ -4,5 +4,6 @@ export interface TimelinePeriod {
date: string; date: string;
grossPerformance: Big; grossPerformance: Big;
investment: Big; investment: Big;
netPerformance: Big;
value: Big; value: Big;
} }

View File

@ -1,8 +1,10 @@
import { Currency } from '@prisma/client'; import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
export interface TransactionPointSymbol { export interface TransactionPointSymbol {
currency: Currency; currency: string;
dataSource: DataSource;
fee: Big;
firstBuyDate: string; firstBuyDate: string;
investment: Big; investment: Big;
quantity: Big; quantity: Big;

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,14 @@
import { OrderType } from '@ghostfolio/api/models/order-type'; import { OrderType } from '@ghostfolio/api/models/order-type';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Logger } from '@nestjs/common';
import Big from 'big.js'; import Big from 'big.js';
import { import {
addDays, addDays,
addMonths, addMonths,
addYears, addYears,
differenceInDays,
endOfDay, endOfDay,
format, format,
isAfter, isAfter,
@ -14,7 +16,7 @@ import {
max, max,
min min
} from 'date-fns'; } from 'date-fns';
import { flatten } from 'lodash'; import { flatten, isNumber } from 'lodash';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface'; import { CurrentPositions } from './interfaces/current-positions.interface';
@ -33,7 +35,7 @@ export class PortfolioCalculator {
public constructor( public constructor(
private currentRateService: CurrentRateService, private currentRateService: CurrentRateService,
private currency: Currency private currency: string
) {} ) {}
public computeTransactionPoints(orders: PortfolioOrder[]) { public computeTransactionPoints(orders: PortfolioOrder[]) {
@ -58,6 +60,8 @@ export class PortfolioCalculator {
.plus(oldAccumulatedSymbol.quantity); .plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = { currentTransactionPointItem = {
currency: order.currency, currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0) investment: newQuantity.eq(0)
? new Big(0) ? new Big(0)
@ -72,6 +76,8 @@ export class PortfolioCalculator {
} else { } else {
currentTransactionPointItem = { currentTransactionPointItem = {
currency: order.currency, currency: order.currency,
dataSource: order.dataSource,
fee: order.fee,
firstBuyDate: order.date, firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor), investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor), quantity: order.quantity.mul(factor),
@ -101,6 +107,23 @@ export class PortfolioCalculator {
} }
} }
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[] { public getTransactionPoints(): TransactionPoint[] {
return this.transactionPoints; return this.transactionPoints;
} }
@ -112,11 +135,14 @@ export class PortfolioCalculator {
public async getCurrentPositions(start: Date): Promise<CurrentPositions> { public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) { if (!this.transactionPoints?.length) {
return { return {
currentValue: new Big(0),
hasErrors: false, hasErrors: false,
positions: [],
grossPerformance: new Big(0), grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
currentValue: new Big(0), netAnnualizedPerformance: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
totalInvestment: new Big(0) totalInvestment: new Big(0)
}; };
} }
@ -130,12 +156,15 @@ export class PortfolioCalculator {
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length; let firstIndex = this.transactionPoints.length;
const dates = []; const dates = [];
const symbols = new Set<string>(); const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: Currency } = {}; const currencies: { [symbol: string]: string } = {};
dates.push(resetHours(start)); dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) { for (const item of this.transactionPoints[firstIndex - 1].items) {
symbols.add(item.symbol); dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency; currencies[item.symbol] = item.currency;
} }
for (let i = 0; i < this.transactionPoints.length; i++) { for (let i = 0; i < this.transactionPoints.length; i++) {
@ -155,10 +184,10 @@ export class PortfolioCalculator {
const marketSymbols = await this.currentRateService.getValues({ const marketSymbols = await this.currentRateService.getValues({
currencies, currencies,
dataGatheringItems,
dateQuery: { dateQuery: {
in: dates in: dates
}, },
symbols: Array.from(symbols),
userCurrency: this.currency userCurrency: this.currency
}); });
@ -181,7 +210,9 @@ export class PortfolioCalculator {
const startString = format(start, DATE_FORMAT); const startString = format(start, DATE_FORMAT);
const holdingPeriodReturns: { [symbol: string]: Big } = {}; const holdingPeriodReturns: { [symbol: string]: Big } = {};
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
const grossPerformance: { [symbol: string]: Big } = {}; const grossPerformance: { [symbol: string]: Big } = {};
const netPerformance: { [symbol: string]: Big } = {};
const todayString = format(today, DATE_FORMAT); const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) { if (firstIndex > 0) {
@ -190,6 +221,7 @@ export class PortfolioCalculator {
const invalidSymbols = []; const invalidSymbols = [];
const lastInvestments: { [symbol: string]: Big } = {}; const lastInvestments: { [symbol: string]: Big } = {};
const lastQuantities: { [symbol: string]: Big } = {}; const lastQuantities: { [symbol: string]: Big } = {};
const lastFees: { [symbol: string]: Big } = {};
const initialValues: { [symbol: string]: Big } = {}; const initialValues: { [symbol: string]: Big } = {};
for (let i = firstIndex; i < this.transactionPoints.length; i++) { for (let i = firstIndex; i < this.transactionPoints.length; i++) {
@ -202,14 +234,10 @@ export class PortfolioCalculator {
const items = this.transactionPoints[i].items; const items = this.transactionPoints[i].items;
for (const item of items) { for (const item of items) {
let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol];
if (!oldHoldingPeriodReturn) {
oldHoldingPeriodReturn = new Big(1);
}
if (!marketSymbolMap[nextDate]?.[item.symbol]) { if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol); invalidSymbols.push(item.symbol);
hasErrors = true; hasErrors = true;
console.error( Logger.error(
`Missing value for symbol ${item.symbol} at ${nextDate}` `Missing value for symbol ${item.symbol} at ${nextDate}`
); );
continue; continue;
@ -224,6 +252,13 @@ export class PortfolioCalculator {
const itemValue = marketSymbolMap[currentDate]?.[item.symbol]; const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
let initialValue = itemValue?.mul(lastQuantity); let initialValue = itemValue?.mul(lastQuantity);
let investedValue = itemValue?.mul(item.quantity); let investedValue = itemValue?.mul(item.quantity);
const isFirstOrderAndIsStartBeforeCurrentDate =
i === firstIndex &&
isBefore(parseDate(this.transactionPoints[i].date), start);
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
const fee = isFirstOrderAndIsStartBeforeCurrentDate
? new Big(0)
: item.fee.minus(lastFee);
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) { if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
initialValue = item.investment; initialValue = item.investment;
investedValue = item.investment; investedValue = item.investment;
@ -235,7 +270,7 @@ export class PortfolioCalculator {
if (!initialValue) { if (!initialValue) {
invalidSymbols.push(item.symbol); invalidSymbols.push(item.symbol);
hasErrors = true; hasErrors = true;
console.error( Logger.error(
`Missing value for symbol ${item.symbol} at ${currentDate}` `Missing value for symbol ${item.symbol} at ${currentDate}`
); );
continue; continue;
@ -247,18 +282,26 @@ export class PortfolioCalculator {
); );
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow)); const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] = holdingPeriodReturns[item.symbol] = (
oldHoldingPeriodReturn.mul(holdingPeriodReturn); holdingPeriodReturns[item.symbol] ?? new Big(1)
let oldGrossPerformance = grossPerformance[item.symbol]; ).mul(holdingPeriodReturn);
if (!oldGrossPerformance) { grossPerformance[item.symbol] = (
oldGrossPerformance = new Big(0); grossPerformance[item.symbol] ?? new Big(0)
} ).plus(endValue.minus(investedValue));
const currentPerformance = endValue.minus(investedValue);
grossPerformance[item.symbol] = const netHoldingPeriodReturn = endValue.div(
oldGrossPerformance.plus(currentPerformance); initialValue.plus(cashFlow).plus(fee)
);
netHoldingPeriodReturns[item.symbol] = (
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
).mul(netHoldingPeriodReturn);
netPerformance[item.symbol] = (
netPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue).minus(fee));
} }
lastInvestments[item.symbol] = item.investment; lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity; lastQuantities[item.symbol] = item.quantity;
lastFees[item.symbol] = item.fee;
} }
} }
@ -272,6 +315,7 @@ export class PortfolioCalculator {
? new Big(0) ? new Big(0)
: item.investment.div(item.quantity), : item.investment.div(item.quantity),
currency: item.currency, currency: item.currency,
dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate, firstBuyDate: item.firstBuyDate,
grossPerformance: isValid grossPerformance: isValid
? grossPerformance[item.symbol] ?? null ? grossPerformance[item.symbol] ?? null
@ -282,15 +326,17 @@ export class PortfolioCalculator {
: null, : null,
investment: item.investment, investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null, marketPrice: marketValue?.toNumber() ?? null,
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
netPerformancePercentage:
isValid && netHoldingPeriodReturns[item.symbol]
? netHoldingPeriodReturns[item.symbol].minus(1)
: null,
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
transactionCount: item.transactionCount transactionCount: item.transactionCount
}); });
} }
const overall = this.calculateOverallGrossPerformance( const overall = this.calculateOverallPerformance(positions, initialValues);
positions,
initialValues
);
return { return {
...overall, ...overall,
@ -378,7 +424,7 @@ export class PortfolioCalculator {
return flatten(timelinePeriods); return flatten(timelinePeriods);
} }
private calculateOverallGrossPerformance( private calculateOverallPerformance(
positions: TimelinePosition[], positions: TimelinePosition[],
initialValues: { [p: string]: Big } initialValues: { [p: string]: Big }
) { ) {
@ -387,7 +433,14 @@ export class PortfolioCalculator {
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0); let grossPerformancePercentage = new Big(0);
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0); let completeInitialValue = new Big(0);
let netAnnualizedPerformance = new Big(0);
// use Date.now() to use the mock for today
const today = new Date(Date.now());
for (const currentPosition of positions) { for (const currentPosition of positions) {
if (currentPosition.marketPrice) { if (currentPosition.marketPrice) {
currentValue = currentValue.add( currentValue = currentValue.add(
@ -401,6 +454,7 @@ export class PortfolioCalculator {
grossPerformance = grossPerformance.plus( grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance currentPosition.grossPerformance
); );
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
hasErrors = true; hasErrors = true;
} }
@ -414,8 +468,20 @@ export class PortfolioCalculator {
grossPerformancePercentage = grossPerformancePercentage.plus( grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue) currentPosition.grossPerformancePercentage.mul(currentInitialValue)
); );
netAnnualizedPerformance = netAnnualizedPerformance.plus(
this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
today,
parseDate(currentPosition.firstBuyDate)
),
netPerformancePercent: currentPosition.netPerformancePercentage
}).mul(currentInitialValue)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue)
);
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
console.error( Logger.error(
`Initial value is missing for symbol ${currentPosition.symbol}` `Initial value is missing for symbol ${currentPosition.symbol}`
); );
hasErrors = true; hasErrors = true;
@ -425,6 +491,10 @@ export class PortfolioCalculator {
if (!completeInitialValue.eq(0)) { if (!completeInitialValue.eq(0)) {
grossPerformancePercentage = grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue); grossPerformancePercentage.div(completeInitialValue);
netPerformancePercentage =
netPerformancePercentage.div(completeInitialValue);
netAnnualizedPerformance =
netAnnualizedPerformance.div(completeInitialValue);
} }
return { return {
@ -432,6 +502,9 @@ export class PortfolioCalculator {
grossPerformance, grossPerformance,
grossPerformancePercentage, grossPerformancePercentage,
hasErrors, hasErrors,
netAnnualizedPerformance,
netPerformance,
netPerformancePercentage,
totalInvestment totalInvestment
}; };
} }
@ -442,34 +515,39 @@ export class PortfolioCalculator {
endDate: Date endDate: Date
): Promise<TimelinePeriod[]> { ): Promise<TimelinePeriod[]> {
let investment: Big = new Big(0); let investment: Big = new Big(0);
let fees: Big = new Big(0);
const marketSymbolMap: { const marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
} = {}; } = {};
if (j >= 0) { if (j >= 0) {
const currencies: { [name: string]: Currency } = {}; const currencies: { [name: string]: string } = {};
const symbols: string[] = []; const dataGatheringItems: IDataGatheringItem[] = [];
for (const item of this.transactionPoints[j].items) { for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency; currencies[item.symbol] = item.currency;
symbols.push(item.symbol); dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.add(item.investment); investment = investment.add(item.investment);
fees = fees.add(item.fee);
} }
let marketSymbols: GetValueObject[] = []; let marketSymbols: GetValueObject[] = [];
if (symbols.length > 0) { if (dataGatheringItems.length > 0) {
try { try {
marketSymbols = await this.currentRateService.getValues({ marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: { dateQuery: {
gte: startDate, gte: startDate,
lt: endOfDay(endDate) lt: endOfDay(endDate)
}, },
symbols,
currencies,
userCurrency: this.currency userCurrency: this.currency
}); });
} catch (error) { } catch (error) {
console.error( Logger.error(
`Failed to fetch info for date ${startDate} with exception`, `Failed to fetch info for date ${startDate} with exception`,
error error
); );
@ -490,7 +568,7 @@ export class PortfolioCalculator {
} }
} }
const results = []; const results: TimelinePeriod[] = [];
for ( for (
let currentDate = startDate; let currentDate = startDate;
isBefore(currentDate, endDate); isBefore(currentDate, endDate);
@ -513,11 +591,13 @@ export class PortfolioCalculator {
} }
} }
if (!invalid) { if (!invalid) {
const grossPerformance = value.minus(investment);
const result = { const result = {
date: currentDateAsString, grossPerformance,
grossPerformance: value.minus(investment),
investment, investment,
value value,
date: currentDateAsString,
netPerformance: grossPerformance.minus(fees)
}; };
results.push(result); results.push(result);
} }

View File

@ -1,17 +1,21 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPerformance, PortfolioPerformance,
PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -38,6 +42,8 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
public constructor( public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
@ -47,8 +53,17 @@ export class PortfolioController {
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async findAll( public async findAll(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId,
@Res() res: Response
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json([]);
}
let investments = await this.portfolioService.getInvestments( let investments = await this.portfolioService.getInvestments(
impersonationId impersonationId
); );
@ -68,7 +83,7 @@ export class PortfolioController {
})); }));
} }
return investments; return <any>res.json(investments);
} }
@Get('chart') @Get('chart')
@ -125,8 +140,20 @@ export class PortfolioController {
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioDetails> { ): Promise<PortfolioDetails> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({ accounts: {}, holdings: {} });
}
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(impersonationId, range); await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
range
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
res.status(StatusCodes.ACCEPTED); res.status(StatusCodes.ACCEPTED);
@ -156,8 +183,9 @@ export class PortfolioController {
portfolioPosition.grossPerformance = null; portfolioPosition.grossPerformance = null;
portfolioPosition.investment = portfolioPosition.investment =
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null; portfolioPosition.quantity = null;
portfolioPosition.value = portfolioPosition.value / totalValue;
} }
for (const [name, { current, original }] of Object.entries(accounts)) { for (const [name, { current, original }] of Object.entries(accounts)) {
@ -223,6 +251,7 @@ export class PortfolioController {
return nullifyValuesInObject(position, [ return nullifyValuesInObject(position, [
'grossPerformance', 'grossPerformance',
'investment', 'investment',
'netPerformance',
'quantity' 'quantity'
]); ]);
}); });
@ -231,6 +260,65 @@ export class PortfolioController {
return <any>res.json(result); return <any>res.json(result);
} }
@Get('public/:accessId')
public async getPublic(
@Param('accessId') accessId,
@Res() res: Response
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
id: access.userId
});
if (!access) {
res.status(StatusCodes.NOT_FOUND);
return <any>res.json({ accounts: {}, holdings: {} });
}
let hasDetails = true;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioService.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,
name: portfolioPosition.name,
sectors: hasDetails ? portfolioPosition.sectors : [],
value: portfolioPosition.value / totalValue
};
}
}
return <any>res.json(portfolioPublicDetails);
}
@Get('summary') @Get('summary')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getSummary( public async getSummary(
@ -246,6 +334,7 @@ export class PortfolioController {
'cash', 'cash',
'committedFunds', 'committedFunds',
'currentGrossPerformance', 'currentGrossPerformance',
'currentNetPerformance',
'currentValue', 'currentValue',
'fees', 'fees',
'netWorth', 'netWorth',
@ -276,6 +365,7 @@ export class PortfolioController {
position = nullifyValuesInObject(position, [ position = nullifyValuesInObject(position, [
'grossPerformance', 'grossPerformance',
'investment', 'investment',
'netPerformance',
'quantity' 'quantity'
]); ]);
} }
@ -292,8 +382,19 @@ export class PortfolioController {
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getReport( public async getReport(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId,
@Res() res: Response
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {
return await this.portfolioService.getReport(impersonationId); if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({ rules: [] });
}
return <any>(
res.json(await this.portfolioService.getReport(impersonationId))
);
} }
} }

View File

@ -1,3 +1,4 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
@ -7,7 +8,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
@ -18,6 +19,7 @@ import { RulesService } from './rules.service';
@Module({ @Module({
imports: [ imports: [
AccessModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
@ -25,6 +27,7 @@ import { RulesService } from './rules.service';
ImpersonationModule, ImpersonationModule,
OrderModule, OrderModule,
PrismaModule, PrismaModule,
SymbolProfileModule,
UserModule UserModule
], ],
controllers: [PortfolioController], controllers: [PortfolioController],
@ -33,8 +36,7 @@ import { RulesService } from './rules.service';
CurrentRateService, CurrentRateService,
MarketDataService, MarketDataService,
PortfolioService, PortfolioService,
RulesService, RulesService
SymbolProfileService
] ]
}) })
export class PortfolioModule {} export class PortfolioModule {}

View File

@ -1,3 +1,5 @@
// TODO ///////////
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
@ -21,31 +23,29 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config'; import {
UNKNOWN_KEY,
baseCurrency,
ghostfolioCashSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition,
PortfolioReport, PortfolioReport,
PortfolioSummary, PortfolioSummary,
Position, Position,
TimelinePosition TimelinePosition
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { import type {
DateRange, DateRange,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
AssetClass,
Currency,
DataSource,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
endOfToday, endOfToday,
@ -84,7 +84,7 @@ export class PortfolioService {
public async getInvestments( public async getInvestments(
aImpersonationId: string aImpersonationId: string
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
this.currentRateService, this.currentRateService,
@ -112,7 +112,7 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> { ): Promise<HistoricalDataItem[]> {
const userId = await this.getUserId(aImpersonationId); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
this.currentRateService, this.currentRateService,
@ -148,17 +148,18 @@ export class PortfolioService {
.map((timelineItem) => ({ .map((timelineItem) => ({
date: timelineItem.date, date: timelineItem.date,
marketPrice: timelineItem.value, marketPrice: timelineItem.value,
value: timelineItem.grossPerformance.toNumber() value: timelineItem.netPerformance.toNumber()
})); }));
} }
public async getDetails( public async getDetails(
aImpersonationId: string, aImpersonationId: string,
aUserId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<PortfolioDetails & { hasErrors: boolean }> { ): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId); const userId = await this.getUserId(aImpersonationId, aUserId);
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
this.currentRateService, this.currentRateService,
userCurrency userCurrency
@ -191,12 +192,18 @@ export class PortfolioService {
); );
const totalValue = currentPositions.currentValue.plus(cashDetails.balance); const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const dataGatheringItems = currentPositions.positions.map((position) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
};
});
const symbols = currentPositions.positions.map( const symbols = currentPositions.positions.map(
(position) => position.symbol (position) => position.symbol
); );
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(symbols), this.dataProviderService.get(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfiles(symbols)
]); ]);
@ -211,6 +218,11 @@ export class PortfolioService {
} }
for (const item of currentPositions.positions) { for (const item of currentPositions.positions) {
if (item.quantity.lte(0)) {
// Ignore positions without any quantity
continue;
}
const value = item.quantity.mul(item.marketPrice); const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[item.symbol]; const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol];
@ -218,6 +230,7 @@ export class PortfolioService {
allocationCurrent: value.div(totalValue).toNumber(), allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment.div(totalInvestment).toNumber(), allocationInvestment: item.investment.div(totalInvestment).toNumber(),
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries, countries: symbolProfile.countries,
currency: item.currency, currency: item.currency,
exchange: dataProviderResponse.exchange, exchange: dataProviderResponse.exchange,
@ -228,6 +241,8 @@ export class PortfolioService {
marketPrice: item.marketPrice, marketPrice: item.marketPrice,
marketState: dataProviderResponse.marketState, marketState: dataProviderResponse.marketState,
name: symbolProfile.name, name: symbolProfile.name,
netPerformance: item.netPerformance?.toNumber() ?? 0,
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
quantity: item.quantity.toNumber(), quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors, sectors: symbolProfile.sectors,
symbol: item.symbol, symbol: item.symbol,
@ -257,7 +272,7 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
const userId = await this.getUserId(aImpersonationId); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const orders = (await this.orderService.getOrders({ userId })).filter( const orders = (await this.orderService.getOrders({ userId })).filter(
(order) => order.symbol === aSymbol (order) => order.symbol === aSymbol
@ -275,6 +290,9 @@ export class PortfolioService {
marketPrice: undefined, marketPrice: undefined,
maxPrice: undefined, maxPrice: undefined,
minPrice: undefined, minPrice: undefined,
name: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
quantity: undefined, quantity: undefined,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined transactionCount: undefined
@ -282,10 +300,13 @@ export class PortfolioService {
} }
const positionCurrency = orders[0].currency; const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? '';
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency, currency: order.currency,
dataSource: order.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.symbol, symbol: order.symbol,
@ -313,13 +334,14 @@ export class PortfolioService {
const { const {
averagePrice, averagePrice,
currency, currency,
dataSource,
firstBuyDate, firstBuyDate,
marketPrice, marketPrice,
quantity, quantity,
transactionCount transactionCount
} = position; } = position;
// Convert investment and gross performance to currency of user // Convert investment, gross and net performance to currency of user
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency( const investment = this.exchangeRateDataService.toCurrency(
position.investment.toNumber(), position.investment.toNumber(),
@ -331,9 +353,14 @@ export class PortfolioService {
currency, currency,
userCurrency userCurrency
); );
const netPerformance = this.exchangeRateDataService.toCurrency(
position.netPerformance.toNumber(),
currency,
userCurrency
);
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
[aSymbol], [{ dataSource, symbol: aSymbol }],
'day', 'day',
parseISO(firstBuyDate), parseISO(firstBuyDate),
new Date() new Date()
@ -392,19 +419,24 @@ export class PortfolioService {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
name,
netPerformance,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(), grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
historicalData: historicalDataArray, historicalData: historicalDataArray,
netPerformancePercent: position.netPerformancePercentage.toNumber(),
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
symbol: aSymbol symbol: aSymbol
}; };
} else { } else {
const currentData = await this.dataProviderService.get([aSymbol]); const currentData = await this.dataProviderService.get([
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
]);
const marketPrice = currentData[aSymbol]?.marketPrice; const marketPrice = currentData[aSymbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical( let historicalData = await this.dataProviderService.getHistorical(
[aSymbol], [{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
'day', 'day',
portfolioStart, portfolioStart,
new Date() new Date()
@ -438,6 +470,7 @@ export class PortfolioService {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
name,
averagePrice: 0, averagePrice: 0,
currency: currentData[aSymbol]?.currency, currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined, firstBuyDate: undefined,
@ -445,6 +478,8 @@ export class PortfolioService {
grossPerformancePercent: undefined, grossPerformancePercent: undefined,
historicalData: historicalDataArray, historicalData: historicalDataArray,
investment: 0, investment: 0,
netPerformance: undefined,
netPerformancePercent: undefined,
quantity: 0, quantity: 0,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined transactionCount: undefined
@ -456,7 +491,7 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; positions: Position[] }> { ): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(aImpersonationId); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
this.currentRateService, this.currentRateService,
@ -483,10 +518,16 @@ export class PortfolioService {
const positions = currentPositions.positions.filter( const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0) (item) => !item.quantity.eq(0)
); );
const dataGatheringItem = positions.map((position) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
};
});
const symbols = positions.map((position) => position.symbol); const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(symbols), this.dataProviderService.get(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfiles(symbols)
]); ]);
@ -508,6 +549,9 @@ export class PortfolioService {
investment: new Big(position.investment).toNumber(), investment: new Big(position.investment).toNumber(),
marketState: dataProviderResponses[position.symbol].marketState, marketState: dataProviderResponses[position.symbol].marketState,
name: symbolProfileMap[position.symbol].name, name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage:
position.netPerformancePercentage?.toNumber() ?? null,
quantity: new Big(position.quantity).toNumber() quantity: new Big(position.quantity).toNumber()
}; };
}) })
@ -518,7 +562,7 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> { ): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const userId = await this.getUserId(aImpersonationId); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
this.currentRateService, this.currentRateService,
@ -531,8 +575,11 @@ export class PortfolioService {
return { return {
hasErrors: false, hasErrors: false,
performance: { performance: {
annualizedPerformancePercent: 0,
currentGrossPerformance: 0, currentGrossPerformance: 0,
currentGrossPerformancePercent: 0, currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0 currentValue: 0
} }
}; };
@ -547,16 +594,25 @@ export class PortfolioService {
); );
const hasErrors = currentPositions.hasErrors; const hasErrors = currentPositions.hasErrors;
const annualizedPerformancePercent =
currentPositions.netAnnualizedPerformance.toNumber();
const currentValue = currentPositions.currentValue.toNumber(); const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance = const currentGrossPerformance =
currentPositions.grossPerformance.toNumber(); currentPositions.grossPerformance.toNumber();
const currentGrossPerformancePercent = const currentGrossPerformancePercent =
currentPositions.grossPerformancePercentage.toNumber(); currentPositions.grossPerformancePercentage.toNumber();
const currentNetPerformance = currentPositions.netPerformance.toNumber();
const currentNetPerformancePercent =
currentPositions.netPerformancePercentage.toNumber();
return { return {
hasErrors: currentPositions.hasErrors || hasErrors, hasErrors: currentPositions.hasErrors || hasErrors,
performance: { performance: {
annualizedPerformancePercent,
currentGrossPerformance, currentGrossPerformance,
currentGrossPerformancePercent, currentGrossPerformancePercent,
currentNetPerformance,
currentNetPerformancePercent,
currentValue: currentValue currentValue: currentValue
} }
}; };
@ -579,8 +635,8 @@ export class PortfolioService {
} }
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(impersonationId: string): Promise<PortfolioReport> {
const userId = await this.getUserId(impersonationId); const currency = this.request.user.Settings.currency;
const baseCurrency = this.request.user.Settings.currency; const userId = await this.getUserId(impersonationId, this.request.user.id);
const { orders, transactionPoints } = await this.getTransactionPoints({ const { orders, transactionPoints } = await this.getTransactionPoints({
userId userId
@ -594,7 +650,7 @@ export class PortfolioService {
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
this.currentRateService, this.currentRateService,
this.request.user.Settings.currency currency
); );
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
@ -610,7 +666,7 @@ export class PortfolioService {
const accounts = await this.getAccounts( const accounts = await this.getAccounts(
orders, orders,
portfolioItemsNow, portfolioItemsNow,
baseCurrency, currency,
userId userId
); );
return { return {
@ -630,7 +686,7 @@ export class PortfolioService {
accounts accounts
) )
], ],
{ baseCurrency } { baseCurrency: currency }
), ),
currencyClusterRisk: await this.rulesService.evaluate( currencyClusterRisk: await this.rulesService.evaluate(
[ [
@ -651,7 +707,7 @@ export class PortfolioService {
currentPositions currentPositions
) )
], ],
{ baseCurrency } { baseCurrency: currency }
), ),
fees: await this.rulesService.evaluate( fees: await this.rulesService.evaluate(
[ [
@ -661,7 +717,7 @@ export class PortfolioService {
this.getFees(orders) this.getFees(orders)
) )
], ],
{ baseCurrency } { baseCurrency: currency }
) )
} }
}; };
@ -669,7 +725,7 @@ export class PortfolioService {
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> { public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const currency = this.request.user.Settings.currency; const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const performanceInformation = await this.getPerformance(aImpersonationId); const performanceInformation = await this.getPerformance(aImpersonationId);
@ -718,14 +774,17 @@ export class PortfolioService {
allocationCurrent: cashValue.div(value).toNumber(), allocationCurrent: cashValue.div(value).toNumber(),
allocationInvestment: cashValue.div(investment).toNumber(), allocationInvestment: cashValue.div(investment).toNumber(),
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
countries: [], countries: [],
currency: Currency.CHF, currency: 'CHF',
grossPerformance: 0, grossPerformance: 0,
grossPerformancePercent: 0, grossPerformancePercent: 0,
investment: cashValue.toNumber(), investment: cashValue.toNumber(),
marketPrice: 0, marketPrice: 0,
marketState: MarketState.open, marketState: MarketState.open,
name: 'Cash', name: 'Cash',
netPerformance: 0,
netPerformancePercent: 0,
quantity: 0, quantity: 0,
sectors: [], sectors: [],
symbol: ghostfolioCashSymbol, symbol: ghostfolioCashSymbol,
@ -768,10 +827,18 @@ export class PortfolioService {
return { transactionPoints: [], orders: [] }; return { transactionPoints: [], orders: [] };
} }
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency, currency: order.currency,
dataSource: order.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
userCurrency
)
),
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.symbol, symbol: order.symbol,
@ -799,7 +866,7 @@ export class PortfolioService {
private async getAccounts( private async getAccounts(
orders: OrderWithAccount[], orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition }, portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency: Currency, userCurrency: string,
userId: string userId: string
) { ) {
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
@ -860,19 +927,19 @@ export class PortfolioService {
return accounts; return accounts;
} }
private async getUserId(aImpersonationId: string) { private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(
aImpersonationId, aImpersonationId,
this.request.user.id aUserId
); );
return impersonationUserId || this.request.user.id; return impersonationUserId || aUserId;
} }
private getTotalByType( private getTotalByType(
orders: OrderWithAccount[], orders: OrderWithAccount[],
currency: Currency, currency: string,
type: TypeOfOrder type: TypeOfOrder
) { ) {
return orders return orders

View File

@ -1,7 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
@Injectable() @Injectable()
export class RulesService { export class RulesService {
@ -9,7 +8,7 @@ export class RulesService {
public async evaluate<T extends RuleSettings>( public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[], aRules: Rule<T>[],
aUserSettings: { baseCurrency: Currency } aUserSettings: { baseCurrency: string }
) { ) {
return aRules return aRules
.filter((rule) => { .filter((rule) => {

View File

@ -21,9 +21,9 @@ export class RedisCacheService {
await this.cache.reset(); 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, { await this.cache.set(key, value, {
ttl: this.configurationService.get('CACHE_TTL') ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
}); });
} }
} }

View File

@ -1,11 +1,12 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Logger,
Post, Post,
Req, Req,
Res, Res,
@ -46,7 +47,7 @@ export class SubscriptionController {
userId: this.request.user.id userId: this.request.user.id
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),

View File

@ -8,6 +8,7 @@ import { SubscriptionService } from './subscription.service';
@Module({ @Module({
imports: [], imports: [],
controllers: [SubscriptionController], controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService] providers: [ConfigurationService, PrismaService, SubscriptionService],
exports: [SubscriptionService]
}) })
export class SubscriptionModule {} export class SubscriptionModule {}

View File

@ -1,7 +1,9 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { addDays } from 'date-fns'; import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
import { addDays, isBefore } from 'date-fns';
import Stripe from 'stripe'; import Stripe from 'stripe';
@Injectable() @Injectable()
@ -83,7 +85,26 @@ export class SubscriptionService {
description: session.client_reference_id description: session.client_reference_id
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
}
}
public getSubscription(aSubscriptions: Subscription[]) {
if (aSubscriptions.length > 0) {
const latestSubscription = aSubscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
});
return {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
return {
type: SubscriptionType.Basic
};
} }
} }
} }

View File

@ -1,7 +1,7 @@
import { Currency, DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface LookupItem { export interface LookupItem {
currency: Currency; currency: string;
dataSource: DataSource; dataSource: DataSource;
name: string; name: string;
symbol: string; symbol: string;

View File

@ -1,7 +1,7 @@
import { Currency, DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem {
currency: Currency; currency: string;
dataSource: DataSource; dataSource: DataSource;
marketPrice: number; marketPrice: number;
} }

View File

@ -1,4 +1,4 @@
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -10,7 +10,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -45,9 +47,28 @@ export class SymbolController {
/** /**
* Must be after /lookup * Must be after /lookup
*/ */
@Get(':symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> { public async getSymbolData(
return this.symbolService.get(symbol); @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<SymbolItem> {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const result = await this.symbolService.get({ dataSource, symbol });
if (!result || isEmpty(result)) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return result;
} }
} }

View File

@ -1,7 +1,8 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Currency, DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -13,15 +14,19 @@ export class SymbolService {
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async get(aSymbol: string): Promise<SymbolItem> { public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> {
const response = await this.dataProviderService.get([aSymbol]); const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, dataSource, marketPrice } = response[aSymbol]; const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
return { if (dataGatheringItem.dataSource && marketPrice) {
dataSource, return {
marketPrice, currency,
currency: <Currency>(<unknown>currency) marketPrice,
}; dataSource: dataGatheringItem.dataSource
};
}
return undefined;
} }
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> { public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
@ -62,7 +67,7 @@ export class SymbolService {
return results; return results;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw error; throw error;
} }

View File

@ -1,7 +1,7 @@
import { Currency, ViewMode } from '@prisma/client'; import { ViewMode } from '@prisma/client';
export interface UserSettingsParams { export interface UserSettingsParams {
currency?: Currency; currency?: string;
userId: string; userId: string;
viewMode?: ViewMode; viewMode?: ViewMode;
} }

View File

@ -1,9 +1,9 @@
import { Currency, ViewMode } from '@prisma/client'; import { ViewMode } from '@prisma/client';
import { IsString } from 'class-validator'; import { IsString } from 'class-validator';
export class UpdateUserSettingsDto { export class UpdateUserSettingsDto {
@IsString() @IsString()
baseCurrency: Currency; baseCurrency: string;
@IsString() @IsString()
viewMode: ViewMode; viewMode: ViewMode;

View File

@ -4,7 +4,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -1,3 +1,4 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -11,7 +12,8 @@ import { UserService } from './user.service';
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}) }),
SubscriptionModule
], ],
controllers: [UserController], controllers: [UserController],
providers: [ConfigurationService, PrismaService, UserService], providers: [ConfigurationService, PrismaService, UserService],

View File

@ -1,12 +1,12 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { locale } from '@ghostfolio/common/config'; import { baseCurrency, locale } from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions'; import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client'; import { Prisma, Provider, User, ViewMode } from '@prisma/client';
import { isBefore } from 'date-fns';
import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface'; import { UserSettings } from './interfaces/user-settings.interface';
@ -15,11 +15,12 @@ const crypto = require('crypto');
@Injectable() @Injectable()
export class UserService { export class UserService {
public static DEFAULT_CURRENCY = Currency.USD; public static DEFAULT_CURRENCY = 'USD';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {} ) {}
public async getUser({ public async getUser({
@ -98,24 +99,9 @@ export class UserService {
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (userFromDatabase?.Subscription?.length > 0) { user.subscription = this.subscriptionService.getSubscription(
const latestSubscription = userFromDatabase.Subscription.reduce( userFromDatabase?.Subscription
(a, b) => { );
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}
);
user.subscription = {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
user.subscription = {
type: SubscriptionType.Basic
};
}
if (user.subscription.type === SubscriptionType.Basic) { if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => { user.permissions = user.permissions.filter((permission) => {
@ -158,9 +144,15 @@ export class UserService {
...data, ...data,
Account: { Account: {
create: { create: {
currency: baseCurrency,
isDefault: true, isDefault: true,
name: 'Default Account' name: 'Default Account'
} }
},
Settings: {
create: {
currency: baseCurrency
}
} }
} }
}); });

View File

@ -1,5 +1,3 @@
import { Currency } from '@prisma/client';
export interface UserSettings { export interface UserSettings {
baseCurrency: Currency; baseCurrency: string;
} }

View File

@ -1,4 +1,4 @@
import { Account, Currency, SymbolProfile } from '@prisma/client'; import { Account, SymbolProfile } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
@ -6,7 +6,7 @@ import { OrderType } from './order-type';
export class Order { export class Order {
private account: Account; private account: Account;
private currency: Currency; private currency: string;
private fee: number; private fee: number;
private date: string; private date: string;
private id: string; private id: string;

View File

@ -3,7 +3,6 @@ import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.in
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface'; import { RuleInterface } from './interfaces/rule.interface';
@ -29,7 +28,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
public groupCurrentPositionsByAttribute( public groupCurrentPositionsByAttribute(
positions: TimelinePosition[], positions: TimelinePosition[],
attribute: keyof TimelinePosition, attribute: keyof TimelinePosition,
baseCurrency: Currency baseCurrency: string
) { ) {
return Array.from(groupBy(attribute, positions).entries()).map( return Array.from(groupBy(attribute, positions).entries()).map(
([attributeValue, objs]) => ({ ([attributeValue, objs]) => ({

View File

@ -2,8 +2,6 @@ import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/curre
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
@ -69,5 +67,5 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: Currency; baseCurrency: string;
} }

View File

@ -1,7 +1,6 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
@ -69,5 +68,5 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: Currency; baseCurrency: string;
} }

View File

@ -1,7 +1,6 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
@ -69,6 +68,6 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: Currency; baseCurrency: string;
threshold: number; threshold: number;
} }

View File

@ -1,7 +1,6 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
@ -69,6 +68,6 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: Currency; baseCurrency: string;
threshold: number; threshold: number;
} }

View File

@ -1,6 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
@ -46,6 +45,6 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: Currency; baseCurrency: string;
threshold: number; threshold: number;
} }

View File

@ -18,7 +18,11 @@ export class CronService {
@Cron(CronExpression.EVERY_12_HOURS) @Cron(CronExpression.EVERY_12_HOURS)
public async runEveryTwelveHours() { public async runEveryTwelveHours() {
await this.dataGatheringService.gatherProfileData();
await this.exchangeRateDataService.loadCurrencies(); await this.exchangeRateDataService.loadCurrencies();
} }
@Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() {
await this.dataGatheringService.gatherProfileData();
}
} }

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CryptocurrencyService } from './cryptocurrency.service';
@Module({
providers: [CryptocurrencyService],
exports: [CryptocurrencyService]
})
export class CryptocurrencyModule {}

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
const cryptocurrencies = require('cryptocurrencies');
const customCryptocurrencies = require('./custom-cryptocurrencies.json');
@Injectable()
export class CryptocurrencyService {
private combinedCryptocurrencies: string[];
public constructor() {}
public isCrypto(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
}
private getCryptocurrencies() {
if (!this.combinedCryptocurrencies) {
this.combinedCryptocurrencies = [
...cryptocurrencies.symbols(),
...Object.keys(customCryptocurrencies)
];
}
return this.combinedCryptocurrencies;
}
}

View File

@ -0,0 +1,6 @@
{
"1INCH": "1inch",
"AVAX": "Avalanche",
"MATIC": "Polygon",
"SHIB": "Shiba Inu"
}

View File

@ -1,12 +1,23 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { SymbolProfileModule } from './symbol-profile.module';
@Module({ @Module({
imports: [ConfigurationModule, DataProviderModule, PrismaModule], imports: [
ConfigurationModule,
DataEnhancerModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
SymbolProfileModule
],
providers: [DataGatheringService], providers: [DataGatheringService],
exports: [DataGatheringService] exports: [DataEnhancerModule, DataGatheringService]
}) })
export class DataGatheringModule {} export class DataGatheringModule {}

View File

@ -1,15 +1,13 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
DATE_FORMAT, benchmarks,
getUtc, ghostfolioFearAndGreedIndexSymbol
isGhostfolioScraperApiSymbol, } from '@ghostfolio/common/config';
resetHours import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
} from '@ghostfolio/common/helper'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
endOfToday,
format, format,
getDate, getDate,
getMonth, getMonth,
@ -21,6 +19,8 @@ import {
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@ -28,17 +28,21 @@ import { PrismaService } from './prisma.service';
export class DataGatheringService { export class DataGatheringService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[],
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService, private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async gather7Days() { public async gather7Days() {
const isDataGatheringNeeded = await this.isDataGatheringNeeded(); const isDataGatheringNeeded = await this.isDataGatheringNeeded();
if (isDataGatheringNeeded) { if (isDataGatheringNeeded) {
console.log('7d data gathering has been started.'); Logger.log('7d data gathering has been started.');
console.time('7d-data-gathering'); console.time('data-gathering-7d');
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
@ -61,7 +65,7 @@ export class DataGatheringService {
where: { key: 'LAST_DATA_GATHERING' } where: { key: 'LAST_DATA_GATHERING' }
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
await this.prismaService.property.delete({ await this.prismaService.property.delete({
@ -70,8 +74,8 @@ export class DataGatheringService {
} }
}); });
console.log('7d data gathering has been completed.'); Logger.log('7d data gathering has been completed.');
console.timeEnd('7d-data-gathering'); console.timeEnd('data-gathering-7d');
} }
} }
@ -81,8 +85,8 @@ export class DataGatheringService {
}); });
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
console.log('Max data gathering has been started.'); Logger.log('Max data gathering has been started.');
console.time('max-data-gathering'); console.time('data-gathering-max');
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
@ -105,7 +109,7 @@ export class DataGatheringService {
where: { key: 'LAST_DATA_GATHERING' } where: { key: 'LAST_DATA_GATHERING' }
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
await this.prismaService.property.delete({ await this.prismaService.property.delete({
@ -114,43 +118,73 @@ export class DataGatheringService {
} }
}); });
console.log('Max data gathering has been completed.'); Logger.log('Max data gathering has been completed.');
console.timeEnd('max-data-gathering'); console.timeEnd('data-gathering-max');
} }
} }
public async gatherProfileData(aSymbols?: string[]) { public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
console.log('Profile data gathering has been started.'); Logger.log('Profile data gathering has been started.');
console.time('profile-data-gathering'); console.time('data-gathering-profile');
let symbols = aSymbols; let dataGatheringItems = aDataGatheringItems;
if (!symbols) { if (!dataGatheringItems) {
const dataGatheringItems = await this.getSymbolsProfileData(); dataGatheringItems = await this.getSymbolsProfileData();
symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
} }
const currentData = await this.dataProviderService.get(symbols); const currentData = await this.dataProviderService.get(dataGatheringItems);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, response] of Object.entries(currentData)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.symbolMapping;
for (const dataEnhancer of this.dataEnhancers) {
try {
currentData[symbol] = await dataEnhancer.enhance({
response,
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
});
} catch (error) {
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
}
}
const {
assetClass,
assetSubClass,
countries,
currency,
dataSource,
name,
sectors
} = currentData[symbol];
for (const [
symbol,
{ assetClass, currency, dataSource, name }
] of Object.entries(currentData)) {
try { try {
await this.prismaService.symbolProfile.upsert({ await this.prismaService.symbolProfile.upsert({
create: { create: {
assetClass, assetClass,
assetSubClass,
countries,
currency, currency,
dataSource, dataSource,
name, name,
sectors,
symbol symbol
}, },
update: { update: {
assetClass, assetClass,
assetSubClass,
countries,
currency, currency,
name name,
sectors
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
@ -160,12 +194,12 @@ export class DataGatheringService {
} }
}); });
} catch (error) { } catch (error) {
console.error(`${symbol}: ${error?.meta?.cause}`); Logger.error(`${symbol}: ${error?.meta?.cause}`);
} }
} }
console.log('Profile data gathering has been completed.'); Logger.log('Profile data gathering has been completed.');
console.timeEnd('profile-data-gathering'); console.timeEnd('data-gathering-profile');
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
@ -207,6 +241,7 @@ export class DataGatheringService {
try { try {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
dataSource,
symbol, symbol,
date: currentDate, date: currentDate,
marketPrice: lastMarketPrice marketPrice: lastMarketPrice
@ -226,30 +261,17 @@ export class DataGatheringService {
} }
} catch (error) { } catch (error) {
hasError = true; hasError = true;
console.error(error); Logger.error(error);
} }
} }
await this.exchangeRateDataService.initialize();
if (hasError) { if (hasError) {
throw ''; throw '';
} }
} }
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const scraperConfigurations =
await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => {
return {
dataSource: DataSource.GHOSTFOLIO,
date: startDate,
symbol: scraperConfiguration.symbol
};
});
}
public async getIsInProgress() { public async getIsInProgress() {
return await this.prismaService.property.findUnique({ return await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: 'LOCKED_DATA_GATHERING' }
@ -269,7 +291,7 @@ export class DataGatheringService {
} }
public async reset() { public async reset() {
console.log('Data gathering has been reset.'); Logger.log('Data gathering has been reset.');
await this.prismaService.property.deleteMany({ await this.prismaService.property.deleteMany({
where: { where: {
@ -291,7 +313,7 @@ export class DataGatheringService {
benchmarksToGather.push({ benchmarksToGather.push({
dataSource: DataSource.RAKUTEN, dataSource: DataSource.RAKUTEN,
date: startDate, date: startDate,
symbol: 'GF.FEAR_AND_GREED_INDEX' symbol: ghostfolioFearAndGreedIndexSymbol
}); });
} }
@ -301,81 +323,82 @@ export class DataGatheringService {
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prismaService.order.findMany({ const symbolProfilesToGather = (
distinct: ['symbol'], await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }, select: {
where: { dataSource: true,
date: { scraperConfiguration: true,
lt: endOfToday() // no draft symbol: true
} }
} })
).map((symbolProfile) => {
return {
...symbolProfile,
date: startDate
};
}); });
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders const currencyPairsToGather = this.exchangeRateDataService
.filter((distinctOrder) => { .getCurrencyPairs()
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol); .map(({ dataSource, symbol }) => {
})
.map((distinctOrder) => {
return { return {
...distinctOrder, dataSource,
symbol,
date: startDate date: startDate
}; };
}); });
const currencyPairsToGather = currencyPairs.map(
({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
}
);
const customSymbolsToGather =
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
return [ return [
...this.getBenchmarksToGather(startDate), ...this.getBenchmarksToGather(startDate),
...customSymbolsToGather,
...currencyPairsToGather, ...currencyPairsToGather,
...distinctOrdersWithDate ...symbolProfilesToGather
]; ];
} }
private async getSymbolsMax(): Promise<IDataGatheringItem[]> { private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate = new Date(getUtc('2015-01-01')); const startDate =
(
await this.prismaService.order.findFirst({
orderBy: [{ date: 'asc' }]
})
)?.date ?? new Date();
const customSymbolsToGather = const currencyPairsToGather = this.exchangeRateDataService
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate); .getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const currencyPairsToGather = currencyPairs.map(
({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
symbol, symbol,
date: startDate date: startDate
}; };
} });
);
const distinctOrders = await this.prismaService.order.findMany({ const symbolProfilesToGather = (
distinct: ['symbol'], await this.prismaService.symbolProfile.findMany({
orderBy: [{ date: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, date: true, symbol: true }, select: {
where: { dataSource: true,
date: { Order: {
lt: endOfToday() // no draft orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
} }
} })
).map((symbolProfile) => {
return {
...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate
};
}); });
return [ return [
...this.getBenchmarksToGather(startDate), ...this.getBenchmarksToGather(startDate),
...customSymbolsToGather,
...currencyPairsToGather, ...currencyPairsToGather,
...distinctOrders ...symbolProfilesToGather
]; ];
} }

View File

@ -2,15 +2,15 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns'; import { isAfter, isBefore, parse } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces'; import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@Injectable() @Injectable()
@ -78,12 +78,16 @@ export class AlphaVantageService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
console.error(error, symbol); Logger.error(error, symbol);
return {}; return {};
} }
} }
public getName(): DataSource {
return DataSource.ALPHA_VANTAGE;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aSymbol); const result = await this.alphaVantage.data.search(aSymbol);

View File

@ -0,0 +1,15 @@
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
import { Module } from '@nestjs/common';
@Module({
exports: ['DataEnhancers', TrackinsightDataEnhancerService],
providers: [
{
inject: [TrackinsightDataEnhancerService],
provide: 'DataEnhancers',
useFactory: (trackinsight) => [trackinsight]
},
TrackinsightDataEnhancerService
]
})
export class DataEnhancerModule {}

View File

@ -0,0 +1,77 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import bent from 'bent';
const getJSON = bent('json');
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com/holdings';
private static countries = require('countries-list/dist/countries.json');
private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples',
'Health Care': 'Healthcare',
'Information Technology': 'Technology'
};
public async enhance({
response,
symbol
}: {
response: IDataProviderResponse;
symbol: string;
}): Promise<IDataProviderResponse> {
if (
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
) {
return response;
}
const holdings = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
).catch(() => {
return getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${
symbol.split('.')[0]
}.json`
);
});
if (!response.countries || response.countries.length === 0) {
response.countries = [];
for (const [name, value] of Object.entries<any>(holdings.countries)) {
let countryCode: string;
for (const [key, country] of Object.entries<any>(
TrackinsightDataEnhancerService.countries
)) {
if (country.name === name) {
countryCode = key;
break;
}
}
response.countries.push({
code: countryCode,
weight: value.weight
});
}
}
if (!response.sectors || response.sectors.length === 0) {
response.sectors = [];
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight
});
}
}
return Promise.resolve(response);
}
public getName() {
return 'TRACKINSIGHT';
}
}

View File

@ -1,21 +1,48 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { DataProviderService } from './data-provider.service'; import { DataProviderService } from './data-provider.service';
@Module({ @Module({
imports: [ConfigurationModule, PrismaModule], imports: [
ConfigurationModule,
CryptocurrencyModule,
PrismaModule,
SymbolProfileModule
],
providers: [ providers: [
AlphaVantageService, AlphaVantageService,
DataProviderService, DataProviderService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService YahooFinanceService,
{
inject: [
AlphaVantageService,
GhostfolioScraperApiService,
RakutenRapidApiService,
YahooFinanceService
],
provide: 'DataProviderInterfaces',
useFactory: (
alphaVantageService,
ghostfolioScraperApiService,
rakutenRapidApiService,
yahooFinanceService
) => [
alphaVantageService,
ghostfolioScraperApiService,
rakutenRapidApiService,
yahooFinanceService
]
}
], ],
exports: [DataProviderService, GhostfolioScraperApiService] exports: [DataProviderService, GhostfolioScraperApiService]
}) })

View File

@ -1,90 +1,57 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataGatheringItem, IDataGatheringItem,
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { import { DATE_FORMAT } from '@ghostfolio/common/helper';
DATE_FORMAT,
isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isEmpty } from 'lodash';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from './yahoo-finance/yahoo-finance.service';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService {
public constructor( public constructor(
private readonly alphaVantageService: AlphaVantageService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService, @Inject('DataProviderInterfaces')
private readonly prismaService: PrismaService, private readonly dataProviderInterfaces: DataProviderInterface[],
private readonly rakutenRapidApiService: RakutenRapidApiService, private readonly prismaService: PrismaService
private readonly yahooFinanceService: YahooFinanceService ) {}
) {
this.rakutenRapidApiService?.setPrisma(this.prismaService);
}
public async get( public async get(items: IDataGatheringItem[]): Promise<{
aSymbols: string[] [symbol: string]: IDataProviderResponse;
): Promise<{ [symbol: string]: IDataProviderResponse }> { }> {
if (aSymbols.length === 1) { const response: {
const symbol = aSymbols[0]; [symbol: string]: IDataProviderResponse;
} = {};
if (isGhostfolioScraperApiSymbol(symbol)) { for (const item of items) {
return this.ghostfolioScraperApiService.get(aSymbols); const dataProvider = this.getDataProvider(item.dataSource);
} else if (isRakutenRapidApiSymbol(symbol)) { response[item.symbol] = (await dataProvider.get([item.symbol]))[
return this.rakutenRapidApiService.get(aSymbols); item.symbol
} ];
} }
const yahooFinanceSymbols = aSymbols.filter((symbol) => { const promises = [];
return ( for (const symbol of Object.keys(response)) {
!isGhostfolioScraperApiSymbol(symbol) && const promise = Promise.resolve(response[symbol]);
!isRakutenRapidApiSymbol(symbol) promises.push(
promise.then((currentResponse) => (response[symbol] = currentResponse))
); );
});
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
const ghostfolioScraperApiSymbols = aSymbols.filter((symbol) => {
return isGhostfolioScraperApiSymbol(symbol);
});
for (const symbol of ghostfolioScraperApiSymbols) {
if (symbol) {
const ghostfolioScraperApiResult =
await this.ghostfolioScraperApiService.get([symbol]);
response[symbol] = ghostfolioScraperApiResult[symbol];
}
} }
const rakutenRapidApiSymbols = aSymbols.filter((symbol) => { await Promise.all(promises);
return isRakutenRapidApiSymbol(symbol);
});
for (const symbol of rakutenRapidApiSymbols) {
if (symbol) {
const rakutenRapidApiResult =
await this.ghostfolioScraperApiService.get([symbol]);
response[symbol] = rakutenRapidApiResult[symbol];
}
}
return response; return response;
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aItems: IDataGatheringItem[],
aGranularity: Granularity = 'month', aGranularity: Granularity = 'month',
from: Date, from: Date,
to: Date to: Date
@ -95,6 +62,10 @@ export class DataProviderService {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {}; } = {};
if (isEmpty(aItems)) {
return response;
}
const granularityQuery = const granularityQuery =
aGranularity === 'month' aGranularity === 'month'
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')` ? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
@ -108,10 +79,21 @@ export class DataProviderService {
)}'` )}'`
: ''; : '';
const dataSources = aItems.map((item) => {
return item.dataSource;
});
const symbols = aItems.map((item) => {
return item.symbol;
});
try { try {
const queryRaw = `SELECT * FROM "MarketData" WHERE "symbol" IN ('${aSymbols.join( const queryRaw = `SELECT *
`','` FROM "MarketData"
)}') ${granularityQuery} ${rangeQuery} ORDER BY date;`; WHERE "dataSource" IN ('${dataSources.join(`','`)}')
AND "symbol" IN ('${symbols.join(
`','`
)}') ${granularityQuery} ${rangeQuery}
ORDER BY date;`;
const marketDataByGranularity: MarketData[] = const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRaw(queryRaw); await this.prismaService.$queryRaw(queryRaw);
@ -127,7 +109,7 @@ export class DataProviderService {
return r; return r;
}, {}); }, {});
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} finally { } finally {
return response; return response;
} }
@ -168,13 +150,24 @@ export class DataProviderService {
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
const { items } = await this.getDataProvider( const promises: Promise<{ items: LookupItem[] }>[] = [];
<DataSource>this.configurationService.get('DATA_SOURCES')[0] let lookupItems: LookupItem[] = [];
).search(aSymbol);
const filteredItems = items.filter((item) => { for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push(
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
);
}
const searchResults = await Promise.all(promises);
searchResults.forEach((searchResult) => {
lookupItems = lookupItems.concat(searchResult.items);
});
const filteredItems = lookupItems.filter((lookupItem) => {
// Only allow symbols with supported currency // Only allow symbols with supported currency
return item.currency ? true : false; return lookupItem.currency ? true : false;
}); });
return { return {
@ -182,18 +175,16 @@ export class DataProviderService {
}; };
} }
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
}
private getDataProvider(providerName: DataSource) { private getDataProvider(providerName: DataSource) {
switch (providerName) { for (const dataProviderInterface of this.dataProviderInterfaces) {
case DataSource.ALPHA_VANTAGE: if (dataProviderInterface.getName() === providerName) {
return this.alphaVantageService; return dataProviderInterface;
case DataSource.GHOSTFOLIO: }
return this.ghostfolioScraperApiService;
case DataSource.RAKUTEN:
return this.rakutenRapidApiService;
case DataSource.YAHOO:
return this.yahooFinanceService;
default:
throw new Error('No data provider has been found.');
} }
throw new Error('No data provider has been found.');
} }
} }

View File

@ -1,31 +1,33 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
DATE_FORMAT, DATE_FORMAT,
getYesterday, getYesterday,
isGhostfolioScraperApiSymbol isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { import {
IDataGatheringItem,
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
MarketState MarketState
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { ScraperConfig } from './interfaces/scraper-config.interface'; import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g; private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
public constructor(private readonly prismaService: PrismaService) {} public constructor(
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol); return isGhostfolioScraperApiSymbol(symbol);
@ -39,9 +41,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
try { try {
const symbol = aSymbols[0]; const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol); [symbol]
);
const { marketPrice } = await this.prismaService.marketData.findFirst({ const { marketPrice } = await this.prismaService.marketData.findFirst({
orderBy: { orderBy: {
@ -55,37 +58,18 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return { return {
[symbol]: { [symbol]: {
marketPrice, marketPrice,
currency: scraperConfig?.currency, currency: symbolProfile?.currency,
dataSource: DataSource.GHOSTFOLIO, dataSource: DataSource.GHOSTFOLIO,
marketState: MarketState.delayed marketState: MarketState.delayed
} }
}; };
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
return {}; return {};
} }
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
where: {
dataSource: DataSource.GHOSTFOLIO
}
});
return ghostfolioSymbolProfiles.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
}
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbols: string[],
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
@ -99,11 +83,11 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
try { try {
const symbol = aSymbols[0]; const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
const scraperConfiguration = await this.getScraperConfigurationBySymbol( [symbol]
symbol
); );
const scraperConfiguration = symbolProfile?.scraperConfiguration;
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {}); const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
@ -122,26 +106,14 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
}; };
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
return {}; return {};
} }
public async getScraperConfigurations(): Promise<ScraperConfig[]> { public getName(): DataSource {
try { return DataSource.GHOSTFOLIO;
const { value: scraperConfigString } =
await this.prismaService.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
return JSON.parse(scraperConfigString);
} catch {}
return [];
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
@ -158,11 +130,4 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return undefined; return undefined;
} }
} }
private async getScraperConfigurationBySymbol(aSymbol: string) {
const scraperConfigurations = await this.getScraperConfigurations();
return scraperConfigurations.find((scraperConfiguration) => {
return scraperConfiguration.symbol === aSymbol;
});
}
} }

View File

@ -1,8 +0,0 @@
import { Currency } from '@prisma/client';
export interface ScraperConfig {
currency: Currency;
selector: string;
symbol: string;
url: string;
}

View File

@ -0,0 +1,4 @@
export interface ScraperConfiguration {
selector: string;
url: string;
}

View File

@ -0,0 +1,13 @@
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
export interface DataEnhancerInterface {
enhance({
response,
symbol
}: {
response: IDataProviderResponse;
symbol: string;
}): Promise<IDataProviderResponse>;
getName(): string;
}

View File

@ -1,10 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from './interfaces'; } from '../../interfaces/interfaces';
export interface DataProviderInterface { export interface DataProviderInterface {
canHandle(symbol: string): boolean; canHandle(symbol: string): boolean;
@ -20,5 +21,7 @@ export interface DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>; }>;
getName(): DataSource;
search(aSymbol: string): Promise<{ items: LookupItem[] }>; search(aSymbol: string): Promise<{ items: LookupItem[] }>;
} }

View File

@ -1,6 +1,7 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getToday, getToday,
@ -8,26 +9,25 @@ import {
isRakutenRapidApiSymbol isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; import { format, subMonths, subWeeks, subYears } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
MarketState MarketState
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable() @Injectable()
export class RakutenRapidApiService implements DataProviderInterface { export class RakutenRapidApiService implements DataProviderInterface {
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index'; public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
private prismaService: PrismaService;
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
) {} ) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
@ -47,11 +47,11 @@ export class RakutenRapidApiService implements DataProviderInterface {
try { try {
const symbol = aSymbols[0]; const symbol = aSymbols[0];
if (symbol === 'GF.FEAR_AND_GREED_INDEX') { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
return { return {
'GF.FEAR_AND_GREED_INDEX': { [ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined, currency: undefined,
dataSource: DataSource.RAKUTEN, dataSource: DataSource.RAKUTEN,
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
@ -61,7 +61,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
}; };
} }
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
} }
return {}; return {};
@ -82,7 +82,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
try { try {
const symbol = aSymbols[0]; const symbol = aSymbols[0];
if (symbol === 'GF.FEAR_AND_GREED_INDEX') { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
try { try {
@ -93,6 +93,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN,
date: subWeeks(getToday(), 1), date: subWeeks(getToday(), 1),
marketPrice: fgi.oneWeekAgo.value marketPrice: fgi.oneWeekAgo.value
} }
@ -101,6 +102,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN,
date: subMonths(getToday(), 1), date: subMonths(getToday(), 1),
marketPrice: fgi.oneMonthAgo.value marketPrice: fgi.oneMonthAgo.value
} }
@ -109,6 +111,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN,
date: subYears(getToday(), 1), date: subYears(getToday(), 1),
marketPrice: fgi.oneYearAgo.value marketPrice: fgi.oneYearAgo.value
} }
@ -118,7 +121,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
} catch {} } catch {}
return { return {
'GF.FEAR_AND_GREED_INDEX': { [ghostfolioFearAndGreedIndexSymbol]: {
[format(getYesterday(), DATE_FORMAT)]: { [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value marketPrice: fgi.previousClose.value
} }
@ -130,12 +133,12 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {}; return {};
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public getName(): DataSource {
return { items: [] }; return DataSource.RAKUTEN;
} }
public setPrisma(aPrismaService: PrismaService) { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
this.prismaService = aPrismaService; return { items: [] };
} }
private async getFearAndGreedIndex(): Promise<{ private async getFearAndGreedIndex(): Promise<{
@ -163,7 +166,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
const { fgi } = await get(); const { fgi } = await get();
return fgi; return fgi;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
return undefined; return undefined;
} }

View File

@ -25,6 +25,7 @@ export interface IYahooFinancePrice {
} }
export interface IYahooFinanceSummaryProfile { export interface IYahooFinanceSummaryProfile {
country?: string;
industry?: string; industry?: string;
sector?: string; sector?: string;
website?: string; website?: string;

View File

@ -1,27 +1,25 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
DATE_FORMAT,
isCrypto,
isCurrency,
parseCurrency
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { AssetClass, Currency, DataSource } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { countries } from 'countries-list';
import { format } from 'date-fns'; import { format } from 'date-fns';
import * as yahooFinance from 'yahoo-finance'; import * as yahooFinance from 'yahoo-finance';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
MarketState MarketState
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
import { import {
IYahooFinanceHistoricalResponse, IYahooFinanceHistoricalResponse,
IYahooFinancePrice,
IYahooFinanceQuoteResponse IYahooFinanceQuoteResponse
} from './interfaces/interfaces'; } from './interfaces/interfaces';
@ -29,7 +27,9 @@ import {
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private yahooFinanceHostname = 'https://query1.finance.yahoo.com'; private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor() {} public constructor(
private readonly cryptocurrencyService: CryptocurrencyService
) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -41,10 +41,9 @@ export class YahooFinanceService implements DataProviderInterface {
if (aSymbols.length <= 0) { if (aSymbols.length <= 0) {
return {}; return {};
} }
const yahooFinanceSymbols = aSymbols.map((symbol) =>
const yahooSymbols = aSymbols.map((symbol) => { this.convertToYahooFinanceSymbol(symbol)
return this.convertToYahooSymbol(symbol); );
});
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {}; const response: { [symbol: string]: IDataProviderResponse } = {};
@ -53,20 +52,24 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: IYahooFinanceQuoteResponse; [symbol: string]: IYahooFinanceQuoteResponse;
} = await yahooFinance.quote({ } = await yahooFinance.quote({
modules: ['price', 'summaryProfile'], modules: ['price', 'summaryProfile'],
symbols: yahooSymbols symbols: yahooFinanceSymbols
}); });
for (const [yahooSymbol, value] of Object.entries(data)) { for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
// Convert symbols back // Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol); const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
response[symbol] = { response[symbol] = {
assetClass: this.parseAssetClass(value.price?.quoteType), assetClass,
currency: parseCurrency(value.price?.currency), assetSubClass,
currency: value.price?.currency,
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
exchange: this.parseExchange(value.price?.exchangeName), exchange: this.parseExchange(value.price?.exchangeName),
marketState: marketState:
value.price?.marketState === 'REGULAR' || isCrypto(symbol) value.price?.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCrypto(symbol)
? MarketState.open ? MarketState.open
: MarketState.closed, : MarketState.closed,
marketPrice: value.price?.regularMarketPrice || 0, marketPrice: value.price?.regularMarketPrice || 0,
@ -75,7 +78,7 @@ export class YahooFinanceService implements DataProviderInterface {
if (value.price?.currency === 'GBp') { if (value.price?.currency === 'GBp') {
// Convert GBp (pence) to GBP // Convert GBp (pence) to GBP
response[symbol].currency = Currency.GBP; response[symbol].currency = 'GBP';
response[symbol].marketPrice = new Big( response[symbol].marketPrice = new Big(
value.price?.regularMarketPrice ?? 0 value.price?.regularMarketPrice ?? 0
) )
@ -83,6 +86,29 @@ export class YahooFinanceService implements DataProviderInterface {
.toNumber(); .toNumber();
} }
// Add country if stock and available
if (
assetSubClass === AssetSubClass.STOCK &&
value.summaryProfile?.country
) {
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === value.summaryProfile?.country;
});
if (code) {
response[symbol].countries = [{ code, weight: 1 }];
}
} catch {}
if (value.summaryProfile?.sector) {
response[symbol].sectors = [
{ name: value.summaryProfile?.sector, weight: 1 }
];
}
}
// Add url if available
const url = value.summaryProfile?.website; const url = value.summaryProfile?.website;
if (url) { if (url) {
response[symbol].url = url; response[symbol].url = url;
@ -91,7 +117,7 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
return {}; return {};
} }
@ -109,15 +135,15 @@ export class YahooFinanceService implements DataProviderInterface {
return {}; return {};
} }
const yahooSymbols = aSymbols.map((symbol) => { const yahooFinanceSymbols = aSymbols.map((symbol) => {
return this.convertToYahooSymbol(symbol); return this.convertToYahooFinanceSymbol(symbol);
}); });
try { try {
const historicalData: { const historicalData: {
[symbol: string]: IYahooFinanceHistoricalResponse[]; [symbol: string]: IYahooFinanceHistoricalResponse[];
} = await yahooFinance.historical({ } = await yahooFinance.historical({
symbols: yahooSymbols, symbols: yahooFinanceSymbols,
from: format(from, DATE_FORMAT), from: format(from, DATE_FORMAT),
to: format(to, DATE_FORMAT) to: format(to, DATE_FORMAT)
}); });
@ -126,9 +152,11 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {}; } = {};
for (const [yahooSymbol, timeSeries] of Object.entries(historicalData)) { for (const [yahooFinanceSymbol, timeSeries] of Object.entries(
historicalData
)) {
// Convert symbols back // Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol); const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {}; response[symbol] = {};
timeSeries.forEach((timeSerie) => { timeSeries.forEach((timeSerie) => {
@ -141,14 +169,18 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
return {}; return {};
} }
} }
public getName(): DataSource {
return DataSource.YAHOO;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const get = bent(
@ -165,19 +197,6 @@ export class YahooFinanceService implements DataProviderInterface {
// filter out undefined symbols // filter out undefined symbols
return quote.symbol; return quote.symbol;
}) })
.filter(({ quoteType }) => {
return quoteType === 'EQUITY' || quoteType === 'ETF';
})
.map(({ symbol }) => {
return symbol;
});
const marketData = await this.get(symbols);
items = searchResult.quotes
.filter((quote) => {
return quote.isYahooFinance;
})
.filter(({ quoteType }) => { .filter(({ quoteType }) => {
return ( return (
quoteType === 'CRYPTOCURRENCY' || quoteType === 'CRYPTOCURRENCY' ||
@ -188,61 +207,90 @@ export class YahooFinanceService implements DataProviderInterface {
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') { if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD // Only allow cryptocurrencies in USD
return symbol.includes(Currency.USD); return symbol.includes('USD');
} }
return true; return true;
}) })
.map(({ longname, shortname, symbol }) => { .map(({ symbol }) => {
return { return symbol;
currency: marketData[symbol]?.currency,
dataSource: DataSource.YAHOO,
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
}); });
const marketData = await this.get(symbols);
for (const [symbol, value] of Object.entries(marketData)) {
items.push({
symbol,
currency: value.currency,
dataSource: DataSource.YAHOO,
name: value.name
});
}
} catch {} } catch {}
return { items }; return { items };
} }
private convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace('-', '');
return symbol.replace('=X', '');
}
/** /**
* Converts a symbol to a Yahoo symbol * Converts a symbol to a Yahoo Finance symbol
* *
* Currency: USDCHF=X * Currency: USDCHF -> USDCHF=X
* Cryptocurrency: BTC-USD * Cryptocurrency: BTCUSD -> BTC-USD
* DOGEUSD -> DOGE-USD
* SOL1USD -> SOL1-USD
*/ */
private convertToYahooSymbol(aSymbol: string) { private convertToYahooFinanceSymbol(aSymbol: string) {
if (isCurrency(aSymbol)) { if (
if (isCrypto(aSymbol)) { (aSymbol.includes('CHF') ||
aSymbol.includes('EUR') ||
aSymbol.includes('USD')) &&
aSymbol.length >= 6
) {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCrypto(aSymbol) ||
this.cryptocurrencyService.isCrypto(aSymbol.replace('1', ''))
) {
// Add a dash before the last three characters // Add a dash before the last three characters
// BTCUSD -> BTC-USD // BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD // DOGEUSD -> DOGE-USD
return `${aSymbol.substring(0, aSymbol.length - 3)}-${aSymbol.substring( // SOL1USD -> SOL1-USD
aSymbol.length - 3 return aSymbol.replace('USD', '-USD');
)}`;
} }
return `${aSymbol}=X`;
} }
return aSymbol; return aSymbol;
} }
private parseAssetClass(aString: string): AssetClass { private parseAssetClass(aPrice: IYahooFinancePrice): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass; let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (aString?.toLowerCase()) { switch (aPrice?.quoteType?.toLowerCase()) {
case 'cryptocurrency': case 'cryptocurrency':
assetClass = AssetClass.CASH; assetClass = AssetClass.CASH;
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break; break;
case 'equity': case 'equity':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'etf': case 'etf':
assetClass = AssetClass.EQUITY; assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break; break;
} }
return assetClass; return { assetClass, assetSubClass };
} }
private parseExchange(aString: string): string { private parseExchange(aString: string): string {
@ -253,8 +301,3 @@ export class YahooFinanceService implements DataProviderInterface {
return aString; return aString;
} }
} }
export const convertFromYahooSymbol = (aSymbol: string) => {
const symbol = aSymbol.replace('-', '');
return symbol.replace('=X', '');
};

View File

@ -2,8 +2,10 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module';
@Module({ @Module({
imports: [DataProviderModule], imports: [DataProviderModule, PrismaModule],
providers: [ExchangeRateDataService], providers: [ExchangeRateDataService],
exports: [ExchangeRateDataService] exports: [ExchangeRateDataService]
}) })

View File

@ -1,27 +1,46 @@
import { currencyPairs } from '@ghostfolio/common/config'; import { baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isNumber } from 'lodash'; import { isEmpty, isNumber, uniq } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private currencyPairs: string[] = []; private currencies: string[] = [];
private currencyPairs: IDataGatheringItem[] = [];
private exchangeRates: { [currencyPair: string]: number } = {}; private exchangeRates: { [currencyPair: string]: number } = {};
public constructor(private dataProviderService: DataProviderService) { public constructor(
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService
) {
this.initialize(); this.initialize();
} }
public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [baseCurrency];
}
public getCurrencyPairs() {
return this.currencyPairs;
}
public async initialize() { public async initialize() {
this.currencies = await this.prepareCurrencies();
this.currencyPairs = []; this.currencyPairs = [];
this.exchangeRates = {}; this.exchangeRates = {};
for (const { currency1, currency2 } of currencyPairs) { for (const {
this.addCurrencyPairs(currency1, currency2); currency1,
currency2,
dataSource
} of this.prepareCurrencyPairs(this.currencies)) {
this.addCurrencyPairs({ currency1, currency2, dataSource });
} }
await this.loadCurrencies(); await this.loadCurrencies();
@ -35,6 +54,24 @@ export class ExchangeRateDataService {
getYesterday() getYesterday()
); );
if (isEmpty(result)) {
// Load currencies directly from data provider as a fallback
// if historical data is not yet available
const historicalData = await this.dataProviderService.get(
this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
);
Object.keys(historicalData).forEach((key) => {
result[key] = {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: historicalData[key].marketPrice
}
};
});
}
const resultExtended = result; const resultExtended = result;
Object.keys(result).forEach((pair) => { Object.keys(result).forEach((pair) => {
@ -49,31 +86,35 @@ export class ExchangeRateDataService {
}; };
}); });
this.currencyPairs.forEach((pair) => { this.currencyPairs.forEach(({ symbol }) => {
const [currency1, currency2] = pair.match(/.{1,3}/g); const [currency1, currency2] = symbol.match(/.{1,3}/g);
const date = format(getYesterday(), DATE_FORMAT); const date = format(getYesterday(), DATE_FORMAT);
this.exchangeRates[pair] = resultExtended[pair]?.[date]?.marketPrice; this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice;
if (!this.exchangeRates[pair]) { if (!this.exchangeRates[symbol]) {
// Not found, calculate indirectly via USD // Not found, calculate indirectly via USD
this.exchangeRates[pair] = this.exchangeRates[symbol] =
resultExtended[`${currency1}${Currency.USD}`]?.[date]?.marketPrice * resultExtended[`${currency1}${'USD'}`]?.[date]?.marketPrice *
resultExtended[`${Currency.USD}${currency2}`]?.[date]?.marketPrice; resultExtended[`${'USD'}${currency2}`]?.[date]?.marketPrice;
// Calculate the opposite direction // Calculate the opposite direction
this.exchangeRates[`${currency2}${currency1}`] = this.exchangeRates[`${currency2}${currency1}`] =
1 / this.exchangeRates[pair]; 1 / this.exchangeRates[symbol];
} }
}); });
} }
public toCurrency( public toCurrency(
aValue: number, aValue: number,
aFromCurrency: Currency, aFromCurrency: string,
aToCurrency: Currency aToCurrency: string
) { ) {
if (isNaN(this.exchangeRates[`${Currency.USD}${Currency.CHF}`])) { const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
return isNaN(exchangeRate);
});
if (hasNaN) {
// Reinitialize if data is not loaded correctly // Reinitialize if data is not loaded correctly
this.initialize(); this.initialize();
} }
@ -85,8 +126,8 @@ export class ExchangeRateDataService {
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`]; factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
} else { } else {
// Calculate indirectly via USD // Calculate indirectly via USD
const factor1 = this.exchangeRates[`${aFromCurrency}${Currency.USD}`]; const factor1 = this.exchangeRates[`${aFromCurrency}${'USD'}`];
const factor2 = this.exchangeRates[`${Currency.USD}${aToCurrency}`]; const factor2 = this.exchangeRates[`${'USD'}${aToCurrency}`];
factor = factor1 * factor2; factor = factor1 * factor2;
@ -94,19 +135,84 @@ export class ExchangeRateDataService {
} }
} }
if (isNumber(factor)) { if (isNumber(factor) && !isNaN(factor)) {
return factor * aValue; return factor * aValue;
} }
// Fallback with error, if currencies are not available // Fallback with error, if currencies are not available
console.error( Logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}` `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
); );
return aValue; return aValue;
} }
private addCurrencyPairs(aCurrency1: Currency, aCurrency2: Currency) { private addCurrencyPairs({
this.currencyPairs.push(`${aCurrency1}${aCurrency2}`); currency1,
this.currencyPairs.push(`${aCurrency2}${aCurrency1}`); currency2,
dataSource
}: {
currency1: string;
currency2: string;
dataSource: DataSource;
}) {
this.currencyPairs.push({
dataSource,
symbol: `${currency1}${currency2}`
});
this.currencyPairs.push({
dataSource,
symbol: `${currency2}${currency1}`
});
}
private async prepareCurrencies(): Promise<string[]> {
const currencies: string[] = [];
(
await this.prismaService.account.findMany({
distinct: ['currency'],
orderBy: [{ currency: 'asc' }],
select: { currency: true }
})
).forEach((account) => {
currencies.push(account.currency);
});
(
await this.prismaService.settings.findMany({
distinct: ['currency'],
orderBy: [{ currency: 'asc' }],
select: { currency: true }
})
).forEach((userSettings) => {
currencies.push(userSettings.currency);
});
(
await this.prismaService.symbolProfile.findMany({
distinct: ['currency'],
orderBy: [{ currency: 'asc' }],
select: { currency: true }
})
).forEach((symbolProfile) => {
currencies.push(symbolProfile.currency);
});
return uniq(currencies).sort();
}
private prepareCurrencyPairs(aCurrencies: string[]) {
return aCurrencies
.filter((currency) => {
return currency !== baseCurrency;
})
.map((currency) => {
return {
currency1: baseCurrency,
currency2: currency,
dataSource: this.dataProviderService.getPrimaryDataSource(),
symbol: `${baseCurrency}${currency}`
};
});
} }
} }

View File

@ -1,7 +1,7 @@
import { import {
Account, Account,
AssetClass, AssetClass,
Currency, AssetSubClass,
DataSource, DataSource,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
@ -16,7 +16,7 @@ export const MarketState = {
export interface IOrder { export interface IOrder {
account: Account; account: Account;
currency: Currency; currency: string;
date: string; date: string;
fee: number; fee: number;
id?: string; id?: string;
@ -35,7 +35,9 @@ export interface IDataProviderHistoricalResponse {
export interface IDataProviderResponse { export interface IDataProviderResponse {
assetClass?: AssetClass; assetClass?: AssetClass;
currency: Currency; assetSubClass?: AssetSubClass;
countries?: { code: string; weight: number }[];
currency: string;
dataSource: DataSource; dataSource: DataSource;
exchange?: string; exchange?: string;
marketChange?: number; marketChange?: number;
@ -43,6 +45,7 @@ export interface IDataProviderResponse {
marketPrice: number; marketPrice: number;
marketState: MarketState; marketState: MarketState;
name?: string; name?: string;
sectors?: { name: string; weight: number }[];
url?: string; url?: string;
} }

View File

@ -1,16 +1,20 @@
import { ScraperConfiguration } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { AssetClass, Currency, DataSource } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface EnhancedSymbolProfile { export interface EnhancedSymbolProfile {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass;
countries: Country[];
createdAt: Date; createdAt: Date;
currency: Currency | null; currency: string | null;
dataSource: DataSource; dataSource: DataSource;
id: string; id: string;
name: string | null; name: string | null;
updatedAt: Date; scraperConfiguration?: ScraperConfiguration | null;
symbol: string;
countries: Country[];
sectors: Sector[]; sectors: Sector[];
symbol: string;
symbolMapping?: { [key: string]: string };
updatedAt: Date;
} }

View File

@ -0,0 +1,11 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { SymbolProfileService } from './symbol-profile.service';
@Module({
imports: [PrismaModule],
providers: [SymbolProfileService],
exports: [SymbolProfileService]
})
export class SymbolProfileModule {}

Some files were not shown because too many files have changed in this diff Show More