Compare commits

...

111 Commits

Author SHA1 Message Date
9002c20165 Release 1.264.0 (#1916) 2023-05-01 17:46:28 +02:00
15c96a9757 Feature/add allocations by platform chart (#1915)
* Add allocations by platform

* Update changelog
2023-05-01 17:44:35 +02:00
1ca3792a4b Feature/clean up initial values from x ray (#1914)
* Clean up initial (original) values from X-Ray

* Refactor current to valueInBaseCurrency

* Update changelog
2023-05-01 17:16:02 +02:00
90fe467114 Feature/deprecate base currency (#1913)
* Deprecate BASE_CURRENCY

* Update changelog
2023-05-01 15:45:59 +02:00
e61b3b34a7 Eliminate getSymbolProfilesBySymbols() (#1912) 2023-05-01 15:45:33 +02:00
1326418ffc Release 1.263.0 (#1911) 2023-04-30 19:21:35 +02:00
a5f0f48ddb Fix accounts page (#1908)
* Add guards

* Update changelog
2023-04-30 19:20:17 +02:00
e500ccb61b Feature/introduce env variable data source exchange rates and data source import (#1910)
* Introduce env variables DATA_SOURCE_EXCHANGE_RATES and DATA_SOURCE_IMPORT

* Update changelog
2023-04-30 18:26:34 +02:00
4090b03406 Release 1.262.0 (#1906) 2023-04-29 10:41:28 +02:00
431d1d5fec Feature/extract locales 20230429 (#1905)
* Update locales

* Update changelog
2023-04-29 10:39:17 +02:00
d74d79198b Feature/add labels to tabs (#1847)
* Add labels

* Update changelog
2023-04-29 10:15:55 +02:00
623a284ba4 Feature/add and update historical data in bulk (#1904)
* Upsert historical data in bulk

* Update changelog
2023-04-29 10:12:50 +02:00
f79c36edbb Fix import (#1902) 2023-04-29 10:00:04 +02:00
f4c748f67a Feature/extend support for impersonation mode (#1898)
* Support impersonation of all users for local development

* Update changelog
2023-04-28 21:02:24 +02:00
672d8dfab2 Fix/holdings always include cash position (#1897)
* Improved holdings table showing cash position also when the filter contains accounts

* Update changelog
2023-04-28 12:08:45 +02:00
0464adccce Fix script (#1885) 2023-04-26 13:41:05 +02:00
c3df6c3194 Release 1.261.0 (#1896) 2023-04-25 20:24:39 +02:00
29d53c7df4 Feature/add distance to now to the subscription expiration date (#1895)
* Add distance to now

* Update changelog
2023-04-25 20:23:07 +02:00
7b77dc044a Feature/add state to market data database schema (#1893)
* Add state (CLOSE / INTRADAY) to MarketData

* Update changelog
2023-04-25 20:09:12 +02:00
67e758365f feature: allow to delete all activities of a user (#1880)
* Allow to delete all activities of a user

* Update changelog
2023-04-23 19:49:32 +02:00
475231ffd8 Release 1.260.0 (#1892) 2023-04-23 12:04:06 +02:00
513a564e2c Restructure services (#1891) 2023-04-23 12:02:01 +02:00
cddea0401f Feature/add data source as unique constraint to market data schema (#1889)
* Add dataSource as unique constraint to MarketData schema

* Update changelog
2023-04-23 11:13:08 +02:00
3dafbf7fef Add schema synchronization (#1890) 2023-04-23 11:02:12 +02:00
fcd75414be Update date (#1860) 2023-04-23 11:01:53 +02:00
c1b5bfff8c Bugfix/remove sort header in comment column of market data table (#1888)
* Remove sort header

* Update changelog
2023-04-23 10:49:11 +02:00
3c322cca0d Release 1.259.0 (#1887) 2023-04-22 16:05:44 +02:00
e965d12e31 Feature/add health check endpoints (#1886)
* Add health check endpoints

* Update changelog
2023-04-22 16:03:45 +02:00
3daf55a0dd Bugfix/remove sort header in comment column of activities table (#1883)
* Remove sort header

* Update changelog
2023-04-22 14:44:45 +02:00
aafedd5f75 Feature/increase robustness if live data is missing (#1884)
* Continuously persist today's market data

* Add fallback to historical market data if data provider does not provide live data

* Update changelog
2023-04-22 14:43:57 +02:00
32956ae04c Fix: performance column header alignment (#1881)
* Fix: performance column header alignment

* Update changelog
2023-04-21 18:05:51 +02:00
bfd0241b2d update target in proxy to work with api in locahost (#1875)
Co-authored-by: francisco <francisco@innonova.ch>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2023-04-20 18:51:35 +02:00
5eff8402db Release/1.258.0 (#1878)
* Release 1.258.0
  * Introduce data source mapping

* Update changelog
2023-04-20 09:07:22 +02:00
ffa020ee2a Release 1.257.0 (#1872) 2023-04-18 20:36:22 +02:00
80a3668aa9 Bugfix/fix world map chart component (#1871)
* Clone countries before manipulation

* Update changelog
2023-04-18 20:32:18 +02:00
7378900050 Bugfix/fix currency inconsistency with GBX and GBp in eod historical data service (#1869)
* Fix currency inconsistency (GBX vs. GBp)

* Update changelog
2023-04-18 20:31:33 +02:00
9be457943c Feature/introduce allocations by etf provider (#1870)
* Introduce allocations by etf provider

* Update changelog
2023-04-18 20:13:03 +02:00
93454c6c15 Release 1.256.0 (#1868) 2023-04-17 14:11:59 +02:00
fccbd76993 Bugfix/fix style of apply current market price button (#1866)
* Fix styles

* Update changelog
2023-04-17 14:09:55 +02:00
922876a893 Feature/add yahoo finance data enhancer (#1865)
* Add Yahoo Finance data enhancer

* Update changelog
2023-04-17 14:09:27 +02:00
654446f068 Feature/improve handling of jobs (#1864)
* Improve handling of jobs
  * Remove jobs on complete
  * Refactor jobs removal

* Update changelog
2023-04-16 19:56:19 +02:00
947460abdd Bugfix/fix ids of gather asset profile process jobs (#1863)
* Fix job ids

* Update changelog
2023-04-16 09:49:27 +02:00
c5635b0050 Release 1.255.0 (#1862) 2023-04-15 16:00:47 +02:00
8a3a6308a3 Feature/make system message expandable (#1861)
* Make system message expandable

* Update changelog
2023-04-15 15:59:04 +02:00
290a07fe79 Feature/upgrade prisma to version 4.12.0 (#1845)
* Update prisma to version 4.12.0

* Update changelog
2023-04-15 15:32:02 +02:00
4c907d56f0 Improve message (#1859) 2023-04-15 15:09:15 +02:00
56b437ca74 Bugfix/improve info message style (#1858)
* Improve info message style

* Update changelog
2023-04-15 15:08:56 +02:00
e23ff33e6f Feature/skip job creation for manual data source without scraper configuration (#1857)
* Skip job creation for MANUAL data source without scraper configuration

* Update changelog
2023-04-15 11:54:47 +02:00
f2d206262e Release 1.254.0 (#1856) 2023-04-14 19:58:49 +02:00
1ed5690b33 Feature/improve queue jobs implementation (#1855)
* Improve queue jobs implementation

* Update changelog
2023-04-14 19:57:23 +02:00
4451514ec5 Release 1.253.0 (#1854) 2023-04-14 07:01:34 +02:00
8f73f85276 Bugfix/fix background color of dialogs in dark mode (#1853)
* Fix background color

* Update changelog
2023-04-13 08:47:19 +02:00
9a5d7b664b Release 1.252.2 (#1852) 2023-04-11 18:06:34 +02:00
7d2d1d971a Feature/deprecate get auth endpoint (#1851)
* Deprecate GET auth endpoint

* Update documentation

* Update changelog
2023-04-11 18:04:18 +02:00
d111493eed Release 1.252.1 (#1849) 2023-04-10 20:52:34 +02:00
e975f92a96 Release 1.252.0 (#1848) 2023-04-10 18:03:02 +02:00
739cb4242d Feature/decrease density of theme (#1846)
* Decrease density

* Update changelog
2023-04-10 17:59:29 +02:00
a37eebc9f1 Feature/upgrade nestjs from version 9.1.4 to 9.4.0 (#1843)
* Upgrade nestjs from version 9.1.4 to 9.4.0

* Update changelog
2023-04-10 13:50:27 +02:00
e92730879e Feature/migrate dialog components to angular material 15 (#1844)
* Migrate MatDialog

* Update changelog
2023-04-10 13:49:53 +02:00
464973f9b0 Feature/migrate form components to angular material 15 (#1842)
* Upgrade @angular/cdk

* Upgrade form components to Angular Material 15

* Update changelog
2023-04-10 10:59:44 +02:00
f6228c099f Migrate MatRadio (#1841) 2023-04-10 09:23:16 +02:00
a57fdfb2bb Feature/migrate slide toggle components to angular material 15 (#1840)
* Upgrade @angular/material

* Change MatSlideToggle to MatCheckbox

* Update changelog
2023-04-09 09:33:36 +02:00
24716f0561 Migrate checkbox and chips components to Angular Material 15 (#1839) 2023-04-09 08:55:09 +02:00
3453100afd Feature/migrate various components to angular material 15 part 2 (#1838)
* Migrate tooltips to Angular Material 15

* Migrate tabs to Angular Material 15
2023-04-09 08:54:32 +02:00
84de2c0c68 Migrate card components to Angular Material 15 (#1837) 2023-04-08 17:08:30 +02:00
1b7b082003 Feature/migrate various components to angular material 15 (#1836)
* Migrate components to Angular Material 15

* Update changelog
2023-04-08 15:33:27 +02:00
1928c2c2cc Release 1.251.0 (#1834) 2023-04-07 19:33:26 +02:00
52e7a7886d Feature/migrate libs components to angular material 15 (#1833)
* Migrate to Angular Material 15

* Update changelog
2023-04-07 17:10:03 +02:00
36298b217e Feature/improve tick abbreviation function (#1828)
* Keep the value if value smaller than 1000

* Update changelog
2023-04-07 17:09:34 +02:00
9bce57894e Feature/increase historical market data gathering to 10 years (#1830)
* Increase historical market data gathering of currency pairs to 10+ years

* Update changelog
2023-04-07 16:35:37 +02:00
9d6bb325cd Improve initialization (#1832) 2023-04-07 16:33:55 +02:00
a5f833c612 Feature/upgrade angular and nx 20230405 (#1826)
* Upgrade angular and Nx

* Update changelog
2023-04-06 19:18:23 +02:00
732b14c6ab Feature/improve activities import for csv files of ibkr (#1824)
* Improve import for csv files by Interactive Brokers

* Update changelog
2023-04-05 20:09:00 +02:00
b74a042da8 Feature/change auth endpoint from get to post (#1823)
* Change auth endpoint from GET to POST
  * Login with security token
  * Login with Internet Identity

* Update changelog
2023-04-05 18:10:29 +02:00
d55c052f57 Feature/improve content of pricing and faq pages (#1822)
* Improve content

* Update changelog
2023-04-03 17:39:32 +02:00
864f585efa Release/1.250.0 (#1821) 2023-04-02 09:47:13 +02:00
6d56146054 Feature/add support for multiple subscription offers (#1818)
* Setup for multiple subscription offers

* Update changelog
2023-04-02 09:44:13 +02:00
2c9f29a3c6 Feature/improve handling of platforms in accounts import (#1820)
* Improve handling of platforms

* Fix issue with pagination

* Update changelog
2023-04-01 10:51:55 +02:00
9bef2e960c Feature/ignore first item in portfolio evolution chart (#1816)
* Ignore first item in portfolio evolution chart

* Update changelog
2023-04-01 10:29:39 +02:00
17b8c41673 Add test file (#1810) 2023-03-30 08:24:44 +02:00
f0afbd7346 Release 1.249.0 (#1815) 2023-03-27 20:38:46 +02:00
5dc7429f6a Feature/add testimonials (#1814)
* Add testimonials

* Update changelog
2023-03-27 20:36:49 +02:00
7b39b32293 Feature/improve allocations page (#1813)
* Improve loading state

* Update changelog
2023-03-26 17:18:48 +02:00
e5b5a9e7e9 Feature/improve language localization for german (#1809)
* Improve language localization

* Update changelog
2023-03-26 17:17:48 +02:00
1f3511368a Feature/always show label in value component (#1812)
* Always show label while loading

* Update changelog
2023-03-26 16:59:52 +02:00
b37df2c84f Bugfix/fix algebraic sign in value component (#1811)
* Fix algebraic sign by resetting member variables

* Update changelog
2023-03-26 16:16:24 +02:00
f92ba54060 Release/1.248.0 (#1808) 2023-03-25 17:45:51 +01:00
a3bbd4030e Improve blog post and add images (#1807) 2023-03-25 17:42:41 +01:00
4b30da2d92 Feature/conditionally hide platform selector (#1805)
* Hide platform selector conditionally

* Update changelog
2023-03-25 16:46:46 +01:00
93d082afbb Feature/add blog post 1000 stars on GitHub (#1804)
* Add blog post: 1000 Stars on GitHub

* Add breadcrumb navigation

* Update changelog
2023-03-25 14:28:06 +01:00
0c85380dbf Feature/refactor portfolio calculator (#1803)
* Refactor chart calculation in portfolio calculator

* Update changelog
2023-03-25 12:20:42 +01:00
fb576376dc Feature/upgrade ng extract i18n merge (#1802)
* Upgrade ng-extract-i18n-merge

* Extract locales

* Update changelog
2023-03-24 17:34:27 +01:00
ff111d4c6c Release 1.247.0 (#1801) 2023-03-23 19:26:01 +01:00
bc6e9a8b68 Bugfix/fix total amount calculation in portfolio evolution chart (#1799)
* Fix total amount calculation

* Update changelog
2023-03-23 19:23:31 +01:00
bd1963ec26 Feature/add asset and asset sub class to search endpoint (#1795)
* Add asset and asset sub class to search endpoint

* Update changelog
2023-03-23 19:11:38 +01:00
a0bec9e97f Feature/remove mail address part 2 (#1800)
* Remove mail address and update Slack url

* Update changelog
2023-03-23 14:14:31 +01:00
c45df20d88 Sort imports (#1797) 2023-03-21 19:34:40 +01:00
fa1d669633 Feature/upgrade prisma to version 4.11.0 (#1798)
* Upgrade prisma to version 4.11.0

* Update changelog
2023-03-20 20:08:08 +01:00
1009b462e9 Feature/add subscription expiration dates to the admin control panel (#1796)
* Add expiration date

* Update changelog
2023-03-19 12:03:47 +01:00
b404858904 Release 1.246.0 (#1794) 2023-03-18 10:36:40 +01:00
7ec033577f Feature/extend trackinsight data enhancer by isin (#1793)
* Extend data enhancer by isin

* Update changelog
2023-03-18 10:34:50 +01:00
c8ca82b803 Feature/extend data source eod historical data by asset class and isin (#1791)
* Extend EodHistoricalDataService

* asset and asset sub class
* isin

* Update changelog
2023-03-18 10:09:11 +01:00
5db2faa17d Bugfix/fix border color in fire calculator (#1792)
* Fix border color

* Update changelog
2023-03-17 19:37:36 +01:00
1605fb8d48 Feature/improve language localization for data gathering (#1790)
* Improve locales

* Update changelog
2023-03-16 20:54:34 +01:00
b6a7804a26 Refactoring (#1784) 2023-03-14 10:46:11 +01:00
de31381fd9 Release 1.245.0 (#1787) 2023-03-12 14:33:00 +01:00
0d92b8d8bb Reduce search requests (#1786) 2023-03-12 14:29:22 +01:00
7c6ff776d9 Feature/add search functionality for eod historical data (#1783)
* Add search functionality for EOD_HISTORICAL_DATA

* Update changelog
2023-03-12 13:13:34 +01:00
e37a34ed6c Feature/improve exchange rate service for specific date (#1785)
* Calculate exchange rate indirectly via base currency

* Update changelog
2023-03-12 12:19:13 +01:00
c4d9c00f92 Feature/upgrade ngx device detector to version 5.0.1 (#1782)
* Upgrade ngx-device-detector to version 5.0.1

* Update changelog
2023-03-12 10:14:35 +01:00
3af8be89e3 Feature/improve usability of fire calculator (#1779)
* Improve usability

* Add debounce
* Persist annualInterestRate
* Partially disable date picker

* Update changelog
2023-03-12 09:55:55 +01:00
350 changed files with 8894 additions and 5848 deletions

View File

@ -5,6 +5,270 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.264.0 - 2023-05-01
### Added
- Introduced the allocations by platform chart on the allocations page
### Changed
- Deprecated the use of the environment variable `BASE_CURRENCY`
- Cleaned up initial values from the _X-ray_ section
## 1.263.0 - 2023-04-30
### Changed
- Split the environment variable `DATA_SOURCE_PRIMARY` in `DATA_SOURCE_EXCHANGE_RATES` and `DATA_SOURCE_IMPORT`
### Fixed
- Fixed the exception on the accounts page
## 1.262.0 - 2023-04-29
### Added
- Added the labels to the tabs to increase the usability
- Extended the support of the impersonation mode for local development
### Changed
- Improved the queue jobs implementation by adding / updating historical market data in bulk
- Improved the language localization for German (`de`)
### Fixed
- Improved the holdings table by showing the cash position also when the filter contains the accounts, so that we can see the total allocation for that account
## 1.261.0 - 2023-04-25
### Added
- Introduced a new button to delete all activities from the portfolio activities page
- Added `state` to the `MarketData` database schema to distinguish `CLOSE` and `INTRADAY` in the data gathering
- Added the distance to now to the subscription expiration date in the users table of the admin control panel
## 1.260.0 - 2023-04-23
### Added
- Added `dataSource` as a unique constraint to the `MarketData` database schema
### Fixed
- Removed the unnecessary sort header of the comment column in the historical market data table of the admin control panel
## 1.259.0 - 2023-04-22
### Added
- Added a fallback to historical market data if a data provider does not provide live data
- Added a general health check endpoint
- Added health check endpoints for data providers
### Changed
- Persisted today's market data continuously
### Fixed
- Fixed the alignment of the performance column header in the holdings table
- Removed the unnecessary sort header of the comment column in the activities table
- Fixed the targets in `proxy.conf.json` from `http://localhost:3333` to `http://0.0.0.0:3333` for local development
## 1.258.0 - 2023-04-20
### Added
- Introduced a data source mapping
## 1.257.0 - 2023-04-18
### Added
- Introduced the allocations by ETF provider chart on the allocations page
### Fixed
- Fixed an issue in the global heat map component caused by manipulating an input property
- Fixed an issue with the currency inconsistency in the _EOD Historical Data_ service (convert from `GBX` to `GBp`)
## 1.256.0 - 2023-04-17
### Added
- Added the _Yahoo Finance_ data enhancer for countries, sectors and urls
### Changed
- Enabled the configuration to immediately remove queue jobs on complete
- Refactored the implementation of removing queue jobs
### Fixed
- Fixed the unique job ids of the gather asset profile process
- Fixed the style of the button to fetch the current market price
## 1.255.0 - 2023-04-15
### Added
- Made the system message expandable
### Changed
- Skipped creating queue jobs for asset profiles with `MANUAL` data source not having a scraper configuration
- Reduced the execution interval of the data gathering to every hour
- Upgraded `prisma` from version `4.11.0` to `4.12.0`
### Fixed
- Improved the style of the system message
## 1.254.0 - 2023-04-14
### Changed
- Improved the queue jobs implementation by adding in bulk
- Improved the queue jobs implementation by introducing unique job ids
- Reverted the execution interval of the data gathering from every 12 hours to every 4 hours
## 1.253.0 - 2023-04-14
### Changed
- Reduced the execution interval of the data gathering to every 12 hours
### Fixed
- Fixed the background color of dialogs in dark mode
## 1.252.2 - 2023-04-11
### Changed
- Deprecated the `auth` endpoint of the login with _Security Token_ (`GET`)
## 1.252.1 - 2023-04-10
### Changed
- Changed the slide toggles to checkboxes on the account page
- Changed the slide toggles to checkboxes in the admin control panel
- Decreased the density of the theme
- Migrated the style of various components to `@angular/material` `15` (mdc)
- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6`
- Upgraded `bull` from version `4.10.2` to `4.10.4`
## 1.251.0 - 2023-04-07
### Changed
- Improved the activities import for `csv` files exported by _Interactive Brokers_
- Improved the rendering of the chart ticks (`0.5K``500`)
- Increased the historical market data gathering of currency pairs to 10+ years
- Improved the content of the Frequently Asked Questions (FAQ) page
- Improved the content of the pricing page
- Changed the `auth` endpoint of the login with _Security Token_ from `GET` to `POST`
- Changed the `auth` endpoint of the _Internet Identity_ login provider from `GET` to `POST`
- Migrated the style of the `libs` components to `@angular/material` `15` (mdc)
- `ActivitiesFilterComponent`
- `ActivitiesTableComponent`
- `BenchmarkComponent`
- `HoldingsTableComponent`
- Upgraded `angular` from version `15.1.5` to `15.2.5`
- Upgraded `Nx` from version `15.7.2` to `15.9.2`
## 1.250.0 - 2023-04-02
### Added
- Added support for multiple subscription offers
### Changed
- Improved the portfolio evolution chart (ignore first item)
- Improved the accounts import by handling the platform
### Fixed
- Fixed an issue with more than 50 activities in the activities import (`dryRun`)
## 1.249.0 - 2023-03-27
### Added
- Extended the testimonial section on the landing page
### Changed
- Improved the loading state of the value component on the allocations page
- Improved the value component by always showing the label (also while loading)
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue with the algebraic sign in the value component
## 1.248.0 - 2023-03-25
### Added
- Added a blog post: _Ghostfolio reaches 1000 Stars on GitHub_
- Added a breadcrumb navigation to the blog post pages
### Changed
- Refactored the calculation of the chart
- Hid the platform selector if no platforms are available in the create or update account dialog
- Upgraded `ng-extract-i18n-merge` from version `2.5.0` to `2.6.0`
## 1.247.0 - 2023-03-23
### Added
- Added the asset and asset sub class to the search functionality
- Added the subscription expiration date to the users table of the admin control panel
### Changed
- Updated the URL of the Ghostfolio Slack channel
- Upgraded `prisma` from version `4.10.1` to `4.11.0`
### Fixed
- Fixed the total amount calculation in the portfolio evolution chart
## 1.246.0 - 2023-03-18
### Added
- Added support for asset and asset sub class to the `EOD_HISTORICAL_DATA` data source type
- Added `isin` to the asset profile model
### Changed
- Extended the _Trackinsight_ data enhancer for asset profile data by `isin`
- Improved the language localization for _Gather Data_
### Fixed
- Fixed the border color in the _FIRE_ calculator (dark mode)
## 1.245.0 - 2023-03-12
### Added
- Added the search functionality for the `EOD_HISTORICAL_DATA` data source type
### Changed
- Improved the usability of the _FIRE_ calculator
- Improved the exchange rate service for a specific date used in activities with a manual currency
- Upgraded `ngx-device-detector` from version `3.0.0` to `5.0.1`
## 1.244.0 - 2023-03-09
### Added
@ -166,7 +430,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support to export accounts
- Added suport to import accounts
- Added support to import accounts
### Changed

View File

@ -18,7 +18,13 @@
### Prisma
#### Create schema migration (local)
#### Synchronize schema with database for prototyping
Run `yarn database:push`
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
#### Create schema migration
Run `yarn prisma migrate dev --name added_job_title`

View File

@ -200,7 +200,9 @@ Set the header for each request as follows:
"Authorization": "Bearer eyJh..."
```
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
### Import Activities
@ -272,6 +274,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
## License
© 2023 [Ghostfolio](https://ghostfol.io)
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -2,13 +2,14 @@
export default {
displayName: 'api',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json'
}
},
globals: {},
transform: {
'^.+\\.[tj]s$': 'ts-jest'
'^.+\\.[tj]s$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.spec.json'
}
]
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',

View File

@ -1,4 +1,4 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { AccessController } from './access.controller';

View File

@ -1,4 +1,4 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Access, Prisma } from '@prisma/client';

View File

@ -1,6 +1,6 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -87,10 +87,7 @@ export class AccountController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
): Promise<Accounts> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
await this.impersonationService.validateImpersonationId(impersonationId);
return this.portfolioService.getAccountsWithAggregations({
userId: impersonationUserId || this.request.user.id,
@ -106,10 +103,7 @@ export class AccountController {
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
await this.impersonationService.validateImpersonationId(impersonationId);
const accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations({

View File

@ -1,11 +1,11 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { AccountController } from './account.controller';

View File

@ -1,5 +1,5 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client';

View File

@ -1,5 +1,5 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
GATHER_ASSET_PROFILE_PROCESS,
@ -100,16 +100,21 @@ export class AdminController {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
}
};
})
);
this.dataGatheringService.gatherMax();
}
@ -131,16 +136,21 @@ export class AdminController {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
}
};
})
);
}
@Post('gather/profile-data/:dataSource/:symbol')
@ -161,14 +171,17 @@ export class AdminController {
);
}
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
await this.dataGatheringService.addJobToQueue({
data: {
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
}
});
}
@Post('gather/:dataSource/:symbol')
@ -304,9 +317,10 @@ export class AdminController {
const date = new Date(dateString);
return this.marketDataService.updateMarketData({
data: { ...data, dataSource },
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
where: {
date_symbol: {
dataSource_date_symbol: {
dataSource,
date,
symbol
}

View File

@ -1,12 +1,12 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';

View File

@ -1,10 +1,10 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import {
AdminData,
@ -100,6 +100,7 @@ export class AdminService {
dataSource,
marketDataItemCount,
symbol,
assetClass: 'CASH',
countriesCount: 0,
sectorsCount: 0
};
@ -186,8 +187,11 @@ export class AdminService {
]);
return {
assetProfile,
marketData
marketData,
assetProfile: assetProfile ?? {
symbol,
currency: '-'
}
};
}

View File

@ -1,4 +1,4 @@
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { Module } from '@nestjs/common';
import { QueueController } from './queue.controller';

View File

@ -4,7 +4,7 @@ import {
} from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { JobStatus, Queue } from 'bull';
@Injectable()
@ -23,14 +23,11 @@ export class QueueService {
}: {
status?: JobStatus[];
}) {
const jobs = await this.dataGatheringQueue.getJobs(status);
for (const job of jobs) {
try {
await job.remove();
} catch (error) {
Logger.warn(error, 'QueueService');
}
for (const statusItem of status) {
await this.dataGatheringQueue.clean(
300,
statusItem === 'waiting' ? 'wait' : statusItem
);
}
}
@ -44,18 +41,23 @@ export class QueueService {
const jobs = await this.dataGatheringQueue.getJobs(status);
const jobsWithState = await Promise.all(
jobs.slice(0, limit).map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,
name: job.name,
stacktrace: job.stacktrace,
state: await job.getState(),
timestamp: job.timestamp
};
})
jobs
.filter((job) => {
return job;
})
.slice(0, limit)
.map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,
name: job.name,
stacktrace: job.stacktrace,
state: await job.getState(),
timestamp: job.timestamp
};
})
);
return {

View File

@ -1,4 +1,4 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { Controller } from '@nestjs/common';
@Controller()

View File

@ -1,18 +1,18 @@
import { join } from 'path';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { ConfigurationModule } from '../services/configuration.module';
import { CronService } from '../services/cron.service';
import { DataGatheringModule } from '../services/data-gathering.module';
import { DataProviderModule } from '../services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '../services/exchange-rate-data.module';
import { PrismaModule } from '../services/prisma.module';
import { TwitterBotModule } from '../services/twitter-bot/twitter-bot.module';
import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
@ -24,6 +24,7 @@ import { CacheModule } from './cache/cache.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware';
import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module';
@ -57,6 +58,7 @@ import { UserModule } from './user/user.module';
ExchangeRateModule,
ExchangeRateDataModule,
ExportModule,
HealthModule,
ImportModule,
InfoModule,
LogoModule,

View File

@ -1,7 +1,7 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { AuthDevice, Prisma } from '@prisma/client';

View File

@ -1,5 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import {
@ -33,8 +33,11 @@ export class AuthController {
private readonly webAuthService: WebAuthService
) {}
/**
* @deprecated
*/
@Get('anonymous/:accessToken')
public async accessTokenLogin(
public async accessTokenLoginGet(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
@ -50,6 +53,23 @@ export class AuthController {
}
}
@Post('anonymous')
public async accessTokenLogin(
@Body() body: { accessToken: string }
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateAnonymousLogin(
body.accessToken
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Get('google')
@UseGuards(AuthGuard('google'))
public googleLogin() {
@ -81,13 +101,13 @@ export class AuthController {
}
}
@Get('internet-identity/:principalId')
@Post('internet-identity')
public async internetIdentityLogin(
@Param('principalId') principalId: string
@Body() body: { principalId: string }
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
principalId
body.principalId
);
return { authToken };
} catch {

View File

@ -2,8 +2,8 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';

View File

@ -1,5 +1,5 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

View File

@ -1,4 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';

View File

@ -1,6 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';

View File

@ -1,7 +1,7 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.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/configuration.service';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Inject,

View File

@ -1,10 +1,10 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller';

View File

@ -1,9 +1,9 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
MAX_CHART_ITEMS,
PROPERTY_BENCHMARKS

View File

@ -1,10 +1,10 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { CacheController } from './cache.controller';

View File

@ -1,4 +1,4 @@
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { Module } from '@nestjs/common';
import { ExchangeRateController } from './exchange-rate.controller';

View File

@ -1,4 +1,4 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { Injectable } from '@nestjs/common';
@Injectable()

View File

@ -1,8 +1,8 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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/prisma.module';
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';

View File

@ -1,5 +1,5 @@
import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';

View File

@ -2,7 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common';
@ -87,6 +87,13 @@ export class FrontendMiddleware implements NestMiddleware {
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
title = `Ghostfolio meets Umbrel - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
)
) {
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
title = `Ghostfolio reaches 1000 Stars on GitHub - ${title}`;
}
if (

View File

@ -0,0 +1,44 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import {
Controller,
Get,
HttpException,
Param,
UseInterceptors
} from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { HealthService } from './health.service';
@Controller('health')
export class HealthController {
public constructor(private readonly healthService: HealthService) {}
@Get()
public async getHealth() {}
@Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider(
@Param('dataSource') dataSource: DataSource
) {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const hasResponse = await this.healthService.hasResponseFromDataProvider(
dataSource
);
if (hasResponse !== true) {
throw new HttpException(
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
StatusCodes.SERVICE_UNAVAILABLE
);
}
}
}

View File

@ -0,0 +1,13 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
imports: [ConfigurationModule, DataProviderModule],
providers: [HealthService]
})
export class HealthModule {}

View File

@ -0,0 +1,14 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@Injectable()
export class HealthService {
public constructor(
private readonly dataProviderService: DataProviderService
) {}
public async hasResponseFromDataProvider(aDataSource: DataSource) {
return this.dataProviderService.checkQuote(aDataSource);
}
}

View File

@ -1,6 +1,6 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';

View File

@ -3,12 +3,13 @@ import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PlatformModule } from '@ghostfolio/api/services/platform/platform.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
@ -24,6 +25,7 @@ import { ImportService } from './import.service';
DataProviderModule,
ExchangeRateDataModule,
OrderModule,
PlatformModule,
PortfolioModule,
PrismaModule,
RedisCacheModule,

View File

@ -5,8 +5,9 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PlatformService } from '@ghostfolio/api/services/platform/platform.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import {
@ -14,7 +15,7 @@ import {
OrderWithAccount
} from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
@ -26,6 +27,7 @@ export class ImportService {
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -118,15 +120,18 @@ export class ImportService {
const accountIdMapping: { [oldAccountId: string]: string } = {};
if (!isDryRun && accountsDto?.length) {
const existingAccounts = await this.accountService.accounts({
where: {
id: {
in: accountsDto.map(({ id }) => {
return id;
})
const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({
where: {
id: {
in: accountsDto.map(({ id }) => {
return id;
})
}
}
}
});
}),
this.platformService.get()
]);
for (const account of accountsDto) {
// Check if there is any existing account with the same ID
@ -146,19 +151,24 @@ export class ImportService {
delete account.id;
}
const newAccountObject = {
let accountObject: Prisma.AccountCreateInput = {
...account,
User: { connect: { id: userId } }
};
if (platformId) {
Object.assign(newAccountObject, {
if (
existingPlatforms.some(({ id }) => {
return id === platformId;
})
) {
accountObject = {
...accountObject,
Platform: { connect: { id: platformId } }
});
};
}
const newAccount = await this.accountService.createAccount(
newAccountObject,
accountObject,
userId
);
@ -173,9 +183,10 @@ export class ImportService {
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (activity.type === 'ITEM') {
activity.dataSource = 'MANUAL';
activity.dataSource = DataSource.MANUAL;
} else {
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
}
@ -254,6 +265,7 @@ export class ImportService {
countries: null,
createdAt: undefined,
id: undefined,
isin: null,
name: null,
scraperConfiguration: null,
sectors: null,

View File

@ -1,12 +1,12 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';

View File

@ -1,8 +1,8 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
@ -22,6 +22,7 @@ import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent';
@ -304,19 +305,17 @@ export class InfoService {
return statistics;
}
private async getSubscriptions(): Promise<Subscription[]> {
private async getSubscriptions(): Promise<{
[offer in SubscriptionOffer]: Subscription;
}> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
let subscriptions: Subscription[] = [];
const stripeConfig = (await this.prismaService.property.findUnique({
where: { key: PROPERTY_STRIPE_CONFIG }
})) ?? { value: '{}' };
subscriptions = [JSON.parse(stripeConfig.value)];
return subscriptions;
return JSON.parse(stripeConfig.value);
}
}

View File

@ -1,5 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { LogoController } from './logo.controller';

View File

@ -1,4 +1,4 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';

View File

@ -2,7 +2,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -41,6 +41,23 @@ export class OrderController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete()
@UseGuards(AuthGuard('jwt'))
public async deleteOrders(): Promise<number> {
if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.orderService.deleteOrders({
userId: this.request.user.id
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
@ -79,10 +96,7 @@ export class OrderController {
});
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.orderService.getOrders({

View File

@ -3,13 +3,13 @@ import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { OrderController } from './order.controller';

View File

@ -1,8 +1,8 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
@ -112,14 +112,17 @@ export class OrderService {
};
}
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
await this.dataGatheringService.addJobToQueue({
data: {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
}
});
const isDraft = isAfter(data.date as Date, endOfToday());
@ -178,6 +181,14 @@ export class OrderService {
return order;
}
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
const { count } = await this.prismaService.order.deleteMany({
where
});
return count;
}
public async getOrders({
filters,
includeDrafts = false,

View File

@ -1,8 +1,8 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) {
@ -49,11 +49,9 @@ export const CurrentRateServiceMock = {
getValues: ({
dataGatheringItems,
dateQuery
}: GetValuesParams): Promise<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}> => {
}: GetValuesParams): Promise<GetValuesObject> => {
const values: GetValueObject[] = [];
if (dateQuery.lt) {
for (
let date = resetHours(dateQuery.gte);
@ -85,6 +83,7 @@ export const CurrentRateServiceMock = {
}
}
}
return Promise.resolve({ values, dataProviderInfos: [] });
return Promise.resolve({ values, dataProviderInfos: [], errors: [] });
}
};

View File

@ -1,13 +1,13 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';
jest.mock('@ghostfolio/api/services/market-data.service', () => {
jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
return {
MarketDataService: jest.fn().mockImplementation(() => {
return {
@ -18,7 +18,8 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
createdAt: date,
dataSource: DataSource.YAHOO,
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
marketPrice: 1847.839966
marketPrice: 1847.839966,
state: 'CLOSE'
});
},
getRange: ({
@ -37,6 +38,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
state: 'CLOSE',
symbol: symbols[0]
},
{
@ -45,6 +47,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
state: 'CLOSE',
symbol: symbols[0]
}
]);
@ -54,14 +57,27 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
};
});
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
toCurrency: (value: number) => {
return 1 * value;
}
};
})
};
}
);
jest.mock('@ghostfolio/api/services/property/property.service', () => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
PropertyService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
toCurrency: (value: number) => {
return 1 * value;
}
getByKey: (key: string) => Promise.resolve({})
};
})
};
@ -72,9 +88,18 @@ describe('CurrentRateService', () => {
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let marketDataService: MarketDataService;
let propertyService: PropertyService;
beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null);
propertyService = new PropertyService(null);
dataProviderService = new DataProviderService(
null,
[],
null,
null,
propertyService
);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
@ -104,21 +129,14 @@ describe('CurrentRateService', () => {
},
userCurrency: 'CHF'
})
).toMatchObject<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}>({
).toMatchObject<GetValuesObject>({
dataProviderInfos: [],
errors: [],
values: [
{
date: undefined,
marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
}
]
});

View File

@ -1,13 +1,14 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
import { flatten, isEmpty, uniqBy } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable()
@ -23,10 +24,7 @@ export class CurrentRateService {
dataGatheringItems,
dateQuery,
userCurrency
}: GetValuesParams): Promise<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}> {
}: GetValuesParams): Promise<GetValuesObject> {
const dataProviderInfos: DataProviderInfo[] = [];
const includeToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
@ -34,9 +32,10 @@ export class CurrentRateService {
(!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise<GetValueObject[]>[] = [];
const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date());
if (includeToday) {
const today = resetHours(new Date());
promises.push(
this.dataProviderService
.getQuotes(dataGatheringItems)
@ -51,18 +50,26 @@ export class CurrentRateService {
);
}
result.push({
date: today,
marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency(
dataResultProvider?.[dataGatheringItem.symbol]
?.marketPrice ?? 0,
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency
),
symbol: dataGatheringItem.symbol
});
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
result.push({
date: today,
marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency(
dataResultProvider?.[dataGatheringItem.symbol]
?.marketPrice,
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency
),
symbol: dataGatheringItem.symbol
});
} else {
quoteErrors.push({
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
});
}
}
return result;
})
);
@ -94,10 +101,60 @@ export class CurrentRateService {
})
);
return {
const values = flatten(await Promise.all(promises));
const response: GetValuesObject = {
dataProviderInfos,
values: flatten(await Promise.all(promises))
errors: quoteErrors.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
};
if (!isEmpty(quoteErrors)) {
for (const { symbol } of quoteErrors) {
try {
// If missing quote, fallback to the latest available historical market price
let value: GetValueObject = response.values.find((currentValue) => {
return currentValue.symbol === symbol && isToday(currentValue.date);
});
if (!value) {
value = {
symbol,
date: today,
marketPriceInBaseCurrency: 0
};
response.values.push(value);
}
const [latestValue] = response.values
.filter((currentValue) => {
return (
currentValue.symbol === symbol &&
currentValue.marketPriceInBaseCurrency
);
})
.sort((a, b) => {
if (a.date < b.date) {
return 1;
}
if (a.date > b.date) {
return -1;
}
return 0;
});
value.marketPriceInBaseCurrency =
latestValue.marketPriceInBaseCurrency;
} catch {}
}
}
return response;
}
private containsToday(dates: Date[]): boolean {

View File

@ -0,0 +1,9 @@
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
import { GetValueObject } from './get-value-object.interface';
export interface GetValuesObject {
dataProviderInfos: DataProviderInfo[];
errors: ResponseError['errors'];
values: GetValueObject[];
}

View File

@ -86,7 +86,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 13.100263852242744,
netPerformance: 19.86,
totalInvestment: 0,
value: 19.86
value: 0
});
expect(currentPositions).toEqual({

View File

@ -24,9 +24,10 @@ import {
isSameYear,
max,
min,
set
set,
subDays
} from 'date-fns';
import { first, flatten, isNumber, last, sortBy } from 'lodash';
import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
@ -182,10 +183,10 @@ export class PortfolioCalculator {
return isBefore(parseDate(transactionPoint.date), end);
}) ?? [];
const firstIndex = transactionPointsBeforeEndDate.length;
const currencies: { [symbol: string]: string } = {};
const dates: Date[] = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
const firstIndex = transactionPointsBeforeEndDate.length;
let day = start;
@ -235,87 +236,100 @@ export class PortfolioCalculator {
}
}
const netPerformanceValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
const valuesByDate: {
[date: string]: {
maxTotalInvestmentValue: Big;
totalCurrentValue: Big;
totalInvestmentValue: Big;
totalNetPerformanceValue: Big;
};
} = {};
const investmentValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
const valuesBySymbol: {
[symbol: string]: {
currentValues: { [date: string]: Big };
investmentValues: { [date: string]: Big };
maxInvestmentValues: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big };
};
} = {};
const maxInvestmentValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const totalNetPerformanceValues: { [date: string]: Big } = {};
const totalInvestmentValues: { [date: string]: Big } = {};
const maxTotalInvestmentValues: { [date: string]: Big } = {};
for (const symbol of Object.keys(symbols)) {
const { investmentValues, maxInvestmentValues, netPerformanceValues } =
this.getSymbolMetrics({
end,
marketSymbolMap,
start,
step,
symbol,
isChartMode: true
});
const {
currentValues,
investmentValues,
maxInvestmentValues,
netPerformanceValues
} = this.getSymbolMetrics({
end,
marketSymbolMap,
start,
step,
symbol,
isChartMode: true
});
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
investmentValuesBySymbol[symbol] = investmentValues;
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
valuesBySymbol[symbol] = {
currentValues,
investmentValues,
maxInvestmentValues,
netPerformanceValues
};
}
for (const currentDate of dates) {
const dateString = format(currentDate, DATE_FORMAT);
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
totalNetPerformanceValues[dateString] =
totalNetPerformanceValues[dateString] ?? new Big(0);
for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol];
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
dateString
].add(netPerformanceValuesBySymbol[symbol][dateString]);
}
const currentValue =
symbolValues.currentValues?.[dateString] ?? new Big(0);
const investmentValue =
symbolValues.investmentValues?.[dateString] ?? new Big(0);
const maxInvestmentValue =
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
totalInvestmentValues[dateString] =
totalInvestmentValues[dateString] ?? new Big(0);
maxTotalInvestmentValues[dateString] =
maxTotalInvestmentValues[dateString] ?? new Big(0);
if (investmentValuesBySymbol[symbol]?.[dateString]) {
totalInvestmentValues[dateString] = totalInvestmentValues[
dateString
].add(investmentValuesBySymbol[symbol][dateString]);
}
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
dateString
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
}
valuesByDate[dateString] = {
totalCurrentValue: (
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
totalInvestmentValue: (
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
).add(investmentValue),
maxTotalInvestmentValue: (
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
).add(maxInvestmentValue),
totalNetPerformanceValue: (
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
).add(netPerformanceValue)
};
}
}
return Object.keys(totalNetPerformanceValues).map((date) => {
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
return Object.entries(valuesByDate).map(([date, values]) => {
const {
maxTotalInvestmentValue,
totalCurrentValue,
totalInvestmentValue,
totalNetPerformanceValue
} = values;
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
? 0
: totalNetPerformanceValues[date]
.div(maxTotalInvestmentValues[date])
: totalNetPerformanceValue
.div(maxTotalInvestmentValue)
.mul(100)
.toNumber();
return {
date,
netPerformanceInPercentage,
netPerformance: totalNetPerformanceValues[date].toNumber(),
totalInvestment: totalInvestmentValues[date].toNumber(),
value: totalInvestmentValues[date]
.plus(totalNetPerformanceValues[date])
.toNumber()
netPerformance: totalNetPerformanceValue.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
value: totalCurrentValue.toNumber()
};
});
}
@ -347,7 +361,7 @@ export class PortfolioCalculator {
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = transactionPointsBeforeEndDate.length;
const dates = [];
let dates = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
@ -376,15 +390,37 @@ export class PortfolioCalculator {
dates.push(resetHours(end));
const { dataProviderInfos, values: marketSymbols } =
await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
// Add dates of last week for fallback
dates.push(subDays(resetHours(new Date()), 7));
dates.push(subDays(resetHours(new Date()), 6));
dates.push(subDays(resetHours(new Date()), 5));
dates.push(subDays(resetHours(new Date()), 4));
dates.push(subDays(resetHours(new Date()), 3));
dates.push(subDays(resetHours(new Date()), 2));
dates.push(subDays(resetHours(new Date()), 1));
dates.push(resetHours(new Date()));
dates = uniq(
dates.map((date) => {
return date.getTime();
})
).map((timestamp) => {
return new Date(timestamp);
});
dates.sort((a, b) => a.getTime() - b.getTime());
const {
dataProviderInfos,
errors: currentRateErrors,
values: marketSymbols
} = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
this.dataProviderInfos = dataProviderInfos;
@ -459,7 +495,13 @@ export class PortfolioCalculator {
transactionCount: item.transactionCount
});
if (hasErrors && item.investment.gt(0)) {
if (
(hasErrors ||
currentRateErrors.find(({ dataSource, symbol }) => {
return dataSource === item.dataSource && symbol === item.symbol;
})) &&
item.investment.gt(0)
) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
}
}
@ -709,7 +751,7 @@ export class PortfolioCalculator {
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
`Missing historical market data for symbol ${currentPosition.symbol}`,
'PortfolioCalculator'
);
hasErrors = true;
@ -906,12 +948,16 @@ export class PortfolioCalculator {
if (orders.length <= 0) {
return {
currentValues: {},
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
hasErrors: false,
initialValue: new Big(0),
investmentValues: {},
maxInvestmentValues: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
netPerformanceValues: {}
};
}
@ -946,6 +992,7 @@ export class PortfolioCalculator {
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let investmentAtStartDate: Big;
const currentValues: { [date: string]: Big } = {};
const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
@ -1164,6 +1211,7 @@ export class PortfolioCalculator {
}
if (isChartMode && i > indexOfStartOrder) {
currentValues[order.date] = valueOfInvestment;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
@ -1261,15 +1309,16 @@ export class PortfolioCalculator {
}
return {
initialValue,
currentValues,
grossPerformancePercentage,
initialValue,
investmentValues,
maxInvestmentValues,
netPerformancePercentage,
netPerformanceValues,
grossPerformance: totalGrossPerformance,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
grossPerformance: totalGrossPerformance
netPerformance: totalNetPerformance
};
}

View File

@ -8,8 +8,8 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
PortfolioDetails,
@ -91,6 +91,7 @@ export class PortfolioController {
filteredValueInPercentage,
hasErrors,
holdings,
platforms,
summary,
totalValueInBaseCurrency
} = await this.portfolioService.getDetails({
@ -136,9 +137,12 @@ export class PortfolioController {
portfolioPosition.value / totalValue;
}
for (const [name, { current, original }] of Object.entries(accounts)) {
accounts[name].current = current / totalValue;
accounts[name].original = original / totalInvestment;
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
accounts[name].valueInPercentage = valueInBaseCurrency / totalValue;
}
for (const [name, { valueInBaseCurrency }] of Object.entries(platforms)) {
platforms[name].valueInPercentage = valueInBaseCurrency / totalValue;
}
}
@ -182,6 +186,7 @@ export class PortfolioController {
filteredValueInPercentage,
hasError,
holdings,
platforms,
totalValueInBaseCurrency,
summary: portfolioSummary
};

View File

@ -3,14 +3,14 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service';

View File

@ -7,18 +7,15 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
EMERGENCY_FUND_TAG_ID,
MAX_CHART_ITEMS,
@ -37,8 +34,7 @@ import {
PortfolioSummary,
Position,
TimelinePosition,
UserSettings,
UserWithSettings
UserSettings
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
@ -47,7 +43,8 @@ import type {
GroupBy,
Market,
OrderWithAccount,
RequestWithUser
RequestWithUser,
UserWithSettings
} from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -149,7 +146,8 @@ export class PortfolioService {
}
}
const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0;
const valueInBaseCurrency =
details.accounts[account.id]?.valueInBaseCurrency ?? 0;
const result = {
...account,
@ -462,10 +460,18 @@ export class PortfolioService {
});
const holdings: PortfolioDetails['holdings'] = {};
const totalInvestmentInBaseCurrency = currentPositions.totalInvestment.plus(
const totalValueInBaseCurrency = currentPositions.currentValue.plus(
cashDetails.balanceInBaseCurrency
);
let filteredValueInBaseCurrency = currentPositions.currentValue;
const isFilteredByAccount =
filters?.some((filter) => {
return filter.type === 'ACCOUNT';
}) ?? false;
let filteredValueInBaseCurrency = isFilteredByAccount
? totalValueInBaseCurrency
: currentPositions.currentValue;
if (
filters?.length === 0 ||
@ -484,13 +490,10 @@ export class PortfolioService {
symbol: position.symbol
};
});
const symbols = currentPositions.positions.map(
(position) => position.symbol
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -564,12 +567,11 @@ export class PortfolioService {
};
}
if (
filters?.length === 0 ||
(filters?.length === 1 &&
filters[0].type === 'ASSET_CLASS' &&
filters[0].id === 'CASH')
) {
const isFilteredByCash = filters?.some((filter) => {
return filter.type === 'ASSET_CLASS' && filter.id === 'CASH';
});
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
const cashPositions = await this.getCashPositions({
cashDetails,
userCurrency,
@ -581,7 +583,7 @@ export class PortfolioService {
}
}
const accounts = await this.getValueOfAccounts({
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
filters,
orders,
portfolioItemsNow,
@ -595,7 +597,7 @@ export class PortfolioService {
filters[0].id === EMERGENCY_FUND_TAG_ID &&
filters[0].type === 'TAG'
) {
const cashPositions = await this.getCashPositions({
const emergencyFundCashPositions = await this.getCashPositions({
cashDetails,
userCurrency,
value: filteredValueInBaseCurrency
@ -614,13 +616,12 @@ export class PortfolioService {
accounts[UNKNOWN_KEY] = {
balance: 0,
currency: userCurrency,
current: emergencyFundInCash,
name: UNKNOWN_KEY,
original: emergencyFundInCash
valueInBaseCurrency: emergencyFundInCash
};
holdings[userCurrency] = {
...cashPositions[userCurrency],
...emergencyFundCashPositions[userCurrency],
investment: emergencyFundInCash,
value: emergencyFundInCash
};
@ -640,6 +641,7 @@ export class PortfolioService {
return {
accounts,
holdings,
platforms,
summary,
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
filteredValueInPercentage: summary.netWorth
@ -979,11 +981,13 @@ export class PortfolioService {
};
});
const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -1168,7 +1172,7 @@ export class PortfolioService {
portfolioItemsNow[position.symbol] = position;
}
const accounts = await this.getValueOfAccounts({
const { accounts } = await this.getValueOfAccountsAndPlatforms({
orders,
portfolioItemsNow,
userCurrency,
@ -1179,10 +1183,6 @@ export class PortfolioService {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
[
new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService,
accounts
),
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
accounts
@ -1196,18 +1196,10 @@ export class PortfolioService {
),
currencyClusterRisk: await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
this.exchangeRateDataService,
positions
),
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
positions
),
new CurrencyClusterRiskInitialInvestment(
this.exchangeRateDataService,
positions
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
positions
@ -1700,7 +1692,7 @@ export class PortfolioService {
};
}
private async getValueOfAccounts({
private async getValueOfAccountsAndPlatforms({
filters = [],
orders,
portfolioItemsNow,
@ -1724,6 +1716,7 @@ export class PortfolioService {
});
const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {};
let currentAccounts: (Account & {
Order?: Order[];
@ -1768,63 +1761,81 @@ export class PortfolioService {
accounts[account.id] = {
balance: account.balance,
currency: account.currency,
current: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
name: account.name,
original: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
)
};
if (platforms[account.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
platforms[account.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency +=
this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
} else {
platforms[account.Platform?.id || UNKNOWN_KEY] = {
balance: account.balance,
currency: account.currency,
name: account.Platform?.name,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
)
};
}
for (const order of ordersByAccount) {
let currentValueOfSymbolInBaseCurrency =
order.quantity *
(portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ??
order.unitPrice ??
0);
let originalValueOfSymbolInBaseCurrency =
this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.SymbolProfile.currency,
userCurrency
);
if (order.type === 'SELL') {
currentValueOfSymbolInBaseCurrency *= -1;
originalValueOfSymbolInBaseCurrency *= -1;
}
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.id || UNKNOWN_KEY].current +=
if (accounts[order.Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
accounts[order.Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
currentValueOfSymbolInBaseCurrency;
accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbolInBaseCurrency;
} else {
accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
current: currentValueOfSymbolInBaseCurrency,
name: account.name,
original: originalValueOfSymbolInBaseCurrency
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}
if (
platforms[order.Account?.Platform?.id || UNKNOWN_KEY]
?.valueInBaseCurrency
) {
platforms[
order.Account?.Platform?.id || UNKNOWN_KEY
].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency;
} else {
platforms[order.Account?.Platform?.id || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
name: account.Platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}
}
}
return accounts;
return { accounts, platforms };
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
aUserId
);
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}

View File

@ -1,5 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store';

View File

@ -1,4 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';

View File

@ -1,4 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_LANGUAGE_CODE,

View File

@ -1,5 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';

View File

@ -1,12 +1,12 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { UserWithSettings } from '@ghostfolio/common/interfaces';
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { UserWithSettings } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns';
@ -123,7 +123,9 @@ export class SubscriptionService {
}
}
public getSubscription(aSubscriptions: Subscription[]) {
public getSubscription(
aSubscriptions: Subscription[]
): UserWithSettings['subscription'] {
if (aSubscriptions.length > 0) {
const latestSubscription = aSubscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
@ -131,12 +133,14 @@ export class SubscriptionService {
return {
expiresAt: latestSubscription.expiresAt,
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
return {
offer: 'default',
type: SubscriptionType.Basic
};
}

View File

@ -1,6 +1,8 @@
import { DataSource } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface LookupItem {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
currency: string;
dataSource: DataSource;
name: string;

View File

@ -1,15 +1,18 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -21,7 +24,10 @@ import { SymbolService } from './symbol.service';
@Controller('symbol')
export class SymbolController {
public constructor(private readonly symbolService: SymbolService) {}
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolService: SymbolService
) {}
/**
* Must be before /:symbol
@ -33,7 +39,10 @@ export class SymbolController {
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try {
return this.symbolService.lookup(query.toLowerCase());
return this.symbolService.lookup({
query: query.toLowerCase(),
user: this.request.user
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -1,7 +1,7 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { SymbolController } from './symbol.controller';

View File

@ -3,9 +3,10 @@ import {
IDataGatheringItem,
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns';
@ -79,15 +80,24 @@ export class SymbolService {
};
}
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
public async lookup({
query,
user
}: {
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] };
if (!aQuery) {
if (!query) {
return results;
}
try {
const { items } = await this.dataProviderService.search(aQuery);
const { items } = await this.dataProviderService.search({
query,
user
});
results.items = items;
return results;
} catch (error) {

View File

@ -3,17 +3,20 @@ import type {
DateRange,
ViewMode
} from '@ghostfolio/common/types';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsISO8601,
IsIn,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class UpdateUserSettingDto {
@IsNumber()
@IsOptional()
annualInterestRate?: number;
@IsOptional()
@IsString()
baseCurrency?: string;

View File

@ -1,6 +1,6 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';

View File

@ -1,19 +1,17 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
import {
User as IUser,
UserSettings,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client';
import { sortBy } from 'lodash';
@ -199,6 +197,10 @@ export class UserService {
}
}
if (!environment.production && role === 'ADMIN') {
currentPermissions.push(permissions.impersonateAllUsers);
}
user.Account = sortBy(user.Account, (account) => {
return account.name;
});

View File

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { decodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
@ -7,8 +8,6 @@ import {
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ConfigurationService } from '../services/configuration.service';
@Injectable()
export class TransformDataSourceInRequestInterceptor<T>
implements NestInterceptor<T, any>

View File

@ -1,4 +1,5 @@
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { encodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
@ -10,8 +11,6 @@ import { DataSource } from '@prisma/client';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ConfigurationService } from '../services/configuration.service';
@Injectable()
export class TransformDataSourceInResponseInterceptor<T>
implements NestInterceptor<T, any>

View File

@ -32,12 +32,23 @@ async function bootstrap() {
// Support 10mb csv/json files for importing activities
app.use(bodyParser.json({ limit: '10mb' }));
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;
await app.listen(PORT, HOST, () => {
logLogo();
Logger.log(`Listening at http://${HOST}:${PORT}`);
Logger.log('');
if (BASE_CURRENCY) {
Logger.warn(
`The environment variable "BASE_CURRENCY" is deprecated and will be removed in Ghostfolio 2.0.`
);
Logger.warn(
'Please use the currency converter in the activity dialog instead.'
);
}
});
}

View File

@ -1,8 +1,7 @@
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
export class Order {
private account: Account;
private currency: string;

View File

@ -1,5 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';

View File

@ -1,5 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import {
PortfolioDetails,
PortfolioPosition,
@ -14,7 +14,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
private accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
name: 'Current Investment'
name: 'Investment'
});
}
@ -28,7 +28,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[accountId] = {
name: account.name,
investment: account.current
investment: account.valueInBaseCurrency
};
}

View File

@ -1,88 +0,0 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import {
PortfolioDetails,
PortfolioPosition,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
name: 'Initial Investment'
});
}
public evaluate(ruleSettings?: Settings) {
const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number;
};
} = {};
for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[accountId] = {
name: account.name,
investment: account.original
};
}
let maxItem;
let totalInvestment = 0;
for (const account of Object.values(accounts)) {
if (!maxItem) {
maxItem = account;
}
// Calculate total investment
totalInvestment += account.investment;
// Find maximum
if (account.investment > maxItem?.investment) {
maxItem = account;
}
}
const maxInvestmentRatio = maxItem.investment / totalInvestment;
if (maxInvestmentRatio > ruleSettings.threshold) {
return {
evaluation: `Over ${
ruleSettings.threshold * 100
}% of your initial investment is at ${maxItem.name} (${(
maxInvestmentRatio * 100
).toPrecision(3)}%)`,
value: false
};
}
return {
evaluation: `The major part of your initial investment is at ${
maxItem.name
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100
}%`,
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
isActive: boolean;
threshold: number;
}

View File

@ -1,5 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,5 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
@ -10,7 +10,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
private positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Current Investment: Base Currency'
name: 'Investment: Base Currency'
});
}

View File

@ -1,71 +0,0 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Initial Investment: Base Currency'
});
}
public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.positions,
'currency',
ruleSettings.baseCurrency
);
let maxItem = positionsGroupedByCurrency[0];
let totalInvestment = 0;
positionsGroupedByCurrency.forEach((groupItem) => {
// Calculate total investment
totalInvestment += groupItem.investment;
// Find maximum
if (groupItem.investment > maxItem.investment) {
maxItem = groupItem;
}
});
const baseCurrencyItem = positionsGroupedByCurrency.find((item) => {
return item.groupKey === ruleSettings.baseCurrency;
});
const baseCurrencyInvestmentRatio =
baseCurrencyItem?.investment / totalInvestment || 0;
if (maxItem.groupKey !== ruleSettings.baseCurrency) {
return {
evaluation: `The major part of your initial investment is not in your base currency (${(
baseCurrencyInvestmentRatio * 100
).toPrecision(3)}% in ${ruleSettings.baseCurrency})`,
value: false
};
}
return {
evaluation: `The major part of your initial investment is in your base currency (${(
baseCurrencyInvestmentRatio * 100
).toPrecision(3)}% in ${ruleSettings.baseCurrency})`,
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
}

View File

@ -1,5 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
@ -10,7 +10,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Current Investment'
name: 'Investment'
});
}

View File

@ -1,72 +0,0 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Initial Investment'
});
}
public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.positions,
'currency',
ruleSettings.baseCurrency
);
let maxItem = positionsGroupedByCurrency[0];
let totalInvestment = 0;
positionsGroupedByCurrency.forEach((groupItem) => {
// Calculate total investment
totalInvestment += groupItem.investment;
// Find maximum
if (groupItem.investment > maxItem.investment) {
maxItem = groupItem;
}
});
const maxInvestmentRatio = maxItem.investment / totalInvestment;
if (maxInvestmentRatio > ruleSettings.threshold) {
return {
evaluation: `Over ${
ruleSettings.threshold * 100
}% of your initial investment is in ${maxItem.groupKey} (${(
maxInvestmentRatio * 100
).toPrecision(3)}%)`,
value: false
};
}
return {
evaluation: `The major part of your initial investment is in ${
maxItem.groupKey
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100
}%`,
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
threshold: number;
}

View File

@ -1,5 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
@ -11,7 +11,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
private fees: number
) {
super(exchangeRateDataService, {
name: 'Initial Investment'
name: 'Investment'
});
}

View File

@ -1,4 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Module } from '@nestjs/common';
@Module({

View File

@ -1,9 +1,8 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { Environment } from './interfaces/environment.interface';
@Injectable()
export class ConfigurationService {
private readonly environmentConfiguration: Environment;
@ -17,7 +16,8 @@ export class ConfigurationService {
default: 'USD'
}),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
}),

View File

@ -5,8 +5,8 @@ import {
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service';
import { DataGatheringService } from './data-gathering/data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service';
import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable()
@ -19,8 +19,8 @@ export class CronService {
private readonly twitterBotService: TwitterBotService
) {}
@Cron(CronExpression.EVERY_4_HOURS)
public async runEveryFourHours() {
@Cron(CronExpression.EVERY_HOUR)
public async runEveryHour() {
await this.dataGatheringService.gather7Days();
}
@ -38,15 +38,20 @@ export class CronService {
public async runEverySundayAtTwelvePm() {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
}
};
})
);
}
}

View File

@ -1,17 +1,17 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/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 { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import ms from 'ms';
import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { MarketDataModule } from './market-data.module';
import { SymbolProfileModule } from './symbol-profile.module';
@Module({
imports: [

View File

@ -1,12 +1,16 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Job } from 'bull';
import {
format,
@ -18,9 +22,6 @@ import {
} from 'date-fns';
import { DataGatheringService } from './data-gathering.service';
import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@Injectable()
@Processor(DATA_GATHERING_QUEUE)
@ -28,10 +29,10 @@ export class DataGatheringProcessor {
public constructor(
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService
private readonly marketDataService: MarketDataService
) {}
@Process(GATHER_ASSET_PROFILE_PROCESS)
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
public async gatherAssetProfile(job: Job<UniqueAsset>) {
try {
await this.dataGatheringService.gatherAssetProfiles([job.data]);
@ -45,18 +46,27 @@ export class DataGatheringProcessor {
}
}
@Process(GATHER_HISTORICAL_MARKET_DATA_PROCESS)
@Process({ concurrency: 1, name: GATHER_HISTORICAL_MARKET_DATA_PROCESS })
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
try {
const { dataSource, date, symbol } = job.data;
let currentDate = parseISO(<string>(<unknown>date));
Logger.log(
`Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format(
currentDate,
DATE_FORMAT
)}`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
);
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
parseISO(<string>(<unknown>date)),
currentDate,
new Date()
);
let currentDate = parseISO(<string>(<unknown>date));
const data: Prisma.MarketDataUpdateInput[] = [];
let lastMarketPrice: number;
while (
@ -82,23 +92,13 @@ export class DataGatheringProcessor {
}
if (lastMarketPrice) {
try {
await this.prismaService.marketData.create({
data: {
dataSource,
symbol,
date: new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate),
0
)
),
marketPrice: lastMarketPrice
}
});
} catch {}
data.push({
dataSource,
symbol,
date: getStartOfUtcDate(currentDate),
marketPrice: lastMarketPrice,
state: 'CLOSE'
});
}
// Count month one up for iteration
@ -112,8 +112,13 @@ export class DataGatheringProcessor {
);
}
await this.marketDataService.updateMany({ data });
Logger.log(
`Historical market data gathering has been completed for ${symbol} (${dataSource}).`,
`Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format(
currentDate,
DATE_FORMAT
)}`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
);
} catch (error) {

View File

@ -1,9 +1,14 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATA_GATHERING_QUEUE,
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
QUEUE_JOB_STATUS_LIST
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
@ -11,14 +16,8 @@ import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { JobOptions, Queue } from 'bull';
import { format, subDays } from 'date-fns';
import { DataProviderService } from './data-provider/data-provider.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { MarketDataService } from './market-data.service';
import { PrismaService } from './prisma.service';
import { format, min, subDays, subYears } from 'date-fns';
import { isEmpty } from 'lodash';
@Injectable()
export class DataGatheringService {
@ -34,17 +33,22 @@ export class DataGatheringService {
private readonly symbolProfileService: SymbolProfileService
) {}
public async addJobToQueue(name: string, data: any, options?: JobOptions) {
const hasJob = await this.hasJob(name, data);
public async addJobToQueue({
data,
name,
opts
}: {
data: any;
name: string;
opts?: JobOptions;
}) {
return this.dataGatheringQueue.add(name, data, opts);
}
if (hasJob) {
Logger.log(
`Job ${name} with data ${JSON.stringify(data)} already exists.`,
'DataGatheringService'
);
} else {
return this.dataGatheringQueue.add(name, data, options);
}
public async addJobsToQueue(
jobs: { data: any; name: string; opts?: JobOptions }[]
) {
return this.dataGatheringQueue.addBulk(jobs);
}
public async gather7Days() {
@ -97,7 +101,7 @@ export class DataGatheringService {
symbol
},
update: { marketPrice },
where: { date_symbol: { date, symbol } }
where: { dataSource_date_symbol: { dataSource, date, symbol } }
});
}
} catch (error) {
@ -119,12 +123,9 @@ export class DataGatheringService {
const assetProfiles = await this.dataProviderService.getAssetProfiles(
uniqueAssets
);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(
uniqueAssets.map(({ symbol }) => {
return symbol;
})
);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
uniqueAssets
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -152,10 +153,11 @@ export class DataGatheringService {
countries,
currency,
dataSource,
isin,
name,
sectors,
url
} = assetProfiles[symbol];
} = assetProfile;
try {
await this.prismaService.symbolProfile.upsert({
@ -165,6 +167,7 @@ export class DataGatheringService {
countries,
currency,
dataSource,
isin,
name,
sectors,
symbol,
@ -175,6 +178,7 @@ export class DataGatheringService {
assetSubClass,
countries,
currency,
isin,
name,
sectors,
url
@ -206,59 +210,22 @@ export class DataGatheringService {
}
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
await this.addJobToQueue(
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
{
dataSource,
date,
symbol
},
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
);
}
}
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate =
(
await this.prismaService.order.findFirst({
orderBy: [{ date: 'asc' }]
})
)?.date ?? new Date();
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
await this.addJobsToQueue(
aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
data: {
dataSource,
date,
symbol
},
scraperConfiguration: true,
symbol: true
}
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}`
}
};
})
).map((symbolProfile) => {
return {
...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate
};
});
return [...currencyPairsToGather, ...symbolProfilesToGather];
);
}
public async getUniqueAssets(): Promise<UniqueAsset[]> {
@ -295,13 +262,14 @@ export class DataGatheringService {
// Only consider symbols with incomplete market data for the last
// 7 days
const symbolsNotToGather = (
const symbolsWithCompleteMarketData = (
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
orderBy: [{ symbol: 'asc' }],
where: {
date: { gt: startDate }
date: { gt: startDate },
state: 'CLOSE'
}
})
)
@ -313,8 +281,14 @@ export class DataGatheringService {
});
const symbolProfilesToGather = symbolProfiles
.filter(({ symbol }) => {
return !symbolsNotToGather.includes(symbol);
.filter(({ dataSource, scraperConfiguration, symbol }) => {
const manualDataSourceWithScraperConfiguration =
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return (
!symbolsWithCompleteMarketData.includes(symbol) &&
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
);
})
.map((symbolProfile) => {
return {
@ -326,7 +300,7 @@ export class DataGatheringService {
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.filter(({ symbol }) => {
return !symbolsNotToGather.includes(symbol);
return !symbolsWithCompleteMarketData.includes(symbol);
})
.map(({ dataSource, symbol }) => {
return {
@ -339,17 +313,56 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather];
}
private async hasJob(name: string, data: any) {
const jobs = await this.dataGatheringQueue.getJobs(
QUEUE_JOB_STATUS_LIST.filter((status) => {
return status !== 'completed';
})
);
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate =
(
await this.prismaService.order.findFirst({
orderBy: [{ date: 'asc' }]
})
)?.date ?? new Date();
return jobs.some((job) => {
return (
job.name === name && JSON.stringify(job.data) === JSON.stringify(data)
);
});
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: min([startDate, subYears(new Date(), 10)])
};
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
})
)
.filter((symbolProfile) => {
const manualDataSourceWithScraperConfiguration =
symbolProfile.dataSource === 'MANUAL' &&
!isEmpty(symbolProfile.scraperConfiguration);
return (
symbolProfile.dataSource !== 'MANUAL' ||
manualDataSourceWithScraperConfiguration
);
})
.map((symbolProfile) => {
return {
...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate
};
});
return [...currencyPairsToGather, ...symbolProfilesToGather];
}
}

View File

@ -1,5 +1,5 @@
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/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
@ -7,7 +7,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { format, isAfter, isBefore, parse } from 'date-fns';
@ -33,7 +33,8 @@ export class AlphaVantageService implements DataProviderInterface {
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
dataSource: this.getName(),
symbol: aSymbol
};
}
@ -109,6 +110,10 @@ export class AlphaVantageService implements DataProviderInterface {
return {};
}
public getTestSymbol() {
return undefined;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aQuery);

View File

@ -1,5 +1,5 @@
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/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
@ -160,13 +160,13 @@ export class CoinGeckoService implements DataProviderInterface {
return results;
}
public getTestSymbol() {
return 'bitcoin';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
if (aQuery.length <= 2) {
return { items };
}
try {
const get = bent(
`${this.URL}/search?query=${aQuery}`,
@ -180,6 +180,8 @@ export class CoinGeckoService implements DataProviderInterface {
return {
name,
symbol,
assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency,
dataSource: this.getName()
};

View File

@ -1,15 +1,27 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { Module } from '@nestjs/common';
@Module({
exports: ['DataEnhancers', TrackinsightDataEnhancerService],
exports: [
'DataEnhancers',
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService
],
imports: [ConfigurationModule, CryptocurrencyModule],
providers: [
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService,
{
inject: [TrackinsightDataEnhancerService],
inject: [
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService
],
provide: 'DataEnhancers',
useFactory: (trackinsight) => [trackinsight]
},
TrackinsightDataEnhancerService
useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance]
}
]
})
export class DataEnhancerModule {}

View File

@ -1,13 +1,15 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import bent from 'bent';
const getJSON = bent('json');
@Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com/holdings';
private static baseUrl = 'https://data.trackinsight.com';
private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = {
'Russian Federation': 'Russia'
@ -32,17 +34,29 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response;
}
const result = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
const profile = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json`
).catch(() => {
return {};
});
const isin = profile.isin?.split(';')?.[0];
if (isin) {
response.isin = isin;
}
const holdings = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
).catch(() => {
return getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${
symbol.split('.')[0]
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
symbol.split('.')?.[0]
}.json`
);
});
if (result.weight < 0.95) {
if (holdings?.weight < 0.95) {
// Skip if data is inaccurate
return response;
}
@ -52,7 +66,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.countries as unknown as Country[]).length === 0
) {
response.countries = [];
for (const [name, value] of Object.entries<any>(result.countries)) {
for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {}
)) {
let countryCode: string;
for (const [key, country] of Object.entries<any>(
@ -80,7 +96,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.sectors as unknown as Sector[]).length === 0
) {
response.sectors = [];
for (const [name, value] of Object.entries<any>(result.sectors)) {
for (const [name, value] of Object.entries<any>(
holdings?.sectors ?? {}
)) {
response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight

View File

@ -1,7 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceService } from './yahoo-finance.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
jest.mock(
'@ghostfolio/api/services/cryptocurrency/cryptocurrency.service',
@ -25,16 +25,16 @@ jest.mock(
}
);
describe('YahooFinanceService', () => {
describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceService: YahooFinanceService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService();
yahooFinanceService = new YahooFinanceService(
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService
);
@ -42,25 +42,37 @@ describe('YahooFinanceService', () => {
it('convertFromYahooFinanceSymbol', async () => {
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('BRK-B')
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
'BRK-B'
)
).toEqual('BRK-B');
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('BTC-USD')
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
'BTC-USD'
)
).toEqual('BTCUSD');
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('EURUSD=X')
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
'EURUSD=X'
)
).toEqual('EURUSD');
});
it('convertToYahooFinanceSymbol', async () => {
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('BTCUSD')
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
'BTCUSD'
)
).toEqual('BTC-USD');
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD')
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
'DOGEUSD'
)
).toEqual('DOGE-USD');
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF')
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
'USDCHF'
)
).toEqual('USDCHF=X');
});
});

View File

@ -0,0 +1,325 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import { countries } from 'countries-list';
import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
);
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
symbol = `${this.baseCurrency}${symbol}`;
}
return symbol.replace('=X', '');
}
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF -> USDCHF=X
* Cryptocurrency: BTCUSD -> BTC-USD
* DOGEUSD -> DOGE-USD
*/
public convertToYahooFinanceSymbol(aSymbol: string) {
if (
aSymbol.includes(this.baseCurrency) &&
aSymbol.length > this.baseCurrency.length
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
)
) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
)
) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace(
new RegExp(`-?${this.baseCurrency}$`),
`-${this.baseCurrency}`
);
}
}
return aSymbol;
}
public async enhance({
response,
symbol
}: {
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
if (response.dataSource !== 'YAHOO' && !response.isin) {
return response;
}
try {
let yahooSymbol: string;
if (response.dataSource === 'YAHOO') {
yahooSymbol = symbol;
} else {
const { quotes } = await yahooFinance.search(response.isin);
yahooSymbol = quotes[0].symbol;
}
const { countries, sectors, url } = await this.getAssetProfile(
yahooSymbol
);
if (countries) {
response.countries = countries;
}
if (sectors) {
response.sectors = sectors;
}
if (url) {
response.url = url;
}
} catch (error) {
Logger.error(error, 'YahooFinanceDataEnhancerService');
}
return response;
}
public formatName({
longName,
quoteType,
shortName,
symbol
}: {
longName: Price['longName'];
quoteType: Price['quoteType'];
shortName: Price['shortName'];
symbol: Price['symbol'];
}) {
let name = longName;
if (name) {
name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares V PLC - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');
name = name.replace('VanEck ETFs N.V. - ', '');
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
name = name.replace('Vanguard Funds Public Limited Company - ', '');
name = name.replace('Vanguard Index Funds - ', '');
name = name.replace('Xtrackers (IE) Plc - ', '');
}
if (quoteType === 'FUTURE') {
// "Gold Jun 22" -> "Gold"
name = shortName?.slice(0, -7);
}
return name || shortName || symbol;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {};
try {
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
const assetProfile = await yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile', 'topHoldings']
});
const { assetClass, assetSubClass } = this.parseAssetClass({
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName
});
response.assetClass = assetClass;
response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency;
response.dataSource = this.getName();
response.name = this.formatName({
longName: assetProfile.price.longName,
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol
});
response.symbol = aSymbol;
if (assetSubClass === AssetSubClass.MUTUALFUND) {
response.sectors = [];
for (const sectorWeighting of assetProfile.topHoldings
?.sectorWeightings ?? []) {
for (const [sector, weight] of Object.entries(sectorWeighting)) {
response.sectors.push({ weight, name: this.parseSector(sector) });
}
}
} else if (
assetSubClass === AssetSubClass.STOCK &&
assetProfile.summaryProfile?.country
) {
// Add country if asset is stock and country available
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === assetProfile.summaryProfile?.country;
});
if (code) {
response.countries = [{ code, weight: 1 }];
}
} catch {}
if (assetProfile.summaryProfile?.sector) {
response.sectors = [
{ name: assetProfile.summaryProfile?.sector, weight: 1 }
];
}
}
const url = assetProfile.summaryProfile?.website;
if (url) {
response.url = url;
}
} catch (error) {
Logger.error(error, 'YahooFinanceService');
}
return response;
}
public getName() {
return DataSource.YAHOO;
}
public parseAssetClass({
quoteType,
shortName
}: {
quoteType: string;
shortName: string;
}): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (quoteType?.toLowerCase()) {
case 'cryptocurrency':
assetClass = AssetClass.CASH;
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break;
case 'equity':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'etf':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
case 'future':
assetClass = AssetClass.COMMODITY;
assetSubClass = AssetSubClass.COMMODITY;
if (
shortName?.toLowerCase()?.startsWith('gold') ||
shortName?.toLowerCase()?.startsWith('palladium') ||
shortName?.toLowerCase()?.startsWith('platinum') ||
shortName?.toLowerCase()?.startsWith('silver')
) {
assetSubClass = AssetSubClass.PRECIOUS_METAL;
}
break;
case 'mutualfund':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.MUTUALFUND;
break;
}
return { assetClass, assetSubClass };
}
private parseSector(aString: string): string {
let sector = UNKNOWN_KEY;
switch (aString) {
case 'basic_materials':
sector = 'Basic Materials';
break;
case 'communication_services':
sector = 'Communication Services';
break;
case 'consumer_cyclical':
sector = 'Consumer Cyclical';
break;
case 'consumer_defensive':
sector = 'Consumer Staples';
break;
case 'energy':
sector = 'Energy';
break;
case 'financial_services':
sector = 'Financial Services';
break;
case 'healthcare':
sector = 'Healthcare';
break;
case 'industrials':
sector = 'Industrials';
break;
case 'realestate':
sector = 'Real Estate';
break;
case 'technology':
sector = 'Technology';
break;
case 'utilities':
sector = 'Utilities';
break;
}
return sector;
}
}

View File

@ -1,4 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
@ -7,17 +7,24 @@ import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/goog
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { DataEnhancerModule } from './data-enhancer/data-enhancer.module';
import { YahooFinanceDataEnhancerService } from './data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderService } from './data-provider.service';
@Module({
imports: [
ConfigurationModule,
CryptocurrencyModule,
DataEnhancerModule,
MarketDataModule,
PrismaModule,
PropertyModule,
SymbolProfileModule
],
providers: [
@ -57,7 +64,8 @@ import { DataProviderService } from './data-provider.service';
rapidApiService,
yahooFinanceService
]
}
},
YahooFinanceDataEnhancerService
],
exports: [DataProviderService, YahooFinanceService]
})

View File

@ -1,27 +1,108 @@
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/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns';
import { groupBy, isEmpty } from 'lodash';
import { groupBy, isEmpty, isNumber } from 'lodash';
@Injectable()
export class DataProviderService {
private dataProviderMapping: { [dataProviderName: string]: string };
public constructor(
private readonly configurationService: ConfigurationService,
@Inject('DataProviderInterfaces')
private readonly dataProviderInterfaces: DataProviderInterface[],
private readonly prismaService: PrismaService
) {}
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) {
this.initialize();
}
public async initialize() {
this.dataProviderMapping =
((await this.propertyService.getByKey(PROPERTY_DATA_SOURCE_MAPPING)) as {
[dataProviderName: string]: string;
}) ?? {};
}
public async checkQuote(dataSource: DataSource) {
const dataProvider = this.getDataProvider(dataSource);
const symbol = dataProvider.getTestSymbol();
const quotes = await this.getQuotes([
{
dataSource,
symbol
}
]);
if (quotes[symbol]?.marketPrice > 0) {
return true;
}
return false;
}
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
[symbol: string]: Partial<SymbolProfile>;
}> {
const response: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
for (const symbol of symbols) {
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol)
);
promises.push(
promise.then((symbolProfile) => {
response[symbol] = symbolProfile;
})
);
}
}
await Promise.all(promises);
return response;
}
public getDataSourceForExchangeRates(): DataSource {
return DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
];
}
public getDataSourceForImport(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')];
}
public async getDividends({
dataSource,
@ -147,46 +228,6 @@ export class DataProviderService {
return result;
}
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
}
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
[symbol: string]: Partial<SymbolProfile>;
}> {
const response: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
for (const symbol of symbols) {
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol)
);
promises.push(
promise.then((symbolProfile) => {
response[symbol] = symbolProfile;
})
);
}
}
await Promise.all(promises);
return response;
}
public async getQuotes(items: IDataGatheringItem[]): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
@ -226,7 +267,7 @@ export class DataProviderService {
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
promises.push(
promise.then((result) => {
promise.then(async (result) => {
for (const [symbol, dataProviderResponse] of Object.entries(
result
)) {
@ -241,6 +282,27 @@ export class DataProviderService {
1000
).toFixed(3)} seconds`
);
try {
await this.marketDataService.updateMany({
data: Object.keys(response)
.filter((symbol) => {
return (
isNumber(response[symbol].marketPrice) &&
response[symbol].marketPrice > 0
);
})
.map((symbol) => {
return {
symbol,
dataSource: response[symbol].dataSource,
date: getStartOfUtcDate(new Date()),
marketPrice: response[symbol].marketPrice,
state: 'INTRADAY'
};
})
});
} catch {}
})
);
}
@ -260,26 +322,51 @@ export class DataProviderService {
return response;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
query,
user
}: {
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = [];
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push(
this.getDataProvider(DataSource[dataSource]).search(aQuery)
);
if (query?.length < 2) {
return { items: lookupItems };
}
let dataSources = this.configurationService.get('DATA_SOURCES');
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === 'Basic'
) {
dataSources = dataSources.filter((dataSource) => {
return !this.isPremiumDataSource(DataSource[dataSource]);
});
}
for (const dataSource of dataSources) {
promises.push(this.getDataProvider(DataSource[dataSource]).search(query));
}
const searchResults = await Promise.all(promises);
searchResults.forEach((searchResult) => {
lookupItems = lookupItems.concat(searchResult.items);
searchResults.forEach(({ items }) => {
if (items?.length > 0) {
lookupItems = lookupItems.concat(items);
}
});
const filteredItems = lookupItems.filter((lookupItem) => {
// Only allow symbols with supported currency
return lookupItem.currency ? true : false;
});
const filteredItems = lookupItems
.filter((lookupItem) => {
// Only allow symbols with supported currency
return lookupItem.currency ? true : false;
})
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
});
return {
items: filteredItems
@ -288,6 +375,21 @@ export class DataProviderService {
private getDataProvider(providerName: DataSource) {
for (const dataProviderInterface of this.dataProviderInterfaces) {
if (this.dataProviderMapping[dataProviderInterface.getName()]) {
const mappedDataProviderInterface = this.dataProviderInterfaces.find(
(currentDataProviderInterface) => {
return (
currentDataProviderInterface.getName() ===
this.dataProviderMapping[dataProviderInterface.getName()]
);
}
);
if (mappedDataProviderInterface) {
return mappedDataProviderInterface;
}
}
if (dataProviderInterface.getName() === providerName) {
return dataProviderInterface;
}
@ -295,4 +397,9 @@ export class DataProviderService {
throw new Error('No data provider has been found.');
}
private isPremiumDataSource(aDataSource: DataSource) {
const premiumDataSources: DataSource[] = [DataSource.EOD_HISTORICAL_DATA];
return premiumDataSources.includes(aDataSource);
}
}

View File

@ -1,28 +1,34 @@
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/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import bent from 'bent';
import { format } from 'date-fns';
import Big from 'big.js';
import { format, isToday } from 'date-fns';
@Injectable()
export class EodHistoricalDataService implements DataProviderInterface {
private apiKey: string;
private baseCurrency: string;
private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService
private readonly configurationService: ConfigurationService
) {
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) {
@ -32,8 +38,16 @@ export class EodHistoricalDataService implements DataProviderInterface {
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(aSymbol);
return {
dataSource: this.getName()
assetClass: searchResult?.assetClass,
assetSubClass: searchResult?.assetSubClass,
currency: this.convertCurrency(searchResult?.currency),
dataSource: this.getName(),
isin: searchResult?.isin,
name: searchResult?.name,
symbol: aSymbol
};
}
@ -59,9 +73,11 @@ export class EodHistoricalDataService implements DataProviderInterface {
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
const symbol = this.convertToEodSymbol(aSymbol);
try {
const get = bent(
`${this.URL}/eod/${aSymbol}?api_token=${
`${this.URL}/eod/${symbol}?api_token=${
this.apiKey
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
to,
@ -76,14 +92,17 @@ export class EodHistoricalDataService implements DataProviderInterface {
return response.reduce(
(result, historicalItem, index, array) => {
result[aSymbol][historicalItem.date] = {
marketPrice: historicalItem.close,
result[this.convertFromEodSymbol(symbol)][historicalItem.date] = {
marketPrice: this.getConvertedValue({
symbol: aSymbol,
value: historicalItem.close
}),
performance: historicalItem.open - historicalItem.close
};
return result;
},
{ [aSymbol]: {} }
{ [this.convertFromEodSymbol(symbol)]: {} }
);
} catch (error) {
throw new Error(
@ -108,46 +127,87 @@ export class EodHistoricalDataService implements DataProviderInterface {
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
const symbols = aSymbols.map((symbol) => {
return this.convertToEodSymbol(symbol);
});
if (symbols.length <= 0) {
return {};
}
try {
const get = bent(
`${this.URL}/real-time/${aSymbols[0]}?api_token=${
`${this.URL}/real-time/${symbols[0]}?api_token=${
this.apiKey
}&fmt=json&s=${aSymbols.join(',')}`,
}&fmt=json&s=${symbols.join(',')}`,
'GET',
'json',
200
);
const [response, symbolProfiles] = await Promise.all([
get(),
this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => {
return {
symbol,
dataSource: DataSource.EOD_HISTORICAL_DATA
};
const realTimeResponse = await get();
const quotes =
symbols.length === 1 ? [realTimeResponse] : realTimeResponse;
const searchResponse = await Promise.all(
symbols
.filter((symbol) => {
return !symbol.endsWith('.FOREX');
})
)
]);
.map((symbol) => {
return this.search(symbol);
})
);
const quotes = aSymbols.length === 1 ? [response] : response;
const lookupItems = searchResponse.flat().map(({ items }) => {
return items[0];
});
return quotes.reduce((result, item, index, array) => {
result[item.code] = {
currency: symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === item.code;
})?.currency,
dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: item.close,
marketState: 'delayed'
const response = quotes.reduce(
(
result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp }
) => {
const currency = lookupItems.find((lookupItem) => {
return lookupItem.symbol === code;
})?.currency;
result[this.convertFromEodSymbol(code)] = {
currency: currency ?? this.baseCurrency,
dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
};
return result;
},
{}
);
if (response[`${this.baseCurrency}GBP`]) {
response[`${this.baseCurrency}GBp`] = {
...response[`${this.baseCurrency}GBP`],
currency: `${this.baseCurrency}GBp`,
marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}GBp`,
value: response[`${this.baseCurrency}GBP`].marketPrice
})
};
}
return result;
}, {});
if (response[`${this.baseCurrency}ILS`]) {
response[`${this.baseCurrency}ILA`] = {
...response[`${this.baseCurrency}ILS`],
currency: `${this.baseCurrency}ILA`,
marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ILA`,
value: response[`${this.baseCurrency}ILS`].marketPrice
})
};
}
return response;
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
}
@ -155,7 +215,179 @@ export class EodHistoricalDataService implements DataProviderInterface {
return {};
}
public getTestSymbol() {
return 'AAPL.US';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
const searchResult = await this.getSearchResult(aQuery);
return {
items: searchResult
.filter(({ symbol }) => {
return !symbol.endsWith('.FOREX');
})
.map(
({
assetClass,
assetSubClass,
currency,
dataSource,
name,
symbol
}) => {
return {
assetClass,
assetSubClass,
dataSource,
name,
symbol,
currency: this.convertCurrency(currency)
};
}
)
};
}
private convertCurrency(aCurrency: string) {
let currency = aCurrency;
if (currency === 'GBX') {
currency = 'GBp';
}
return currency;
}
private convertFromEodSymbol(aEodSymbol: string) {
let symbol = aEodSymbol;
if (symbol.endsWith('.FOREX')) {
symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', '');
symbol = `${this.baseCurrency}${symbol}`;
}
return symbol;
}
/**
* Converts a symbol to a EOD symbol
*
* Currency: USDCHF -> CHF.FOREX
*/
private convertToEodSymbol(aSymbol: string) {
if (
aSymbol.startsWith(this.baseCurrency) &&
aSymbol.length > this.baseCurrency.length
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
)
) {
return `${aSymbol
.replace('GBp', 'GBX')
.replace(this.baseCurrency, '')}.FOREX`;
}
}
return aSymbol;
}
private getConvertedValue({
symbol,
value
}: {
symbol: string;
value: number;
}) {
if (symbol === `${this.baseCurrency}GBp`) {
// Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) {
// Convert ILS to ILA
return new Big(value).mul(100).toNumber();
}
return value;
}
private async getSearchResult(aQuery: string): Promise<
(LookupItem & {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
isin: string;
})[]
> {
let searchResult = [];
try {
const get = bent(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
'GET',
'json',
200
);
const response = await get();
searchResult = response.map(
({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => {
const { assetClass, assetSubClass } = this.parseAssetClass({
Exchange,
Type
});
return {
assetClass,
assetSubClass,
isin,
name,
currency: this.convertCurrency(Currency),
dataSource: this.getName(),
symbol: `${Code}.${Exchange}`
};
}
);
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
}
return searchResult;
}
private parseAssetClass({
Exchange,
Type
}: {
Exchange: string;
Type: string;
}): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (Type?.toLowerCase()) {
case 'common stock':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'currency':
assetClass = AssetClass.CASH;
if (Exchange?.toLowerCase() === 'cc') {
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
}
break;
case 'etf':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
}
return { assetClass, assetSubClass };
}
}

View File

@ -1,12 +1,12 @@
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/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -30,7 +30,8 @@ export class GoogleSheetsService implements DataProviderInterface {
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
dataSource: this.getName(),
symbol: aSymbol
};
}
@ -108,8 +109,14 @@ export class GoogleSheetsService implements DataProviderInterface {
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => {
return {
symbol,
dataSource: this.getName()
};
})
);
const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
@ -142,9 +149,15 @@ export class GoogleSheetsService implements DataProviderInterface {
return {};
}
public getTestSymbol() {
return 'INDEXSP:.INX';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
assetClass: true,
assetSubClass: true,
currency: true,
dataSource: true,
name: true,

View File

@ -40,5 +40,7 @@ export interface DataProviderInterface {
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }>;
getTestSymbol(): string;
search(aQuery: string): Promise<{ items: LookupItem[] }>;
}

View File

@ -4,8 +4,8 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATE_FORMAT,
extractNumberFromString,
@ -34,7 +34,8 @@ export class ManualService implements DataProviderInterface {
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
dataSource: this.getName(),
symbol: aSymbol
};
}
@ -63,8 +64,9 @@ export class ManualService implements DataProviderInterface {
try {
const symbol = aSymbol;
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[{ symbol, dataSource: this.getName() }]
);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration ?? {};
@ -127,8 +129,11 @@ export class ManualService implements DataProviderInterface {
}
try {
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => {
return { symbol, dataSource: this.getName() };
})
);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],
@ -162,9 +167,15 @@ export class ManualService implements DataProviderInterface {
return {};
}
public getTestSymbol() {
return undefined;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
let items = await this.prismaService.symbolProfile.findMany({
select: {
assetClass: true,
assetSubClass: true,
currency: true,
dataSource: true,
name: true,

View File

@ -1,5 +1,5 @@
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/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
@ -27,7 +27,8 @@ export class RapidApiService implements DataProviderInterface {
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
dataSource: this.getName(),
symbol: aSymbol
};
}
@ -112,6 +113,10 @@ export class RapidApiService implements DataProviderInterface {
return {};
}
public getTestSymbol() {
return undefined;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
}

View File

@ -1,26 +1,19 @@
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/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns';
import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
@ -28,7 +21,8 @@ export class YahooFinanceService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@ -37,127 +31,20 @@ export class YahooFinanceService implements DataProviderInterface {
return true;
}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
);
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
symbol = `${this.baseCurrency}${symbol}`;
}
return symbol.replace('=X', '');
}
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF -> USDCHF=X
* Cryptocurrency: BTCUSD -> BTC-USD
* DOGEUSD -> DOGE-USD
*/
public convertToYahooFinanceSymbol(aSymbol: string) {
if (
aSymbol.includes(this.baseCurrency) &&
aSymbol.length > this.baseCurrency.length
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
)
) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
)
) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace(
new RegExp(`-?${this.baseCurrency}$`),
`-${this.baseCurrency}`
);
}
}
return aSymbol;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {};
const { assetClass, assetSubClass, currency, name } =
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
try {
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
const assetProfile = await yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile', 'topHoldings']
});
const { assetClass, assetSubClass } = this.parseAssetClass(
assetProfile.price
);
response.assetClass = assetClass;
response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency;
response.dataSource = this.getName();
response.name = this.formatName({
longName: assetProfile.price.longName,
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol
});
response.symbol = aSymbol;
if (assetSubClass === AssetSubClass.MUTUALFUND) {
response.sectors = [];
for (const sectorWeighting of assetProfile.topHoldings
?.sectorWeightings ?? []) {
for (const [sector, weight] of Object.entries(sectorWeighting)) {
response.sectors.push({ weight, name: this.parseSector(sector) });
}
}
} else if (
assetSubClass === AssetSubClass.STOCK &&
assetProfile.summaryProfile?.country
) {
// Add country if asset is stock and country available
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === assetProfile.summaryProfile?.country;
});
if (code) {
response.countries = [{ code, weight: 1 }];
}
} catch {}
if (assetProfile.summaryProfile?.sector) {
response.sectors = [
{ name: assetProfile.summaryProfile?.sector, weight: 1 }
];
}
}
const url = assetProfile.summaryProfile?.website;
if (url) {
response.url = url;
}
} catch (error) {
Logger.error(error, 'YahooFinanceService');
}
return response;
return {
assetClass,
assetSubClass,
currency,
name,
dataSource: this.getName(),
symbol: aSymbol
};
}
public async getDividends({
@ -177,7 +64,9 @@ export class YahooFinanceService implements DataProviderInterface {
try {
const historicalResult = await yahooFinance.historical(
this.convertToYahooFinanceSymbol(symbol),
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
{
events: 'dividends',
interval: granularity === 'month' ? '1mo' : '1d',
@ -227,7 +116,9 @@ export class YahooFinanceService implements DataProviderInterface {
try {
const historicalResult = await yahooFinance.historical(
this.convertToYahooFinanceSymbol(aSymbol),
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
aSymbol
),
{
interval: '1d',
period1: format(from, DATE_FORMAT),
@ -276,8 +167,9 @@ export class YahooFinanceService implements DataProviderInterface {
if (aSymbols.length <= 0) {
return {};
}
const yahooFinanceSymbols = aSymbols.map((symbol) =>
this.convertToYahooFinanceSymbol(symbol)
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
);
try {
@ -287,7 +179,10 @@ export class YahooFinanceService implements DataProviderInterface {
for (const quote of quotes) {
// Convert symbols back
const symbol = this.convertFromYahooFinanceSymbol(quote.symbol);
const symbol =
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
quote.symbol
);
response[symbol] = {
currency: quote.currency,
@ -308,9 +203,10 @@ export class YahooFinanceService implements DataProviderInterface {
response[`${this.baseCurrency}GBp`] = {
...response[symbol],
currency: 'GBp',
marketPrice: new Big(response[symbol].marketPrice)
.mul(100)
.toNumber()
marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}GBp`,
value: response[symbol].marketPrice
})
};
} else if (
symbol === `${this.baseCurrency}ILS` &&
@ -320,9 +216,10 @@ export class YahooFinanceService implements DataProviderInterface {
response[`${this.baseCurrency}ILA`] = {
...response[symbol],
currency: 'ILA',
marketPrice: new Big(response[symbol].marketPrice)
.mul(100)
.toNumber()
marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ILA`,
value: response[symbol].marketPrice
})
};
} else if (
symbol === `${this.baseCurrency}ZAR` &&
@ -332,9 +229,10 @@ export class YahooFinanceService implements DataProviderInterface {
response[`${this.baseCurrency}ZAc`] = {
...response[symbol],
currency: 'ZAc',
marketPrice: new Big(response[symbol].marketPrice)
.mul(100)
.toNumber()
marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ZAc`,
value: response[symbol].marketPrice
})
};
}
}
@ -357,6 +255,10 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
public getTestSymbol() {
return 'AAPL';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = [];
@ -404,15 +306,24 @@ export class YahooFinanceService implements DataProviderInterface {
return currentQuote.symbol === marketDataItem.symbol;
});
const symbol = this.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
const symbol =
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
const { assetClass, assetSubClass } =
this.yahooFinanceDataEnhancerService.parseAssetClass({
quoteType: quote.quoteType,
shortName: quote.shortname
});
items.push({
assetClass,
assetSubClass,
symbol,
currency: marketDataItem.currency,
dataSource: this.getName(),
name: this.formatName({
name: this.yahooFinanceDataEnhancerService.formatName({
longName: quote.longname,
quoteType: quote.quoteType,
shortName: quote.shortname,
@ -427,42 +338,6 @@ export class YahooFinanceService implements DataProviderInterface {
return { items };
}
private formatName({
longName,
quoteType,
shortName,
symbol
}: {
longName: Price['longName'];
quoteType: Price['quoteType'];
shortName: Price['shortName'];
symbol: Price['symbol'];
}) {
let name = longName;
if (name) {
name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares V PLC - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');
name = name.replace('VanEck ETFs N.V. - ', '');
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
name = name.replace('Vanguard Funds Public Limited Company - ', '');
name = name.replace('Vanguard Index Funds - ', '');
name = name.replace('Xtrackers (IE) Plc - ', '');
}
if (quoteType === 'FUTURE') {
// "Gold Jun 22" -> "Gold"
name = shortName?.slice(0, -6);
}
return name || shortName || symbol;
}
private getConvertedValue({
symbol,
value
@ -483,89 +358,4 @@ export class YahooFinanceService implements DataProviderInterface {
return value;
}
private parseAssetClass(aPrice: Price): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (aPrice?.quoteType?.toLowerCase()) {
case 'cryptocurrency':
assetClass = AssetClass.CASH;
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break;
case 'equity':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'etf':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
case 'future':
assetClass = AssetClass.COMMODITY;
assetSubClass = AssetSubClass.COMMODITY;
if (
aPrice?.shortName?.toLowerCase()?.startsWith('gold') ||
aPrice?.shortName?.toLowerCase()?.startsWith('palladium') ||
aPrice?.shortName?.toLowerCase()?.startsWith('platinum') ||
aPrice?.shortName?.toLowerCase()?.startsWith('silver')
) {
assetSubClass = AssetSubClass.PRECIOUS_METAL;
}
break;
case 'mutualfund':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.MUTUALFUND;
break;
}
return { assetClass, assetSubClass };
}
private parseSector(aString: string): string {
let sector = UNKNOWN_KEY;
switch (aString) {
case 'basic_materials':
sector = 'Basic Materials';
break;
case 'communication_services':
sector = 'Communication Services';
break;
case 'consumer_cyclical':
sector = 'Consumer Cyclical';
break;
case 'consumer_defensive':
sector = 'Consumer Staples';
break;
case 'energy':
sector = 'Energy';
break;
case 'financial_services':
sector = 'Financial Services';
break;
case 'healthcare':
sector = 'Healthcare';
break;
case 'industrials':
sector = 'Industrials';
break;
case 'realestate':
sector = 'Real Estate';
break;
case 'technology':
sector = 'Technology';
break;
case 'utilities':
sector = 'Utilities';
break;
}
return sector;
}
}

View File

@ -1,12 +1,11 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { MarketDataModule } from './market-data.module';
import { PrismaModule } from './prisma.module';
@Module({
exports: [ExchangeRateDataService],
imports: [

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