Compare commits

...

116 Commits

Author SHA1 Message Date
761376d72d Release 1.106.0 (#647) 2022-01-23 17:41:54 +01:00
9c086edffe Feature/extend historical data view in admin control (#646)
* Extend market data view

* Update changelog
2022-01-23 17:02:12 +01:00
585f99e4df Feature/add summary row to activities table (#645)
* Add summary row to activities table

* Update changelog
2022-01-23 11:39:30 +01:00
9d907b5eb5 Bugfix/improve the redirection on logout (#642)
* Improve logout

* Update changelog
2022-01-22 09:38:01 +01:00
ba05f5ba30 Feature/upgrade prisma to version 3.8.1 (#640)
* Upgrade prisma to version 3.8.1

* Update changelog
2022-01-22 09:36:58 +01:00
3261e3ee59 Feature/upgrade stripe dependencies (#641)
* Upgrade stripe dependencies

* Update changelog
2022-01-21 20:30:41 +01:00
5607c6bb52 Update blog url (#639) 2022-01-21 20:07:56 +01:00
1c6050d3e3 Release 1.105.0 (#638) 2022-01-20 21:35:56 +01:00
38f2930ec6 Feature/improve data provider service (#637)
* Improve data provider service

* Update changelog
2022-01-20 21:34:23 +01:00
556be61fff Bugfix/fix unresolved account names in reports (#636)
* Fix unresolved account names

* Update changelog
2022-01-19 21:28:15 +01:00
651b4bcff7 Release 1.104.0 (#631) 2022-01-16 15:45:28 +01:00
0a8d159f78 Bugfix/fix missing symbol profile data connection in import (#630)
* Fix missing symbol profile data connection in import

* Update changelog
2022-01-16 15:31:56 +01:00
1a4109ebaa Bugfix/fix fallback to load currencies directly from data provider (#629)
* Fix fallback

* Update changelog
2022-01-16 13:46:00 +01:00
92e502e1c2 Release 1.103.0 (#628) 2022-01-13 20:33:31 +01:00
e344c43a5a Bugfix/fix currency of value in position detail dialog (#627)
* Fix currency

* Update changelog
2022-01-13 20:25:21 +01:00
d6b78f3457 Feature/add links to statistics section (#626)
* Add links and clean up style

* Update changelog
2022-01-13 19:07:23 +01:00
9bbb856f66 Release 1.102.0 (#625) 2022-01-11 19:53:23 +01:00
d3707bbb87 Bugfix/fix preselected default account in create activity dialog (#624)
* Fix preselected default account

* Update changelog
2022-01-11 19:50:22 +01:00
7df53896f3 Feature/start eliminating data source from order (#622)
* Start eliminating dataSource from order

* Update changelog
2022-01-11 19:49:45 +01:00
b2b3fde80e Bugfix/support multiple accounts with the same name (#623)
* Support multiple accounts with the same name

* Update changelog
2022-01-10 21:23:47 +01:00
a83441b3ba Release 1.101.0 (#621) 2022-01-08 18:21:33 +01:00
075431d868 Feature/add google sheets as data source (#620)
* Add google sheets as data source

* Update changelog
2022-01-08 18:19:25 +01:00
0168c1c4e8 Feature/exclude url pattern of shared portfolios in robots.txt (#619)
* Exclude shared portfolios

* Update changelog
2022-01-08 09:37:54 +01:00
07de8f87fc Set market prices explicitly (#618)
* Set market prices explicitly

* Set comments explicitly
2022-01-07 08:09:12 +01:00
3e16041c16 Release 1.100.0 (#617) 2022-01-05 20:24:34 +01:00
5882b7914d Feature/add first months in open source blog post (#616)
* Add blog post

* Update changelog
2022-01-05 20:22:59 +01:00
69c9e259b1 Bugfix/fix routing of create activity dialog (#615)
* Fix routing of create activity dialog

* Update changelog
2022-01-03 21:31:55 +01:00
aca37a27f9 Feature/add top performers to analysis page (#613)
* Add Top 3 / Bottom 3 performers

* Update changelog
2022-01-02 13:29:45 +01:00
313d2a2f79 Release 1.99.0 (#612) 2022-01-01 16:40:55 +01:00
9ac67b0af2 Feature/expose profile data gathering by symbol endpoint (#611)
* Expose profile data gathering by symbol endpoint

* Update changelog
2022-01-01 16:18:18 +01:00
1e526852a7 Bugfix/fix mapping for russia in trackinsight data enhancer (#610)
* Fix mapping for Russia

* Update changelog
2022-01-01 13:55:53 +01:00
e54638a684 Feature/improve analysis page (#609)
* Improve analysis page (show y-axis, extend chart in relation to days in market)

* Update changelog
2022-01-01 12:09:49 +01:00
0179823ad9 Feature/restructure about page (#608)
* Restructure about page: introduce pages for blog and changelog

* Update changelog
2022-01-01 10:10:37 +01:00
029b7bed9a Bugfix/improve error handling in position api endpoint (#607)
* Add guards

* Update changelog
2021-12-31 22:00:58 +01:00
635f10e2d0 Bugfix/hide data provider warning while loading (#605)
* Hide data provider warning while loading

* Update changelog
2021-12-31 10:21:41 +01:00
cebf879d67 Feature/refactor demo user (#604)
* Refactor demo user id

* Update changelog
2021-12-31 09:52:03 +01:00
124bdc028d Bugfix/fix reload of position detail dialog (#603)
* Fix reload of position detail dialog

* Update changelog
2021-12-31 09:51:30 +01:00
d69a69ce18 Bugfix/fix exception with market state (#602)
* Fix exception with market state

* Update changelog
2021-12-30 22:19:08 +01:00
15344513ce Feature/upgrade chart.js to version 3.7.0 (#601)
* Upgrade chart.js from version 3.5.0 to 3.7.0

* Update changelog
2021-12-30 22:18:39 +01:00
b291d9e031 Feature/refactor transactions to activities table (#600)
* Refactor transactions to activities table with attributes and routes

* Update changelog
2021-12-30 18:56:51 +01:00
bee702302f Upgrade angular and Nx dependencies (#599) 2021-12-30 17:31:07 +01:00
bb56e09a13 Clean up preview feature flag (#598) 2021-12-30 10:15:30 +01:00
0873f539c5 Release 1.98.0 (#597) 2021-12-29 18:40:18 +01:00
6dcd801d05 Feature/extend statistics section with users in slack community (#596)
* Extend statistics with users in Slack community

* Update changelog
2021-12-29 18:38:55 +01:00
77065dac50 Feature/add date range selector to holdings tab (#595)
* Add date range selector to holdings tab

* Update changelog
2021-12-29 18:14:24 +01:00
438484879d Bugfix/fix creation of historical data (#594)
* Fix creation of historical data (upsert instead of update)

* Update changelog
2021-12-29 17:03:37 +01:00
e37a650c70 Bugfix/fix scrolling issue in position detail dialog on mobile (#593)
* Fix scrolling in position detail dialog on mobile

* Update changelog
2021-12-29 10:51:11 +01:00
6e8c90b3fc Release 1.97.0 (#592) 2021-12-28 21:40:10 +01:00
9e1a7fc981 Feature/dividend (#547)
* Add dividend to order type

* Support dividend in transactions table

* Support dividend in transaction dialog

* Extend import file with dividend

* Add dividend to portfolio summary

* Update changelog

Co-authored-by: Fly Man <fly.man.opensim@gmail.com>
2021-12-28 21:12:12 +01:00
ff638adf03 Feature/add transactions to position detail dialog (#591)
* Add transactions to position detail dialog

* Update changelog
2021-12-28 17:45:04 +01:00
fa44cee781 Release 1.96.0 (#590) 2021-12-27 21:08:33 +01:00
db1d474ddf Feature/more discreet data provider warning (#589)
* Upgrade http-status-codes to version 2.2.0

* Make the data provider warning more discreet

* Update changelog
2021-12-27 12:14:41 +01:00
994275e093 Feature/upgrade angular 3rd party dependencies (#588)
* Upgrade angular 3rd party dependencies
  * ngx-device-detector
  * ngx-markdown
  * ngx-stripe

* Update changelog
2021-12-26 21:58:56 +01:00
ee397c8047 Bugfix/fix file type detection for import (#587)
* Fix file type detection ("application/vnd.ms-excel" instead of "text/csv")

* Update changelog
2021-12-26 20:54:53 +01:00
7203939c42 Feature/upgrade prisma to version 3.7.0 (#586)
* Upgrade prisma from version 3.6.0 to 3.7.0

* Update changelog
2021-12-26 17:30:26 +01:00
9725f16c81 Clean up schema.prisma (#584) 2021-12-26 14:33:18 +01:00
bb8b1e4f43 Release 1.95.0 (#583) 2021-12-26 10:14:13 +01:00
9d3610331a Add guard (#582) 2021-12-26 10:07:51 +01:00
0043b44670 Feature/improve data gathering for currencies (#581)
* Improve data gathering for currencies, add warning if it fails

* Update changelog
2021-12-26 09:15:10 +01:00
bbc4e64cb4 Bugfix/filter currencies with null value (#579)
* Filter currencies with null value

* Update changelog
2021-12-25 17:08:56 +01:00
c7f4825499 Release 1.94.0 (#578) 2021-12-25 14:45:58 +01:00
8f583709ef Feature/add support for cosmos and polkadot (#577)
* Add support for cryptocurrencies ATOM and DOT

* Update changelog
2021-12-25 14:23:07 +01:00
4c30212a72 Feature/improve data gathering (#576)
* Eliminate benchmarks to gather

* Optimize 7d data gathering

* Update changelog
2021-12-25 14:18:46 +01:00
cade2f6a5e Feature/upgrade prettier to version 2.5.1 (#575)
* Upgrade prettier from version 2.3.2 to 2.5.1

* Update changelog
2021-12-25 10:29:56 +01:00
3b9a8fabb5 Clean up (#574) 2021-12-24 18:42:30 +01:00
3435b3a348 Feature/make the csv import more flexible (#573)
* Make the csv import more flexible

* Update changelog
2021-12-24 18:21:27 +01:00
5d39b267ab write portfolio calculator test case for symbol BALN.SW (refs #554) (#572) 2021-12-24 17:52:08 +01:00
ffaaa14dba Feature/increase fear and greed index to 30 days (#571)
* Increase Fear & Greed index to 30 days

* Update changelog
2021-12-24 09:40:24 +01:00
c65746d119 Simplify instructions for development setup (#570) 2021-12-22 18:09:00 +01:00
1a6840f1f6 Fix instruction for database setup (#568) 2021-12-21 20:59:01 +01:00
fb7fb886f6 Fix anchor link (#567) 2021-12-21 20:53:15 +01:00
127abb8f4e Release 1.93.0 (#566) 2021-12-21 20:24:24 +01:00
ed1136999a Feature/extend documentation for self hosting (#565)
* Extend documentation for self-hosting

* Add tag "latest" to docker image

* Update changelog
2021-12-21 20:22:58 +01:00
9f545e3e2b Feature/add cryptocurrency solana (#563)
* Add support for Solana and clean up symbol conversion (SOL1USD)

* Update changelog
2021-12-20 21:24:58 +01:00
1602f976f0 Feature/convert errors to warnings in portfolio calculator (#562)
* Convert errors to warnings

* Update changelog
2021-12-20 21:03:12 +01:00
4bf4c1a8a3 Release 1.92.0 (#561) 2021-12-19 16:57:22 +01:00
e78755c280 Replace OrderType with Type (prisma) (#560) 2021-12-19 16:52:35 +01:00
7772684413 Bugfix/fix permission for system status page (#559)
* Fix permission

* Update changelog
2021-12-19 15:55:03 +01:00
955302666e Bugfix/improve redirection on logout (#558)
* Improve redirection on logout

* Update changelog
2021-12-19 13:45:28 +01:00
ddce8cc7f9 Feature/support update of historical data (#557)
* Support update of historical data

* Update changelog
2021-12-19 10:57:50 +01:00
aca0d77e91 Feature/add line chart to historical data view (#555)
* Add line chart

* Update changelog
2021-12-18 16:43:34 +01:00
8b9379f5ce Release 1.91.0 (#553) 2021-12-18 11:41:13 +01:00
0806d0dc92 Bugfix/fix hidden statistics values (#552)
* Fix hidden values (0)

* Update changelog
2021-12-18 11:13:32 +01:00
e518bc3779 Feature/clean up all time high and low from performance endpoint (#551)
* Remove isAllTimeHigh / isAllTimeLow

* Update changelog
2021-12-18 09:20:42 +01:00
eff807dd9a Add tests for yahoo Finance symbol conversion (#550)
* Add tests for yahoo Finance symbol conversion

* Refactoring
2021-12-18 09:04:52 +01:00
155bf67f60 Bugfix/fix symbol conversion from yahoo finance with hyphen (#549)
* Fix symbol conversion from Yahoo Finance

* Update changelog
2021-12-16 18:44:08 +01:00
9aefe3747e Feature/migrate database schema to prisma 3 (#546)
* Add migration after upgrade to prisma 3

* Update changelog
2021-12-15 21:56:29 +01:00
0878febded build(prisma): fix populating database when attributes have null value (#545) 2021-12-15 21:04:26 +01:00
f953c6ea64 Release/1.90.0 (#544)
* Update stage labels

* Release 1.90.0
2021-12-14 20:04:17 +01:00
76dbf78279 implement docker build on tags (#542)
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-12-14 19:45:55 +01:00
f12866b9ec Update changelog (#543) 2021-12-13 22:31:49 +01:00
83ba5f3d9f Feature/add intro image for dark mode (#540)
* Add intro image for dark mode

* Update changelog
2021-12-13 22:26:16 +01:00
7439c1bf54 fix(custom-cryptocurrencies): add uniswap (uni3) (#541) 2021-12-13 22:25:09 +01:00
e255b76053 Feature/check currency in import validation (#538)
* Check currency in import validation (avoid importing transactions in wrong currency)

* Update changelog
2021-12-13 19:45:23 +01:00
ed7209fb53 Feature/remove transactions import limit (#536)
* Remove default transactions import limit

* Update changelog
2021-12-11 21:44:54 +01:00
008a2ab123 Support arm64 (#465)
* Support arm64

* Move prisma from devDependencies to dependencies (Fix "/bin/sh: prisma: not found" in docker build)

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
2021-12-11 20:58:09 +01:00
2aedd74480 Release 1.89.0 (#534) 2021-12-11 17:23:29 +01:00
11076592d1 Do not log coupon code (#533) 2021-12-11 17:12:24 +01:00
ebee851b23 Feature/support data gathering by symbol and date (#532)
* Support data gathering by symbol and date

* Update changelog
2021-12-11 11:45:13 +01:00
7d3f1832b4 Feature/upgrade nx and storybook dependencies (#531)
* Upgrade dependencies
  * nx
  * storybook

* Update changelog
2021-12-10 19:50:46 +01:00
39e6abfc8c Clean up preview features (#530) 2021-12-10 19:43:53 +01:00
78e0fdb0ca Add coupon system (#529)
* Add coupon system

* Update changelog
2021-12-09 21:14:44 +01:00
606350b2ff Release 1.87.0 (#526) 2021-12-07 20:34:52 +01:00
d09cad4e05 Feature/read only mode (#520)
* Setup read only mode and update permissions dynamically

* Update changelog
2021-12-07 20:24:15 +01:00
069660afe4 Feature/increase fear and greed index to 10 days (#525)
* Increase to 10 days

* Update changelog
2021-12-07 19:10:40 +01:00
4d9a223491 Feature/add system message (#519)
* Add system message

* Update changelog
2021-12-06 20:51:38 +01:00
aed8f5cf04 Feature/upgrade prisma to version 3.6.0 (#518)
* Upgrade prisma from version 2.30.2 to 3.6.0

* Update changelog
2021-12-05 16:52:24 +01:00
1beb4de62f Feature/support additional currencies (#517)
* Support additional currencies

* Update changelog
2021-12-04 21:05:11 +01:00
9bc3505ded Release 1.86.0 (#516) 2021-12-04 11:51:06 +01:00
3e82de6b21 Feature/add historical data chart of fear and greed index (#515)
* Add historical data chart of market mood

* Update changelog
2021-12-04 11:49:00 +01:00
563f354e7e Feature/symbol to uppercase to avoid duplicates (#514)
* Convert the symbol to uppercase to avoid case-sensitive duplicates

* Update changelog
2021-12-04 11:40:12 +01:00
e96e6c717c Feature/enable import by default (#513)
* Enable import by default

* Update changelog
2021-12-04 08:57:22 +01:00
961774ce9f Feature/improve market data detail (#511)
* Improve historical data view (hide invalid and future dates)

* Update changelog
2021-12-03 21:24:05 +01:00
49f46e1a1e Bugfix/improve allocations by currency with cash balances (#508)
* Improve allocations by currency in combination with cash balances

* Update changelog
2021-12-02 21:53:38 +01:00
050c0a4da7 Release 1.85.0 (#506) 2021-12-01 21:20:33 +01:00
4908e6d35d Bugfix/fix data gathering of fear and greed index (#505)
* Fix data gathering of fear and greed index

* Update changelog
2021-12-01 21:18:46 +01:00
216 changed files with 6784 additions and 3015 deletions

View File

@ -3,9 +3,28 @@ git:
depth: false depth: false
node_js: node_js:
- 14 - 14
before_script:
- yarn services:
script: - docker
- yarn format:check
- yarn test cache: yarn
- yarn build:all
if: (type = pull_request) OR (tag IS present)
jobs:
include:
- stage: Install dependencies
if: type = pull_request
script: yarn --frozen-lockfile
- stage: Check formatting
if: type = pull_request
script: yarn format:check
- stage: Execute tests
if: type = pull_request
script: yarn test
- stage: Build application
if: type = pull_request
script: yarn build:all
- stage: Build and publish docker image
if: tag IS present
script: ./publish-docker-image.sh

View File

@ -5,6 +5,288 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.106.0 - 23.01.2022
### Added
- Added the footer row with total fees and total value to the activities table
### Changed
- Extended the historical data view in the admin control panel
- Upgraded _Stripe_ dependencies
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
### Fixed
- Improved the redirection on logout
## 1.105.0 - 20.01.2022
### Added
- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
### Changed
- Improved the data provider with grouping by data source and thereby reducing the number of requests
### Fixed
- Fixed the unresolved account names in the _X-ray_ section
- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
## 1.104.0 - 16.01.2022
### Fixed
- Fixed the fallback to load currencies directly from the data provider
- Fixed the missing symbol profile data connection in the import functionality for activities
## 1.103.0 - 13.01.2022
### Changed
- Added links to the statistics section on the about page
### Fixed
- Fixed the currency of the value in the position detail dialog
## 1.102.0 - 11.01.2022
### Changed
- Start eliminating `dataSource` from activity
### Fixed
- Fixed the support for multiple accounts with the same name
- Fixed the preselected default account of the create activity dialog
## 1.101.0 - 08.01.2022
### Added
- Added `GOOGLE_SHEETS` as a new data source type
### Changed
- Excluded the url pattern of shared portfolios in the `robots.txt` file
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.100.0 - 05.01.2022
### Added
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
- Added a blog post
### Fixed
- Fixed the routing of the create activity dialog
- Fixed the link color in the blog posts
## 1.99.0 - 01.01.2022
### Added
- Exposed the profile data gathering by symbol as an endpoint
### Changed
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
- Restructured the about page
- Start refactoring _transactions_ to _activities_
- Refactored the demo user id
- Upgraded `angular` from version `13.0.2` to `13.1.1`
- Upgraded `chart.js` from version `3.5.0` to `3.7.0`
- Upgraded `Nx` from version `13.3.0` to `13.4.1`
### Fixed
- Hid the data provider warning while loading
- Fixed an exception with the market state caused by a failed data provider request
- Fixed an exception in the portfolio position endpoint
- Fixed the reload of the position detail dialog (with query parameters)
- Fixed the missing mapping for Russia in the data enhancer for symbol profile data via _Trackinsight_
## 1.98.0 - 29.12.2021
### Added
- Added the date range component to the holdings tab
### Changed
- Extended the statistics section on the about page (users in Slack community)
### Fixed
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
- Fixed the scrolling issue in the position detail dialog on mobile
## 1.97.0 - 28.12.2021
### Added
- Added the transactions to the position detail dialog
- Added support for dividend
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.96.0 - 27.12.2021
### Changed
- Made the data provider warning more discreet
- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
- Upgraded `prisma` from version `3.6.0` to `3.7.0`
### Fixed
- Fixed the file type detection in the import functionality for transactions
## 1.95.0 - 26.12.2021
### Added
- Added a warning to the log if the data gathering fails
### Fixed
- Filtered potential `null` currencies
- Improved the 7d data gathering optimization for currencies
## 1.94.0 - 25.12.2021
### Added
- Added support for cryptocurrencies _Cosmos_ (`ATOM-USD`) and _Polkadot_ (`DOT-USD`)
### Changed
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 30 days
- Made the import functionality for transactions by `csv` files more flexible
- Optimized the 7d data gathering (only consider symbols with incomplete market data)
- Upgraded `prettier` from version `2.3.2` to `2.5.1`
## 1.93.0 - 21.12.2021
### Added
- Added support for cryptocurrency _Solana_ (`SOL-USD`)
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
### Fixed
- Converted errors to warnings in portfolio calculator
## 1.92.0 - 19.12.2021
### Added
- Added a line chart to the historical data view in the admin control panel
- Supported the update of historical data in the admin control panel
### Fixed
- Improved the redirection on logout
- Fixed the permission for the system status page
## 1.91.0 - 18.12.2021
### Changed
- Removed the redundant all time high and all time low from the performance endpoint
### Fixed
- Fixed the symbol conversion from _Yahoo Finance_ including a hyphen
- Fixed hidden values (`0`) in the statistics section on the about page
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.90.0 - 14.12.2021
### Added
- Extended the validation in the import functionality for transactions by checking the currency of the data provider service
- Added support for cryptocurrency _Uniswap_
- Set up pipeline for docker build
### Changed
- Removed the default transactions import limit
- Improved the landing page in dark mode
### Fixed
- Fixed `/bin/sh: prisma: not found` in docker build
- Added `apk` in `Dockerfile` (`python3 g++ make openssl`)
## 1.89.0 - 11.12.2021
### Added
- Extended the data gathering by symbol endpoint with an optional date
### Changed
- Upgraded `Nx` from version `13.2.2` to `13.3.0`
- Upgraded `storybook` from version `6.4.0-rc.3` to `6.4.9`
## 1.88.0 - 09.12.2021
### Added
- Added a coupon system
## 1.87.0 - 07.12.2021
### Added
- Supported the management of additional currencies in the admin control panel
- Introduced the system message
- Introduced the read only mode
### Changed
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 10 days
- Upgraded `prisma` from version `2.30.2` to `3.6.0`
## 1.86.0 - 04.12.2021
### Added
- Added the historical data chart of the _Fear & Greed Index_ (market mood)
### Changed
- Improved the historical data view in the admin control panel (hide invalid and future dates)
- Enabled the import functionality for transactions by default
- Converted the symbols to uppercase to avoid case-sensitive duplicates in the symbol profile model
### Fixed
- Improved the allocations by currency in combination with cash balances
## 1.85.0 - 01.12.2021
### Fixed
- Fixed the data gathering of the _Fear & Greed Index_ (market mood)
## 1.84.0 - 30.11.2021 ## 1.84.0 - 30.11.2021
### Added ### Added

View File

@ -12,7 +12,8 @@ COPY ./package.json package.json
COPY ./yarn.lock yarn.lock COPY ./yarn.lock yarn.lock
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma/schema.prisma prisma/schema.prisma
RUN yarn RUN apk add --no-cache python3 g++ make openssl
RUN yarn install
# See https://github.com/nrwl/nx/issues/6586 for further details # See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js COPY ./decorate-angular-cli.js decorate-angular-cli.js

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong> <strong>Open Source Wealth Management Software made for Humans</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a> <a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p> </p>
<p> <p>
<a href="#contributing"> <a href="#contributing">
@ -34,7 +34,7 @@
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker). If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
## Why Ghostfolio? ## Why Ghostfolio?
@ -81,27 +81,43 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com). The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Run with Docker ## Run with Docker (self-hosting)
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
### Setup Docker Image ### a. Run environment
Run the following commands to build and start the Docker image: Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
```bash ```bash
docker-compose -f docker/docker-compose-build-local.yml build docker-compose -f docker/docker-compose.yml up
docker-compose -f docker/docker-compose-build-local.yml up
``` ```
### Setup Database #### Setup Database
Run the following command to setup the database once Ghostfolio is running: Run the following command to setup the database once Ghostfolio is running:
```bash ```bash
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
### b. Build and run environment
Run the following commands to build and start the Docker images:
```bash
docker-compose -f docker/docker-compose.build.yml build
docker-compose -f docker/docker-compose.build.yml up
```
#### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
``` ```
### Fetch Historical Data ### Fetch Historical Data
@ -112,6 +128,12 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
### Finalization
1. Create a new user via _Get Started_
1. Assign the role `ADMIN` to this user (directly in the database)
1. Delete the original _Admin_ (directly in the database)
### Migrate Database ### Migrate Database
With the following command you can keep your database schema in sync after a Ghostfolio version update: With the following command you can keep your database schema in sync after a Ghostfolio version update:
@ -131,9 +153,7 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
1. Run `cd docker` 1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `cd -` to go back to the project root directory
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data 1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development)) 1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9` 1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`

View File

@ -1,9 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -66,10 +62,7 @@ export class AccessController {
@Body() data: CreateAccessDto @Body() data: CreateAccessDto
): Promise<AccessModel> { ): Promise<AccessModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createAccess)
getPermissions(this.request.user.role),
permissions.createAccess
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -86,10 +79,7 @@ export class AccessController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> { public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteAccess)
getPermissions(this.request.user.role),
permissions.deleteAccess
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -24,7 +24,7 @@ export class AccessService {
take?: number; take?: number;
cursor?: Prisma.AccessWhereUniqueInput; cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput; where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByInput; orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> { }): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;

View File

@ -6,11 +6,7 @@ import {
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces'; import { Accounts } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -48,10 +44,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> { public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteAccount)
getPermissions(this.request.user.role),
permissions.deleteAccount
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -143,10 +136,7 @@ export class AccountController {
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createAccount)
getPermissions(this.request.user.role),
permissions.createAccount
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -183,10 +173,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.updateAccount)
getPermissions(this.request.user.role),
permissions.updateAccount
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -40,7 +40,7 @@ export class AccountService {
take?: number; take?: number;
cursor?: Prisma.AccountWhereUniqueInput; cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput; where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (Account & {
Order?: Order[]; Order?: Order[];

View File

@ -1,36 +1,39 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails AdminMarketDataDetails
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body,
Controller, Controller,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param, Param,
Post, Post,
Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -39,7 +42,7 @@ export class AdminController {
public async getAdminData(): Promise<AdminData> { public async getAdminData(): Promise<AdminData> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -57,7 +60,7 @@ export class AdminController {
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -73,35 +76,12 @@ export class AdminController {
return; return;
} }
@Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return;
}
@Post('gather/profile-data') @Post('gather/profile-data')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -116,12 +96,93 @@ export class AdminController {
return; return;
} }
@Post('gather/profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async gatherProfileDataForSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
return;
}
@Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return;
}
@Post('gather/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<MarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = new Date(dateString);
if (!isDate(date)) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
return this.dataGatheringService.gatherSymbolForDate({
dataSource,
date,
symbol
});
}
@Get('market-data') @Get('market-data')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getMarketData(): Promise<AdminMarketData> { public async getMarketData(): Promise<AdminMarketData> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -137,11 +198,11 @@ export class AdminController {
@Get('market-data/:symbol') @Get('market-data/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol( public async getMarketDataBySymbol(
@Param('symbol') symbol @Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> { ): Promise<AdminMarketDataDetails> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -153,4 +214,58 @@ export class AdminController {
return this.adminService.getMarketDataBySymbol(symbol); return this.adminService.getMarketDataBySymbol(symbol);
} }
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async update(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = new Date(dateString);
return this.marketDataService.updateMarketData({
data: { ...data, dataSource },
where: {
date_symbol: {
date,
symbol
}
}
});
}
@Put('settings/:key')
@UseGuards(AuthGuard('jwt'))
public async updateProperty(
@Param('key') key: string,
@Body() data: PropertyDto
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return await this.adminService.putSetting(key, data.value);
}
} }

View File

@ -5,6 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
@ -18,6 +19,7 @@ import { AdminService } from './admin.service';
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule,
SubscriptionModule SubscriptionModule
], ],
controllers: [AdminController], controllers: [AdminController],

View File

@ -4,13 +4,16 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { baseCurrency } from '@ghostfolio/common/config'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails AdminMarketDataDetails,
AdminMarketDataItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()
@ -21,6 +24,7 @@ export class AdminService {
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
@ -45,6 +49,7 @@ export class AdminService {
}; };
}), }),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(), transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(), userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics() users: await this.getUsersWithAnalytics()
@ -52,12 +57,67 @@ export class AdminService {
} }
public async getMarketData(): Promise<AdminMarketData> { public async getMarketData(): Promise<AdminMarketData> {
return { const marketData = await this.prismaService.marketData.groupBy({
marketData: await ( _count: true,
await this.dataGatheringService.getSymbolsMax() by: ['dataSource', 'symbol']
).map((symbol) => { });
return symbol;
const currencyPairsToGather: AdminMarketDataItem[] =
this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol
};
});
const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
},
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
}) })
).map((symbolProfile) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === symbolProfile.dataSource &&
marketDataItem.symbol === symbolProfile.symbol
);
})?._count ?? 0;
return {
marketDataItemCount,
activityCount: symbolProfile._count.Order,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol
};
});
return {
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
}; };
} }
@ -76,6 +136,23 @@ export class AdminService {
}; };
} }
public async putSetting(key: string, value: string) {
let response: Property;
if (value === '') {
response = await this.propertyService.delete({ key });
} else {
response = await this.propertyService.put({ key, value });
}
if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize();
await this.dataGatheringService.reset();
}
return response;
}
private async getLastDataGathering() { private async getLastDataGathering() {
const lastDataGathering = const lastDataGathering =
await this.dataGatheringService.getLastDataGathering(); await this.dataGatheringService.getLastDataGathering();

View File

@ -0,0 +1,6 @@
import { IsNumber } from 'class-validator';
export class UpdateMarketDataDto {
@IsNumber()
marketPrice: number;
}

View File

@ -1,9 +1,5 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -29,7 +25,7 @@ export class AuthDeviceController {
public async deleteAuthDevice(@Param('id') id: string): Promise<void> { public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.deleteAuthDevice permissions.deleteAuthDevice
) )
) { ) {

View File

@ -23,7 +23,7 @@ export class AuthDeviceService {
take?: number; take?: number;
cursor?: Prisma.AuthDeviceWhereUniqueInput; cursor?: Prisma.AuthDeviceWhereUniqueInput;
where?: Prisma.AuthDeviceWhereInput; where?: Prisma.AuthDeviceWhereInput;
orderBy?: Prisma.AuthDeviceOrderByInput; orderBy?: Prisma.AuthDeviceOrderByWithRelationInput;
}): Promise<AuthDevice[]> { }): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.authDevice.findMany({ return this.prismaService.authDevice.findMany({

View File

@ -1,7 +1,7 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -19,7 +19,8 @@ import { JwtStrategy } from './jwt.strategy';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}), }),
SubscriptionModule SubscriptionModule,
UserModule
], ],
providers: [ providers: [
AuthDeviceService, AuthDeviceService,
@ -28,7 +29,6 @@ import { JwtStrategy } from './jwt.strategy';
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
PrismaService, PrismaService,
UserService,
WebAuthService WebAuthService
] ]
}) })

View File

@ -1,5 +1,5 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@ -15,10 +15,11 @@ import { ImportService } from './import.service';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
OrderModule,
PrismaModule, PrismaModule,
RedisCacheModule RedisCacheModule
], ],
controllers: [ImportController], controllers: [ImportController],
providers: [CacheService, ImportService, OrderService] providers: [CacheService, ImportService]
}) })
export class ImportModule {} export class ImportModule {}

View File

@ -1,4 +1,5 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client'; import { Order } from '@prisma/client';
@ -6,9 +7,8 @@ import { isSameDay, parseISO } from 'date-fns';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
private static MAX_ORDERS_TO_IMPORT = 20;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService private readonly orderService: OrderService
) {} ) {}
@ -34,11 +34,6 @@ export class ImportService {
unitPrice unitPrice
} of orders) { } of orders) {
await this.orderService.createOrder({ await this.orderService.createOrder({
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
currency, currency,
dataSource, dataSource,
fee, fee,
@ -46,7 +41,26 @@ export class ImportService {
symbol, symbol,
type, type,
unitPrice, unitPrice,
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
date: parseISO(<string>(<unknown>date)), date: parseISO(<string>(<unknown>date)),
SymbolProfile: {
connectOrCreate: {
create: {
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
},
User: { connect: { id: userId } } User: { connect: { id: userId } }
}); });
} }
@ -59,8 +73,14 @@ export class ImportService {
orders: Partial<Order>[]; orders: Partial<Order>[];
userId: string; userId: string;
}) { }) {
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) { if (
throw new Error('Too many transactions'); orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) {
throw new Error(
`Too many transactions (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT'
)} at most)`
);
} }
const existingOrders = await this.orderService.orders({ const existingOrders = await this.orderService.orders({
@ -98,6 +118,12 @@ export class ImportService {
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (result[symbol].currency !== currency) {
throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
);
}
} }
} }
} }

View File

@ -5,6 +5,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -21,6 +22,7 @@ import { InfoService } from './info.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule SymbolProfileModule
], ],

View File

@ -4,6 +4,14 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE
} from '@ghostfolio/common/config';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
@ -15,7 +23,6 @@ import { subDays } from 'date-fns';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
private static CACHE_KEY_STATISTICS = 'STATISTICS'; private static CACHE_KEY_STATISTICS = 'STATISTICS';
public constructor( public constructor(
@ -25,15 +32,18 @@ export class InfoService {
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {}; const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
const platforms = await this.prismaService.platform.findMany({ const platforms = await this.prismaService.platform.findMany({
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { id: true, name: true } select: { id: true, name: true }
}); });
let systemMessage: string;
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
@ -45,6 +55,12 @@ export class InfoService {
globalPermissions.push(permissions.enableImport); globalPermissions.push(permissions.enableImport);
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin); globalPermissions.push(permissions.enableSocialLogin);
} }
@ -59,10 +75,20 @@ export class InfoService {
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY'); info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
globalPermissions.push(permissions.enableSystemMessage);
systemMessage = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as string;
}
return { return {
...info, ...info,
globalPermissions, globalPermissions,
isReadOnlyMode,
platforms, platforms,
systemMessage,
currencies: this.exchangeRateDataService.getCurrencies(), currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
@ -162,9 +188,15 @@ export class InfoService {
}); });
} }
private async countSlackCommunityUsers() {
return (await this.propertyService.getByKey(
PROPERTY_SLACK_COMMUNITY_USERS
)) as string;
}
private getDemoAuthToken() { private getDemoAuthToken() {
return this.jwtService.sign({ return this.jwtService.sign({
id: InfoService.DEMO_USER_ID id: DEMO_USER_ID
}); });
} }
@ -193,19 +225,19 @@ export class InfoService {
} catch {} } catch {}
const activeUsers1d = await this.countActiveUsers(1); const activeUsers1d = await this.countActiveUsers(1);
const activeUsers7d = await this.countActiveUsers(7);
const activeUsers30d = await this.countActiveUsers(30); const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30); const newUsers30d = await this.countNewUsers(30);
const gitHubContributors = await this.countGitHubContributors(); const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers(); const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
statistics = { statistics = {
activeUsers1d, activeUsers1d,
activeUsers7d,
activeUsers30d, activeUsers30d,
gitHubContributors, gitHubContributors,
gitHubStargazers, gitHubStargazers,
newUsers30d newUsers30d,
slackCommunityUsers
}; };
await this.redisCacheService.set( await this.redisCacheService.set(
@ -222,7 +254,7 @@ export class InfoService {
} }
const stripeConfig = await this.prismaService.property.findUnique({ const stripeConfig = await this.prismaService.property.findUnique({
where: { key: 'STRIPE_CONFIG' } where: { key: PROPERTY_STRIPE_CONFIG }
}); });
if (stripeConfig) { if (stripeConfig) {

View File

@ -0,0 +1,10 @@
import { OrderWithAccount } from '@ghostfolio/common/types';
export interface Activities {
activities: Activity[];
}
export interface Activity extends OrderWithAccount {
feeInBaseCurrency: number;
valueInBaseCurrency: number;
}

View File

@ -1,11 +1,7 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -27,6 +23,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto'; import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
import { OrderService } from './order.service'; import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto'; import { UpdateOrderDto } from './update-order.dto';
@ -43,10 +40,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteOrder)
getPermissions(this.request.user.role),
permissions.deleteOrder
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -66,38 +60,35 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getAllOrders( public async getAllOrders(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> { ): Promise<Activities> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(
impersonationId, impersonationId,
this.request.user.id this.request.user.id
); );
const userCurrency = this.request.user.Settings.currency;
let orders = await this.orderService.orders({ let activities = await this.orderService.getOrders({
include: { userCurrency,
Account: { includeDrafts: true,
include: { userId: impersonationUserId || this.request.user.id
Platform: true
}
},
SymbolProfile: {
select: {
name: true
}
}
},
orderBy: { date: 'desc' },
where: { userId: impersonationUserId || this.request.user.id }
}); });
if ( if (
impersonationUserId || impersonationUserId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']); activities = nullifyValuesInObjects(activities, [
'fee',
'feeInBaseCurrency',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
]);
} }
return orders; return { activities };
} }
@Get(':id') @Get(':id')
@ -115,10 +106,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createOrder)
getPermissions(this.request.user.role),
permissions.createOrder
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -133,23 +121,23 @@ export class OrderController {
return this.orderService.createOrder({ return this.orderService.createOrder({
...data, ...data,
date,
Account: { Account: {
connect: { connect: {
id_userId: { id: accountId, userId: this.request.user.id } id_userId: { id: accountId, userId: this.request.user.id }
} }
}, },
date,
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: {
dataSource: data.dataSource,
symbol: data.symbol
},
where: { where: {
dataSource_symbol: { dataSource_symbol: {
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
} }
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
} }
} }
}, },
@ -161,10 +149,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.updateOrder)
getPermissions(this.request.user.role),
permissions.updateOrder
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -4,6 +4,7 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -16,6 +17,7 @@ import { OrderService } from './order.service';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,

View File

@ -1,15 +1,20 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma } from '@prisma/client'; import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { Activity } from './interfaces/activities.interface';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
public constructor( public constructor(
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -28,7 +33,7 @@ export class OrderService {
take?: number; take?: number;
cursor?: Prisma.OrderWhereUniqueInput; cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput; where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByInput; orderBy?: Prisma.OrderOrderByWithRelationInput;
}): Promise<OrderWithAccount[]> { }): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
@ -45,19 +50,22 @@ export class OrderService {
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> { public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
// Convert the symbol to uppercase to avoid case-sensitive duplicates
const symbol = data.symbol.toUpperCase();
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
symbol,
dataSource: data.dataSource, dataSource: data.dataSource,
date: <Date>data.date, date: <Date>data.date
symbol: data.symbol
} }
]); ]);
} }
this.dataGatheringService.gatherProfileData([ this.dataGatheringService.gatherProfileData([
{ dataSource: data.dataSource, symbol: data.symbol } { symbol, dataSource: data.dataSource }
]); ]);
await this.cacheService.flush(); await this.cacheService.flush();
@ -65,7 +73,8 @@ export class OrderService {
return this.prismaService.order.create({ return this.prismaService.order.create({
data: { data: {
...data, ...data,
isDraft isDraft,
symbol
} }
}); });
} }
@ -78,28 +87,65 @@ export class OrderService {
}); });
} }
public getOrders({ public async getOrders({
includeDrafts = false, includeDrafts = false,
types,
userCurrency,
userId userId
}: { }: {
includeDrafts?: boolean; includeDrafts?: boolean;
types?: TypeOfOrder[];
userCurrency: string;
userId: string; userId: string;
}) { }): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
if (includeDrafts === false) { if (includeDrafts === false) {
where.isDraft = false; where.isDraft = false;
} }
return this.orders({ if (types) {
where, where.OR = types.map((type) => {
include: { return {
// eslint-disable-next-line @typescript-eslint/naming-convention type: {
Account: true, equals: type
// eslint-disable-next-line @typescript-eslint/naming-convention }
SymbolProfile: true };
}, });
orderBy: { date: 'asc' } }
return (
await this.orders({
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: {
include: {
Platform: true
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' }
})
).map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.currency,
userCurrency
)
};
}); });
} }

View File

@ -73,7 +73,7 @@ describe('CurrentRateService', () => {
beforeAll(async () => { beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null); dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null); exchangeRateDataService = new ExchangeRateDataService(null, null, null);
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize(); await exchangeRateDataService.initialize();
@ -85,19 +85,6 @@ describe('CurrentRateService', () => {
); );
}); });
it('getValue', async () => {
expect(
await currentRateService.getValue({
currency: 'USD',
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
symbol: 'AMZN',
userCurrency: 'CHF'
})
).toMatchObject({
marketPrice: 1847.839966
});
});
it('getValues', async () => { it('getValues', async () => {
expect( expect(
await currentRateService.getValues({ await currentRateService.getValues({

View File

@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash'; import { flatten } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable() @Injectable()
@ -18,46 +17,6 @@ export class CurrentRateService {
private readonly marketDataService: MarketDataService private readonly marketDataService: MarketDataService
) {} ) {}
public async getValue({
currency,
date,
symbol,
userCurrency
}: GetValueParams): Promise<GetValueObject> {
if (isToday(date)) {
const dataProviderResult = await this.dataProviderService.get([
{
symbol,
dataSource: this.dataProviderService.getPrimaryDataSource()
}
]);
return {
symbol,
date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
};
}
const marketData = await this.marketDataService.get({
date,
symbol
});
if (marketData) {
return {
date: marketData.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketData.marketPrice,
currency,
userCurrency
),
symbol: marketData.symbol
};
}
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
}
public async getValues({ public async getValues({
currencies, currencies,
dataGatheringItems, dataGatheringItems,

View File

@ -1,6 +0,0 @@
export interface GetValueParams {
currency: string;
date: Date;
symbol: string;
userCurrency: string;
}

View File

@ -1,5 +1,4 @@
import { OrderType } from '@ghostfolio/api/models/order-type'; import { DataSource, Type as TypeOfOrder } from '@prisma/client';
import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
export interface PortfolioOrder { export interface PortfolioOrder {
@ -10,6 +9,6 @@ export interface PortfolioOrder {
name: string; name: string;
quantity: Big; quantity: Big;
symbol: string; symbol: string;
type: OrderType; type: TypeOfOrder;
unitPrice: Big; unitPrice: Big;
} }

View File

@ -1,3 +1,4 @@
import { OrderWithAccount } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass } from '@prisma/client'; import { AssetClass, AssetSubClass } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
@ -16,6 +17,7 @@ export interface PortfolioPositionDetail {
name: string; name: string;
netPerformance: number; netPerformance: number;
netPerformancePercent: number; netPerformancePercent: number;
orders: OrderWithAccount[];
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number; transactionCount: number;

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,9 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface'; import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { OrderType } from '@ghostfolio/api/models/order-type';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
addDays, addDays,
@ -238,9 +238,7 @@ export class PortfolioCalculator {
if (!marketSymbolMap[nextDate]?.[item.symbol]) { if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol); invalidSymbols.push(item.symbol);
hasErrors = true; hasErrors = true;
Logger.error( Logger.warn(`Missing value for symbol ${item.symbol} at ${nextDate}`);
`Missing value for symbol ${item.symbol} at ${nextDate}`
);
continue; continue;
} }
let lastInvestment: Big = new Big(0); let lastInvestment: Big = new Big(0);
@ -271,7 +269,7 @@ export class PortfolioCalculator {
if (!initialValue) { if (!initialValue) {
invalidSymbols.push(item.symbol); invalidSymbols.push(item.symbol);
hasErrors = true; hasErrors = true;
Logger.error( Logger.warn(
`Missing value for symbol ${item.symbol} at ${currentDate}` `Missing value for symbol ${item.symbol} at ${currentDate}`
); );
continue; continue;
@ -515,7 +513,7 @@ export class PortfolioCalculator {
currentPosition.netPerformancePercentage.mul(currentInitialValue) currentPosition.netPerformancePercentage.mul(currentInitialValue)
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.error( Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}` `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
); );
hasErrors = true; hasErrors = true;
@ -660,14 +658,14 @@ export class PortfolioCalculator {
}; };
} }
private getFactor(type: OrderType) { private getFactor(type: TypeOfOrder) {
let factor: number; let factor: number;
switch (type) { switch (type) {
case OrderType.Buy: case 'BUY':
factor = 1; factor = 1;
break; break;
case OrderType.Sell: case 'SELL':
factor = -1; factor = -1;
break; break;
default: default:

View File

@ -10,12 +10,12 @@ import { baseCurrency } from '@ghostfolio/common/config';
import { import {
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance, PortfolioPerformance,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -48,46 +48,10 @@ export class PortfolioController {
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async findAll(
@Headers('impersonation-id') impersonationId,
@Res() res: Response
): Promise<InvestmentItem[]> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json([]);
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
investments = investments.map((item) => ({
date: item.date,
investment: item.investment / maxInvestment
}));
}
return <any>res.json(investments);
}
@Get('chart') @Get('chart')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getChart( public async getChart(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioChart> { ): Promise<PortfolioChart> {
@ -98,18 +62,14 @@ export class PortfolioController {
let chartData = historicalDataContainer.items; let chartData = historicalDataContainer.items;
let hasNullValue = false; let hasError = false;
chartData.forEach((chartDataItem) => { chartData.forEach((chartDataItem) => {
if (hasNotDefinedValuesInObject(chartDataItem)) { if (hasNotDefinedValuesInObject(chartDataItem)) {
hasNullValue = true; hasError = true;
} }
}); });
if (hasNullValue) {
res.status(StatusCodes.ACCEPTED);
}
if ( if (
impersonationId || impersonationId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
@ -131,6 +91,7 @@ export class PortfolioController {
} }
return <any>res.json({ return <any>res.json({
hasError,
chart: chartData, chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh, isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow isAllTimeLow: historicalDataContainer.isAllTimeLow
@ -140,7 +101,7 @@ export class PortfolioController {
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getDetails( public async getDetails(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioDetails> { ): Promise<PortfolioDetails> {
@ -152,6 +113,8 @@ export class PortfolioController {
return <any>res.json({ accounts: {}, holdings: {} }); return <any>res.json({ accounts: {}, holdings: {} });
} }
let hasError = false;
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails( await this.portfolioService.getDetails(
impersonationId, impersonationId,
@ -160,7 +123,7 @@ export class PortfolioController {
); );
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
res.status(StatusCodes.ACCEPTED); hasError = true;
} }
if ( if (
@ -198,43 +161,74 @@ export class PortfolioController {
} }
} }
return <any>res.json({ accounts, holdings }); return <any>res.json({ accounts, hasError, holdings });
}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string,
@Res() res: Response
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({});
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
investments = investments.map((item) => ({
date: item.date,
investment: item.investment / maxInvestment
}));
}
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
} }
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPerformance( public async getPerformance(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioPerformance> { ): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const performanceInformation = await this.portfolioService.getPerformance( const performanceInformation = await this.portfolioService.getPerformance(
impersonationId, impersonationId,
range range
); );
if (performanceInformation?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
let performance = performanceInformation.performance;
if ( if (
impersonationId || impersonationId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
performance = nullifyValuesInObject(performance, [ performanceInformation.performance = nullifyValuesInObject(
'currentGrossPerformance', performanceInformation.performance,
'currentValue' ['currentGrossPerformance', 'currentValue']
]); );
} }
return <any>res.json(performance); return <any>res.json(performanceInformation);
} }
@Get('positions') @Get('positions')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPositions( public async getPositions(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioPositions> { ): Promise<PortfolioPositions> {
@ -243,10 +237,6 @@ export class PortfolioController {
range range
); );
if (result?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
if ( if (
impersonationId || impersonationId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
@ -340,6 +330,7 @@ export class PortfolioController {
'currentGrossPerformance', 'currentGrossPerformance',
'currentNetPerformance', 'currentNetPerformance',
'currentValue', 'currentValue',
'dividend',
'fees', 'fees',
'netWorth', 'netWorth',
'totalBuy', 'totalBuy',
@ -353,7 +344,7 @@ export class PortfolioController {
@Get('position/:symbol') @Get('position/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPosition( public async getPosition(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Param('symbol') symbol @Param('symbol') symbol
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition( let position = await this.portfolioService.getPosition(
@ -370,6 +361,7 @@ export class PortfolioController {
'grossPerformance', 'grossPerformance',
'investment', 'investment',
'netPerformance', 'netPerformance',
'orders',
'quantity', 'quantity',
'value' 'value'
]); ]);
@ -387,7 +379,7 @@ export class PortfolioController {
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getReport( public async getReport(
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId: string,
@Res() res: Response @Res() res: Response
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {
if ( if (

View File

@ -6,7 +6,6 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface'; import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
import { OrderType } from '@ghostfolio/api/models/order-type';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment'; import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
@ -21,11 +20,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
UNKNOWN_KEY,
baseCurrency,
ghostfolioCashSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts, Accounts,
@ -60,7 +55,7 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty, sortBy } from 'lodash';
import { import {
HistoricalDataContainer, HistoricalDataContainer,
@ -112,7 +107,7 @@ export class PortfolioService {
account.currency, account.currency,
userCurrency userCurrency
), ),
value: details.accounts[account.name]?.current ?? 0 value: details.accounts[account.id]?.current ?? 0
}; };
delete result.Order; delete result.Order;
@ -155,12 +150,33 @@ export class PortfolioService {
return []; return [];
} }
return portfolioCalculator.getInvestments().map((item) => { const investments = portfolioCalculator.getInvestments().map((item) => {
return { return {
date: item.date, date: item.date,
investment: item.investment.toNumber() investment: item.investment.toNumber()
}; };
}); });
// Add investment of today
const investmentOfToday = investments.filter((investment) => {
return investment.date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter((investment) => {
return isBefore(parseDate(investment.date), new Date());
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
});
}
return sortBy(investments, (investment) => {
return investment.date;
});
} }
public async getChart( public async getChart(
@ -347,13 +363,17 @@ export class PortfolioService {
}; };
} }
// TODO: Add a cash position for each currency const cashPositions = await this.getCashPositions({
holdings[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails, cashDetails,
userCurrency,
investment: totalInvestment, investment: totalInvestment,
value: totalValue value: totalValue
}); });
for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
const accounts = await this.getValueOfAccounts( const accounts = await this.getValueOfAccounts(
orders, orders,
portfolioItemsNow, portfolioItemsNow,
@ -368,11 +388,12 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const orders = (await this.orderService.getOrders({ userId })).filter( const orders = (
(order) => order.symbol === aSymbol await this.orderService.getOrders({ userCurrency, userId })
); ).filter((order) => order.symbol === aSymbol);
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
@ -389,6 +410,7 @@ export class PortfolioService {
name: undefined, name: undefined,
netPerformance: undefined, netPerformance: undefined,
netPerformancePercent: undefined, netPerformancePercent: undefined,
orders: [],
quantity: undefined, quantity: undefined,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined, transactionCount: undefined,
@ -401,17 +423,21 @@ export class PortfolioService {
const positionCurrency = orders[0].currency; const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? ''; const name = orders[0].SymbolProfile?.name ?? '';
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders
currency: order.currency, .filter((order) => {
dataSource: order.dataSource, return order.type === 'BUY' || order.type === 'SELL';
date: format(order.date, DATE_FORMAT), })
fee: new Big(order.fee), .map((order) => ({
name: order.SymbolProfile?.name, currency: order.currency,
quantity: new Big(order.quantity), dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
symbol: order.symbol, date: format(order.date, DATE_FORMAT),
type: <OrderType>order.type, fee: new Big(order.fee),
unitPrice: new Big(order.unitPrice) name: order.SymbolProfile?.name,
})); quantity: new Big(order.quantity),
symbol: order.symbol,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
this.currentRateService, this.currentRateService,
@ -443,17 +469,17 @@ export class PortfolioService {
// Convert investment, gross and net performance to currency of user // Convert investment, gross and net performance to currency of user
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency( const investment = this.exchangeRateDataService.toCurrency(
position.investment.toNumber(), position.investment?.toNumber(),
currency, currency,
userCurrency userCurrency
); );
const grossPerformance = this.exchangeRateDataService.toCurrency( const grossPerformance = this.exchangeRateDataService.toCurrency(
position.grossPerformance.toNumber(), position.grossPerformance?.toNumber(),
currency, currency,
userCurrency userCurrency
); );
const netPerformance = this.exchangeRateDataService.toCurrency( const netPerformance = this.exchangeRateDataService.toCurrency(
position.netPerformance.toNumber(), position.netPerformance?.toNumber(),
currency, currency,
userCurrency userCurrency
); );
@ -522,6 +548,7 @@ export class PortfolioService {
minPrice, minPrice,
name, name,
netPerformance, netPerformance,
orders,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(), grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
@ -579,6 +606,7 @@ export class PortfolioService {
maxPrice, maxPrice,
minPrice, minPrice,
name, name,
orders,
averagePrice: 0, averagePrice: 0,
currency: currentData[aSymbol]?.currency, currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined, firstBuyDate: undefined,
@ -656,7 +684,9 @@ export class PortfolioService {
grossPerformancePercentage: grossPerformancePercentage:
position.grossPerformancePercentage?.toNumber() ?? null, position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(), investment: new Big(position.investment).toNumber(),
marketState: dataProviderResponses[position.symbol].marketState, marketState:
dataProviderResponses[position.symbol]?.marketState ??
MarketState.delayed,
name: symbolProfileMap[position.symbol].name, name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null, netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage: netPerformancePercentage:
@ -689,9 +719,7 @@ export class PortfolioService {
currentGrossPerformancePercent: 0, currentGrossPerformancePercent: 0,
currentNetPerformance: 0, currentNetPerformance: 0,
currentNetPerformancePercent: 0, currentNetPerformancePercent: 0,
currentValue: 0, currentValue: 0
isAllTimeHigh: false,
isAllTimeLow: false
} }
}; };
} }
@ -724,29 +752,11 @@ export class PortfolioService {
currentGrossPerformancePercent, currentGrossPerformancePercent,
currentNetPerformance, currentNetPerformance,
currentNetPerformancePercent, currentNetPerformancePercent,
currentValue, currentValue
isAllTimeHigh: true, // TODO
isAllTimeLow: false // TODO
} }
}; };
} }
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(impersonationId: string): Promise<PortfolioReport> {
const currency = this.request.user.Settings.currency; const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
@ -827,7 +837,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment( new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(), currentPositions.totalInvestment.toNumber(),
this.getFees(orders) this.getFees(orders).toNumber()
) )
], ],
{ baseCurrency: currency } { baseCurrency: currency }
@ -837,21 +847,25 @@ export class PortfolioService {
} }
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> { public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const currency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const performanceInformation = await this.getPerformance(aImpersonationId); const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails( const { balance } = await this.accountService.getCashDetails(
userId, userId,
currency userCurrency
); );
const orders = await this.orderService.getOrders({ userId }); const orders = await this.orderService.getOrders({
const fees = this.getFees(orders); userCurrency,
userId
});
const dividend = this.getDividend(orders).toNumber();
const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date; const firstOrderDate = orders[0]?.date;
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY); const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL); const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const committedFunds = new Big(totalBuy).sub(totalSell); const committedFunds = new Big(totalBuy).sub(totalSell);
@ -861,49 +875,128 @@ export class PortfolioService {
return { return {
...performanceInformation.performance, ...performanceInformation.performance,
dividend,
fees, fees,
firstOrderDate, firstOrderDate,
netWorth, netWorth,
totalBuy,
totalSell,
cash: balance, cash: balance,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
ordersCount: orders.length, ordersCount: orders.filter((order) => {
totalBuy: totalBuy, return order.type === 'BUY' || order.type === 'SELL';
totalSell: totalSell }).length
}; };
} }
private async getCashPosition({ private async getCashPositions({
cashDetails, cashDetails,
investment, investment,
userCurrency,
value value
}: { }: {
cashDetails: CashDetails; cashDetails: CashDetails;
investment: Big; investment: Big;
userCurrency: string;
value: Big; value: Big;
}) { }) {
const cashValue = new Big(cashDetails.balance); const cashPositions = {};
return { for (const account of cashDetails.accounts) {
allocationCurrent: cashValue.div(value).toNumber(), const convertedBalance = this.exchangeRateDataService.toCurrency(
allocationInvestment: cashValue.div(investment).toNumber(), account.balance,
assetClass: AssetClass.CASH, account.currency,
assetSubClass: AssetClass.CASH, userCurrency
countries: [], );
currency: 'CHF',
grossPerformance: 0, if (convertedBalance === 0) {
grossPerformancePercent: 0, continue;
investment: cashValue.toNumber(), }
marketPrice: 0,
marketState: MarketState.open, if (cashPositions[account.currency]) {
name: 'Cash', cashPositions[account.currency].investment += convertedBalance;
netPerformance: 0, cashPositions[account.currency].value += convertedBalance;
netPerformancePercent: 0, } else {
quantity: 0, cashPositions[account.currency] = {
sectors: [], allocationCurrent: 0,
symbol: ghostfolioCashSymbol, allocationInvestment: 0,
transactionCount: 0, assetClass: AssetClass.CASH,
value: cashValue.toNumber() assetSubClass: AssetClass.CASH,
}; countries: [],
currency: account.currency,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: convertedBalance,
marketPrice: 0,
marketState: MarketState.open,
name: account.currency,
netPerformance: 0,
netPerformancePercent: 0,
quantity: 0,
sectors: [],
symbol: account.currency,
transactionCount: 0,
value: convertedBalance
};
}
}
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = new Big(
cashPositions[symbol].value
)
.div(value)
.toNumber();
cashPositions[symbol].allocationInvestment = new Big(
cashPositions[symbol].investment
)
.div(investment)
.toNumber();
}
return cashPositions;
}
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date and type dividend
return (
isBefore(date, new Date(order.date)) &&
order.type === TypeOfOrder.DIVIDEND
);
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
} }
private getStartDate(aDateRange: DateRange, portfolioStart: Date) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
@ -934,16 +1027,22 @@ export class PortfolioService {
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
orders: OrderWithAccount[]; orders: OrderWithAccount[];
}> { }> {
const orders = await this.orderService.getOrders({ includeDrafts, userId }); const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const orders = await this.orderService.getOrders({
includeDrafts,
userCurrency,
userId,
types: ['BUY', 'SELL']
});
if (orders.length <= 0) { if (orders.length <= 0) {
return { transactionPoints: [], orders: [] }; return { transactionPoints: [], orders: [] };
} }
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
fee: new Big( fee: new Big(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
@ -955,7 +1054,7 @@ export class PortfolioService {
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.symbol, symbol: order.symbol,
type: <OrderType>order.type, type: order.type,
unitPrice: new Big( unitPrice: new Big(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
order.unitPrice, order.unitPrice,
@ -996,8 +1095,11 @@ export class PortfolioService {
account.currency, account.currency,
userCurrency userCurrency
); );
accounts[account.name] = { accounts[account.id] = {
balance: convertedBalance,
currency: account.currency,
current: convertedBalance, current: convertedBalance,
name: account.name,
original: convertedBalance original: convertedBalance
}; };
@ -1011,14 +1113,17 @@ export class PortfolioService {
originalValueOfSymbol *= -1; originalValueOfSymbol *= -1;
} }
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) { if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current += accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol; currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original += accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol; originalValueOfSymbol;
} else { } else {
accounts[order.Account?.name || UNKNOWN_KEY] = { accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
current: currentValueOfSymbol, current: currentValueOfSymbol,
name: account.name,
original: originalValueOfSymbol original: originalValueOfSymbol
}; };
} }

View File

@ -1,4 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -14,6 +17,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@ -22,16 +26,70 @@ import { SubscriptionService } from './subscription.service';
export class SubscriptionController { export class SubscriptionController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
@Post('redeem-coupon')
@UseGuards(AuthGuard('jwt'))
public async redeemCoupon(
@Body() { couponCode }: { couponCode: string },
@Res() res: Response
) {
if (!this.request.user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let coupons =
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
[];
const isValid = coupons.some((coupon) => {
return coupon.code === couponCode;
});
if (!isValid) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.subscriptionService.createSubscription(this.request.user.id);
// Destroy coupon
coupons = coupons.filter((coupon) => {
return coupon.code !== couponCode;
});
await this.propertyService.put({
key: PROPERTY_COUPONS,
value: JSON.stringify(coupons)
});
Logger.log(
`Subscription for user '${this.request.user.id}' has been created with coupon`
);
res.status(StatusCodes.OK);
return <any>res.json({
message: getReasonPhrase(StatusCodes.OK),
statusCode: StatusCodes.OK
});
}
@Get('stripe/callback') @Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) { public async stripeCallback(@Req() req, @Res() res) {
await this.subscriptionService.createSubscription( const userId = await this.subscriptionService.createSubscriptionViaStripe(
req.query.checkoutSessionId req.query.checkoutSessionId
); );
Logger.log(`Subscription for user '${userId}' has been created via Stripe`);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`); res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
} }

View File

@ -1,12 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller'; import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@Module({ @Module({
imports: [], imports: [PropertyModule],
controllers: [SubscriptionController], controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService], providers: [ConfigurationService, PrismaService, SubscriptionService],
exports: [SubscriptionService] exports: [SubscriptionService]

View File

@ -2,7 +2,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client'; import { Subscription, User } from '@prisma/client';
import { addDays, isBefore } from 'date-fns'; import { addDays, isBefore } from 'date-fns';
import Stripe from 'stripe'; import Stripe from 'stripe';
@ -64,26 +64,32 @@ export class SubscriptionService {
}; };
} }
public async createSubscription(aCheckoutSessionId: string) { public async createSubscription(aUserId: string) {
await this.prismaService.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: aUserId
}
}
}
});
}
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try { try {
const session = await this.stripe.checkout.sessions.retrieve( const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId aCheckoutSessionId
); );
await this.prismaService.subscription.create({ await this.createSubscription(session.client_reference_id);
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: session.client_reference_id
}
}
}
});
await this.stripe.customers.update(session.customer as string, { await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id description: session.client_reference_id
}); });
return session.client_reference_id;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);
} }

View File

@ -1,7 +1,9 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem {
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
historicalData: HistoricalDataItem[];
marketPrice: number; marketPrice: number;
} }

View File

@ -1,10 +1,13 @@
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
DefaultValuePipe,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseBoolPipe,
Query, Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
@ -12,7 +15,7 @@ import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -34,8 +37,7 @@ export class SymbolController {
@Query() { query = '' } @Query() { query = '' }
): Promise<{ items: LookupItem[] }> { ): Promise<{ items: LookupItem[] }> {
try { try {
const encodedQuery = encodeURIComponent(query.toLowerCase()); return this.symbolService.lookup(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
@ -51,7 +53,9 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getSymbolData( public async getSymbolData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string,
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
includeHistoricalData: boolean
): Promise<SymbolItem> { ): Promise<SymbolItem> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
@ -60,7 +64,10 @@ export class SymbolController {
); );
} }
const result = await this.symbolService.get({ dataSource, symbol }); const result = await this.symbolService.get({
includeHistoricalData,
dataGatheringItem: { dataSource, symbol }
});
if (!result || isEmpty(result)) { if (!result || isEmpty(result)) {
throw new HttpException( throw new HttpException(
@ -71,4 +78,27 @@ export class SymbolController {
return result; return result;
} }
@Get(':dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString);
if (!isDate(date)) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
return this.symbolService.getForDate({
dataSource,
date,
symbol
});
}
} }

View File

@ -1,5 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,12 @@ import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Module({ @Module({
imports: [ConfigurationModule, DataProviderModule, PrismaModule], imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
],
controllers: [SymbolController], controllers: [SymbolController],
providers: [SymbolService] providers: [SymbolService]
}) })

View File

@ -1,8 +1,15 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import {
IDataGatheringItem,
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -11,16 +18,42 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService { export class SymbolService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> { public async get({
dataGatheringItem,
includeHistoricalData = false
}: {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: boolean;
}): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]); const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {}; const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) { if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[];
if (includeHistoricalData) {
const days = 30;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
symbols: [dataGatheringItem.symbol]
});
historicalData = marketData.map(({ date, marketPrice: value }) => {
return {
value,
date: date.toISOString()
};
});
}
return { return {
currency, currency,
historicalData,
marketPrice, marketPrice,
dataSource: dataGatheringItem.dataSource dataSource: dataGatheringItem.dataSource
}; };
@ -29,6 +62,27 @@ export class SymbolService {
return undefined; return undefined;
} }
public async getForDate({
dataSource,
date,
symbol
}: {
dataSource: DataSource;
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
date,
date
);
return {
marketPrice:
historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
};
}
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> { public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] }; const results: { items: LookupItem[] } = { items: [] };
@ -39,32 +93,6 @@ export class SymbolService {
try { try {
const { items } = await this.dataProviderService.search(aQuery); const { items } = await this.dataProviderService.search(aQuery);
results.items = items; results.items = items;
// Add custom symbols
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
AND: [
{
dataSource: DataSource.GHOSTFOLIO,
name: {
startsWith: aQuery
}
}
]
}
});
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
results.items.push(ghostfolioSymbolProfile);
}
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);

View File

@ -1,7 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { import {
getPermissions,
hasPermission, hasPermission,
hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -20,7 +23,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider, Role } from '@prisma/client';
import { User as UserModel } from '@prisma/client'; import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -34,7 +37,9 @@ import { UserService } from './user.service';
@Controller('user') @Controller('user')
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private jwtService: JwtService, private jwtService: JwtService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -43,10 +48,7 @@ export class UserController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteUser(@Param('id') id: string): Promise<UserModel> { public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteUser) ||
getPermissions(this.request.user.role),
permissions.deleteUser
) ||
id === this.request.user.id id === this.request.user.id
) { ) {
throw new HttpException( throw new HttpException(
@ -68,6 +70,19 @@ export class UserController {
@Post() @Post()
public async signupUser(): Promise<UserItem> { public async signupUser(): Promise<UserItem> {
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
const isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
if (isReadOnlyMode) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
const { accessToken, id } = await this.userService.createUser({ const { accessToken, id } = await this.userService.createUser({
provider: Provider.ANONYMOUS provider: Provider.ANONYMOUS
}); });
@ -85,7 +100,7 @@ export class UserController {
public async updateUserSetting(@Body() data: UpdateUserSettingDto) { public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
) )
) { ) {
@ -111,7 +126,7 @@ export class UserController {
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) { public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
) )
) { ) {
@ -127,10 +142,7 @@ export class UserController {
}; };
if ( if (
hasPermission( hasPermission(this.request.user.permissions, permissions.updateViewMode)
getPermissions(this.request.user.role),
permissions.updateViewMode
)
) { ) {
userSettings.viewMode = data.viewMode; userSettings.viewMode = data.viewMode;
} }

View File

@ -1,6 +1,7 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -13,6 +14,7 @@ import { UserService } from './user.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
PropertyModule,
SubscriptionModule SubscriptionModule
], ],
controllers: [UserController], controllers: [UserController],

View File

@ -1,12 +1,21 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { baseCurrency, locale } from '@ghostfolio/common/config'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_IS_READ_ONLY_MODE,
baseCurrency,
locale
} from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions'; import {
getPermissions,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Provider, User, ViewMode } from '@prisma/client'; import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client';
import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface'; import { UserSettings } from './interfaces/user-settings.interface';
@ -20,6 +29,7 @@ export class UserService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
@ -74,12 +84,32 @@ export class UserService {
const user: UserWithSettings = userFromDatabase; const user: UserWithSettings = userFromDatabase;
const currentPermissions = getPermissions(userFromDatabase.role); let currentPermissions = getPermissions(userFromDatabase.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex); currentPermissions.push(permissions.accessFearAndGreedIndex);
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.toggleReadOnlyMode);
}
const isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
if (isReadOnlyMode) {
currentPermissions = currentPermissions.filter((permission) => {
return !(
permission.startsWith('create') ||
permission.startsWith('delete') ||
permission.startsWith('update')
);
});
}
}
user.permissions = currentPermissions; user.permissions = currentPermissions;
if (userFromDatabase?.Settings) { if (userFromDatabase?.Settings) {
@ -119,7 +149,7 @@ export class UserService {
take?: number; take?: number;
cursor?: Prisma.UserWhereUniqueInput; cursor?: Prisma.UserWhereUniqueInput;
where?: Prisma.UserWhereInput; where?: Prisma.UserWhereInput;
orderBy?: Prisma.UserOrderByInput; orderBy?: Prisma.UserOrderByWithRelationInput;
}): Promise<User[]> { }): Promise<User[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.user.findMany({ return this.prismaService.user.findMany({

View File

@ -1,8 +0,0 @@
export enum OrderType {
CorporateAction = 'CORPORATE_ACTION',
Bonus = 'BONUS',
Buy = 'BUY',
Dividend = 'DIVIDEND',
Sell = 'SELL',
Split = 'SPLIT'
}

View File

@ -1,8 +1,7 @@
import { Account, SymbolProfile } from '@prisma/client'; import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
import { OrderType } from './order-type';
export class Order { export class Order {
private account: Account; private account: Account;
@ -15,7 +14,7 @@ export class Order {
private symbol: string; private symbol: string;
private symbolProfile: SymbolProfile; private symbolProfile: SymbolProfile;
private total: number; private total: number;
private type: OrderType; private type: TypeOfOrder;
private unitPrice: number; private unitPrice: number;
public constructor(data: IOrder) { public constructor(data: IOrder) {

View File

@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} = {}; } = {};
for (const account of Object.keys(this.accounts)) { for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[account] = { accounts[accountId] = {
name: account, name: account.name,
investment: this.accounts[account].current investment: account.current
}; };
} }
let maxItem; let maxItem;
let totalInvestment = 0; let totalInvestment = 0;
Object.values(accounts).forEach((account) => { for (const account of Object.values(accounts)) {
if (!maxItem) { if (!maxItem) {
maxItem = account; maxItem = account;
} }
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
if (account.investment > maxItem?.investment) { if (account.investment > maxItem?.investment) {
maxItem = account; maxItem = account;
} }
}); }
const maxInvestmentRatio = maxItem.investment / totalInvestment; const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
} }
public evaluate(ruleSettings?: Settings) { public evaluate(ruleSettings?: Settings) {
const platforms: { const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number; investment: number;
}; };
} = {}; } = {};
for (const account of Object.keys(this.accounts)) { for (const [accountId, account] of Object.entries(this.accounts)) {
platforms[account] = { accounts[accountId] = {
name: account, name: account.name,
investment: this.accounts[account].original investment: account.original
}; };
} }
let maxItem; let maxItem;
let totalInvestment = 0; let totalInvestment = 0;
Object.values(platforms).forEach((platform) => { for (const account of Object.values(accounts)) {
if (!maxItem) { if (!maxItem) {
maxItem = platform; maxItem = account;
} }
// Calculate total investment // Calculate total investment
totalInvestment += platform.investment; totalInvestment += account.investment;
// Find maximum // Find maximum
if (platform.investment > maxItem?.investment) { if (account.investment > maxItem?.investment) {
maxItem = platform; maxItem = account;
} }
}); }
const maxInvestmentRatio = maxItem.investment / totalInvestment; const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { environment } from '../environments/environment';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@Injectable() @Injectable()
@ -14,18 +13,25 @@ export class ConfigurationService {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }), DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_BLOG: bool({ default: false }), ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }), ENABLE_FEATURE_IMPORT: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SECRET: str({ default: 'dummySecret' }),
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str({}),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }), RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),

View File

@ -10,7 +10,7 @@ export class CryptocurrencyService {
public constructor() {} public constructor() {}
public isCrypto(aSymbol = '') { public isCryptocurrency(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3); const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return this.getCryptocurrencies().includes(cryptocurrencySymbol); return this.getCryptocurrencies().includes(cryptocurrencySymbol);
} }

View File

@ -1,7 +1,11 @@
{ {
"1INCH": "1inch", "1INCH": "1inch",
"ALGO": "Algorand", "ALGO": "Algorand",
"ATOM": "Cosmos",
"AVAX": "Avalanche", "AVAX": "Avalanche",
"DOT": "Polkadot",
"MATIC": "Polygon", "MATIC": "Polygon",
"SHIB": "Shiba Inu" "SHIB": "Shiba Inu",
"SOL": "Solana",
"UNI3": "Uniswap"
} }

View File

@ -1,5 +1,8 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import {
PROPERTY_LAST_DATA_GATHERING,
PROPERTY_LOCKED_DATA_GATHERING
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -13,7 +16,6 @@ import {
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data.service';
@ -25,7 +27,6 @@ export class DataGatheringService {
private dataGatheringProgress: number; private dataGatheringProgress: number;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
@Inject('DataEnhancers') @Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[], private readonly dataEnhancers: DataEnhancerInterface[],
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -43,7 +44,7 @@ export class DataGatheringService {
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
} }
}); });
@ -55,11 +56,11 @@ export class DataGatheringService {
await this.prismaService.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
}, },
update: { value: new Date().toISOString() }, update: { value: new Date().toISOString() },
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);
@ -67,7 +68,7 @@ export class DataGatheringService {
await this.prismaService.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: PROPERTY_LOCKED_DATA_GATHERING
} }
}); });
@ -78,7 +79,7 @@ export class DataGatheringService {
public async gatherMax() { public async gatherMax() {
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
@ -87,7 +88,7 @@ export class DataGatheringService {
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
} }
}); });
@ -99,11 +100,11 @@ export class DataGatheringService {
await this.prismaService.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
}, },
update: { value: new Date().toISOString() }, update: { value: new Date().toISOString() },
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);
@ -111,7 +112,7 @@ export class DataGatheringService {
await this.prismaService.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: PROPERTY_LOCKED_DATA_GATHERING
} }
}); });
@ -128,7 +129,7 @@ export class DataGatheringService {
symbol: string; symbol: string;
}) { }) {
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
@ -137,7 +138,7 @@ export class DataGatheringService {
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
} }
}); });
@ -156,11 +157,11 @@ export class DataGatheringService {
await this.prismaService.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
}, },
update: { value: new Date().toISOString() }, update: { value: new Date().toISOString() },
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);
@ -168,7 +169,7 @@ export class DataGatheringService {
await this.prismaService.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: PROPERTY_LOCKED_DATA_GATHERING
} }
}); });
@ -177,6 +178,44 @@ export class DataGatheringService {
} }
} }
public async gatherSymbolForDate({
dataSource,
date,
symbol
}: {
dataSource: DataSource;
date: Date;
symbol: string;
}) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
date,
date
);
const marketPrice =
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
if (marketPrice) {
return await this.prismaService.marketData.upsert({
create: {
dataSource,
date,
marketPrice,
symbol
},
update: { marketPrice },
where: { date_symbol: { date, symbol } }
});
}
} catch (error) {
Logger.error(error);
} finally {
return undefined;
}
}
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) { public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
Logger.log('Profile data gathering has been started.'); Logger.log('Profile data gathering has been started.');
console.time('data-gathering-profile'); console.time('data-gathering-profile');
@ -203,7 +242,7 @@ export class DataGatheringService {
try { try {
currentData[symbol] = await dataEnhancer.enhance({ currentData[symbol] = await dataEnhancer.enhance({
response, response,
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
}); });
} catch (error) { } catch (error) {
Logger.error(`Failed to enhance data for symbol ${symbol}`, error); Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
@ -295,16 +334,25 @@ export class DataGatheringService {
?.marketPrice; ?.marketPrice;
} }
try { if (lastMarketPrice) {
await this.prismaService.marketData.create({ try {
data: { await this.prismaService.marketData.create({
dataSource, data: {
symbol, dataSource,
date: currentDate, symbol,
marketPrice: lastMarketPrice date: currentDate,
} marketPrice: lastMarketPrice
}); }
} catch {} });
} catch {}
} else {
Logger.warn(
`Failed to gather data for symbol ${symbol} at ${format(
currentDate,
DATE_FORMAT
)}.`
);
}
// Count month one up for iteration // Count month one up for iteration
currentDate = new Date( currentDate = new Date(
@ -351,13 +399,13 @@ export class DataGatheringService {
public async getIsInProgress() { public async getIsInProgress() {
return await this.prismaService.property.findUnique({ return await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
} }
public async getLastDataGathering() { public async getLastDataGathering() {
const lastDataGathering = await this.prismaService.property.findUnique({ const lastDataGathering = await this.prismaService.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
if (lastDataGathering?.value) { if (lastDataGathering?.value) {
@ -406,11 +454,7 @@ export class DataGatheringService {
}; };
}); });
return [ return [...currencyPairsToGather, ...symbolProfilesToGather];
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather,
...symbolProfilesToGather
];
} }
public async reset() { public async reset() {
@ -418,28 +462,35 @@ export class DataGatheringService {
await this.prismaService.property.deleteMany({ await this.prismaService.property.deleteMany({
where: { where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }] OR: [
{ key: PROPERTY_LAST_DATA_GATHERING },
{ key: PROPERTY_LOCKED_DATA_GATHERING }
]
} }
}); });
} }
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather: IDataGatheringItem[] = [];
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
benchmarksToGather.push({
dataSource: DataSource.RAKUTEN,
date: startDate,
symbol: ghostfolioFearAndGreedIndexSymbol
});
}
return benchmarksToGather;
}
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
// Only consider symbols with incomplete market data for the last
// 7 days
const symbolsToGather = (
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
where: {
date: { gt: startDate }
}
})
)
.filter((group) => {
return group._count < 6;
})
.map((group) => {
return group.symbol;
});
const symbolProfilesToGather = ( const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({ await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
@ -449,15 +500,22 @@ export class DataGatheringService {
symbol: true symbol: true
} }
}) })
).map((symbolProfile) => { )
return { .filter(({ symbol }) => {
...symbolProfile, return symbolsToGather.includes(symbol);
date: startDate })
}; .map((symbolProfile) => {
}); return {
...symbolProfile,
date: startDate
};
});
const currencyPairsToGather = this.exchangeRateDataService const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs() .getCurrencyPairs()
.filter(({ symbol }) => {
return symbolsToGather.includes(symbol);
})
.map(({ dataSource, symbol }) => { .map(({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
@ -466,37 +524,29 @@ export class DataGatheringService {
}; };
}); });
return [ return [...currencyPairsToGather, ...symbolProfilesToGather];
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather,
...symbolProfilesToGather
];
} }
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> { private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prismaService.order.findMany({ const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true } select: { dataSource: true, symbol: true }
}); });
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter( return distinctOrders.filter((distinctOrder) => {
(distinctOrder) => { return (
return ( distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.GHOSTFOLIO && distinctOrder.dataSource !== DataSource.RAKUTEN
distinctOrder.dataSource !== DataSource.RAKUTEN );
); });
}
);
} }
private async isDataGatheringNeeded() { private async isDataGatheringNeeded() {
const lastDataGathering = await this.getLastDataGathering(); const lastDataGathering = await this.getLastDataGathering();
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
const diffInHours = differenceInHours(new Date(), lastDataGathering); const diffInHours = differenceInHours(new Date(), lastDataGathering);

View File

@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE; return DataSource.ALPHA_VANTAGE;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aSymbol); const result = await this.alphaVantage.data.search(aQuery);
return { return {
items: result?.bestMatches?.map((bestMatch) => { items: result?.bestMatches?.map((bestMatch) => {
return { return {
dataSource: DataSource.ALPHA_VANTAGE, dataSource: this.getName(),
name: bestMatch['2. name'], name: bestMatch['2. name'],
symbol: bestMatch['1. symbol'] symbol: bestMatch['1. symbol']
}; };

View File

@ -7,6 +7,9 @@ const getJSON = bent('json');
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com/holdings'; private static baseUrl = 'https://data.trackinsight.com/holdings';
private static countries = require('countries-list/dist/countries.json'); private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = {
'Russian Federation': 'Russia'
};
private static sectorsMapping = { private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples', 'Consumer Defensive': 'Consumer Staples',
@ -45,7 +48,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
for (const [key, country] of Object.entries<any>( for (const [key, country] of Object.entries<any>(
TrackinsightDataEnhancerService.countries TrackinsightDataEnhancerService.countries
)) { )) {
if (country.name === name) { if (
country.name === name ||
country.name ===
TrackinsightDataEnhancerService.countriesMapping[name]
) {
countryCode = key; countryCode = key;
break; break;
} }

View File

@ -1,6 +1,7 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
@ -21,12 +22,14 @@ import { DataProviderService } from './data-provider.service';
AlphaVantageService, AlphaVantageService,
DataProviderService, DataProviderService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService, YahooFinanceService,
{ {
inject: [ inject: [
AlphaVantageService, AlphaVantageService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService YahooFinanceService
], ],
@ -34,11 +37,13 @@ import { DataProviderService } from './data-provider.service';
useFactory: ( useFactory: (
alphaVantageService, alphaVantageService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService,
rakutenRapidApiService, rakutenRapidApiService,
yahooFinanceService yahooFinanceService
) => [ ) => [
alphaVantageService, alphaVantageService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService,
rakutenRapidApiService, rakutenRapidApiService,
yahooFinanceService yahooFinanceService
] ]

View File

@ -11,8 +11,8 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns'; import { format, isValid } from 'date-fns';
import { isEmpty } from 'lodash'; import { groupBy, isEmpty } from 'lodash';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService {
@ -30,18 +30,27 @@ export class DataProviderService {
[symbol: string]: IDataProviderResponse; [symbol: string]: IDataProviderResponse;
} = {}; } = {};
for (const item of items) { const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const dataProvider = this.getDataProvider(item.dataSource);
response[item.symbol] = (await dataProvider.get([item.symbol]))[
item.symbol
];
}
const promises = []; const promises = [];
for (const symbol of Object.keys(response)) {
const promise = Promise.resolve(response[symbol]); for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).get(symbols)
);
promises.push( promises.push(
promise.then((currentResponse) => (response[symbol] = currentResponse)) promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
); );
} }
@ -62,7 +71,7 @@ export class DataProviderService {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {}; } = {};
if (isEmpty(aItems)) { if (isEmpty(aItems) || !isValid(from) || !isValid(to)) {
return response; return response;
} }
@ -96,7 +105,7 @@ export class DataProviderService {
ORDER BY date;`; ORDER BY date;`;
const marketDataByGranularity: MarketData[] = const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRaw(queryRaw); await this.prismaService.$queryRawUnsafe(queryRaw);
response = marketDataByGranularity.reduce((r, marketData) => { response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData; const { date, marketPrice, symbol } = marketData;
@ -149,13 +158,13 @@ export class DataProviderService {
return result; return result;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = []; const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
for (const dataSource of this.configurationService.get('DATA_SOURCES')) { for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push( promises.push(
this.getDataProvider(DataSource[dataSource]).search(aSymbol) this.getDataProvider(DataSource[dataSource]).search(aQuery)
); );
} }
@ -176,7 +185,7 @@ export class DataProviderService {
} }
public getPrimaryDataSource(): DataSource { public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCES')[0]]; return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
} }
private getDataProvider(providerName: DataSource) { private getDataProvider(providerName: DataSource) {

View File

@ -1,4 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
@ -13,13 +19,6 @@ import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { format } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g; private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
[symbol]: { [symbol]: {
marketPrice, marketPrice,
currency: symbolProfile?.currency, currency: symbolProfile?.currency,
dataSource: DataSource.GHOSTFOLIO, dataSource: this.getName(),
marketState: MarketState.delayed marketState: MarketState.delayed
} }
}; };
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return DataSource.GHOSTFOLIO; return DataSource.GHOSTFOLIO;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
} }
private extractNumberFromString(aString: string): number { private extractNumberFromString(aString: string): number {

View File

@ -0,0 +1,181 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format } from 'date-fns';
import { GoogleSpreadsheet } from 'google-spreadsheet';
@Injectable()
export class GoogleSheetsService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) {
return true;
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
symbol: 'Overview'
});
const rows = await sheet.getRows();
for (const row of rows) {
const marketPrice = parseFloat(row['marketPrice']);
const symbol = row['symbol'];
if (aSymbols.includes(symbol)) {
response[symbol] = {
marketPrice,
currency: symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
};
}
}
return response;
} catch (error) {
Logger.error(error);
}
return {};
}
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const sheet = await this.getSheet({
symbol,
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
});
const rows = await sheet.getRows();
const historicalData: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
rows
.filter((row, index) => {
return index >= 1;
})
.forEach((row) => {
const date = parseDate(row._rawData[0]);
const close = parseFloat(row._rawData[1]);
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
});
return {
[symbol]: historicalData
};
} catch (error) {
Logger.error(error);
}
return {};
}
public getName(): DataSource {
return DataSource.GOOGLE_SHEETS;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
private async getSheet({
sheetId,
symbol
}: {
sheetId: string;
symbol: string;
}) {
const doc = new GoogleSpreadsheet(sheetId);
await doc.useServiceAccountAuth({
client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
private_key: this.configurationService
.get('GOOGLE_SHEETS_PRIVATE_KEY')
.replace(/\\n/g, '\n')
});
await doc.loadInfo();
const sheet = doc.sheetsByTitle[symbol];
await sheet.loadCells();
return sheet;
}
}

View File

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

View File

@ -2,12 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
DATE_FORMAT,
getToday,
getYesterday,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -31,10 +26,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
) {} ) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return ( return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
isRakutenRapidApiSymbol(symbol) &&
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
);
} }
public async get( public async get(
@ -53,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return { return {
[ghostfolioFearAndGreedIndexSymbol]: { [ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined, currency: undefined,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
marketState: MarketState.open, marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
@ -93,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subWeeks(getToday(), 1), date: subWeeks(getToday(), 1),
marketPrice: fgi.oneWeekAgo.value marketPrice: fgi.oneWeekAgo.value
} }
@ -102,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subMonths(getToday(), 1), date: subMonths(getToday(), 1),
marketPrice: fgi.oneMonthAgo.value marketPrice: fgi.oneMonthAgo.value
} }
@ -111,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subYears(getToday(), 1), date: subYears(getToday(), 1),
marketPrice: fgi.oneYearAgo.value marketPrice: fgi.oneYearAgo.value
} }
@ -137,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return DataSource.RAKUTEN; return DataSource.RAKUTEN;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }

View File

@ -0,0 +1,60 @@
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceService } from './yahoo-finance.service';
jest.mock(
'@ghostfolio/api/services/cryptocurrency/cryptocurrency.service',
() => {
return {
CryptocurrencyService: jest.fn().mockImplementation(() => {
return {
isCryptocurrency: (symbol: string) => {
switch (symbol) {
case 'BTCUSD':
return true;
case 'DOGEUSD':
return true;
default:
return false;
}
}
};
})
};
}
);
describe('YahooFinanceService', () => {
let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceService: YahooFinanceService;
beforeAll(async () => {
cryptocurrencyService = new CryptocurrencyService();
yahooFinanceService = new YahooFinanceService(cryptocurrencyService);
});
it('convertFromYahooFinanceSymbol', async () => {
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('BRK-B')
).toEqual('BRK-B');
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('BTC-USD')
).toEqual('BTCUSD');
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('EURUSD=X')
).toEqual('EURUSD');
});
it('convertToYahooFinanceSymbol', async () => {
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('BTCUSD')
).toEqual('BTC-USD');
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD')
).toEqual('DOGE-USD');
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF')
).toEqual('USDCHF=X');
});
});

View File

@ -1,6 +1,6 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -8,7 +8,7 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import { format } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
import * as yahooFinance from 'yahoo-finance'; import * as yahooFinance from 'yahoo-finance';
import { import {
@ -35,6 +35,44 @@ export class YahooFinanceService implements DataProviderInterface {
return true; return true;
} }
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${baseCurrency}$`),
baseCurrency
);
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(baseCurrency) && aSymbol.length >= 6) {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
)
) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace(
new RegExp(`-?${baseCurrency}$`),
`-${baseCurrency}`
);
}
}
return aSymbol;
}
public async get( public async get(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
@ -65,11 +103,11 @@ export class YahooFinanceService implements DataProviderInterface {
assetClass, assetClass,
assetSubClass, assetSubClass,
currency: value.price?.currency, currency: value.price?.currency,
dataSource: DataSource.YAHOO, dataSource: this.getName(),
exchange: this.parseExchange(value.price?.exchangeName), exchange: this.parseExchange(value.price?.exchangeName),
marketState: marketState:
value.price?.marketState === 'REGULAR' || value.price?.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCrypto(symbol) this.cryptocurrencyService.isCryptocurrency(symbol)
? MarketState.open ? MarketState.open
: MarketState.closed, : MarketState.closed,
marketPrice: value.price?.regularMarketPrice || 0, marketPrice: value.price?.regularMarketPrice || 0,
@ -135,6 +173,10 @@ export class YahooFinanceService implements DataProviderInterface {
return {}; return {};
} }
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
const yahooFinanceSymbols = aSymbols.map((symbol) => { const yahooFinanceSymbols = aSymbols.map((symbol) => {
return this.convertToYahooFinanceSymbol(symbol); return this.convertToYahooFinanceSymbol(symbol);
}); });
@ -179,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`, `${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
aQuery
)}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET', 'GET',
'json', 'json',
200 200
@ -200,8 +244,8 @@ export class YahooFinanceService implements DataProviderInterface {
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
return ( return (
(quoteType === 'CRYPTOCURRENCY' && (quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCrypto( this.cryptocurrencyService.isCryptocurrency(
symbol.replace(new RegExp('-USD$'), 'USD').replace('1', '') symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
)) || )) ||
quoteType === 'EQUITY' || quoteType === 'EQUITY' ||
quoteType === 'ETF' quoteType === 'ETF'
@ -209,9 +253,9 @@ export class YahooFinanceService implements DataProviderInterface {
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') { if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD to avoid having redundancy in the database. // Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Trades need to be converted manually before to USD (or a UI converter needs to be developed) // Transactions need to be converted manually to the base currency before
return symbol.includes('USD'); return symbol.includes(baseCurrency);
} }
return true; return true;
@ -226,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
items.push({ items.push({
symbol, symbol,
currency: value.currency, currency: value.currency,
dataSource: DataSource.YAHOO, dataSource: this.getName(),
name: value.name name: value.name
}); });
} }
@ -235,44 +279,6 @@ export class YahooFinanceService implements DataProviderInterface {
return { items }; return { items };
} }
private convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace('-', '');
return symbol.replace('=X', '');
}
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF -> USDCHF=X
* Cryptocurrency: BTCUSD -> BTC-USD
* DOGEUSD -> DOGE-USD
* SOL1USD -> SOL1-USD
*/
private convertToYahooFinanceSymbol(aSymbol: string) {
if (
(aSymbol.includes('CHF') ||
aSymbol.includes('EUR') ||
aSymbol.includes('USD')) &&
aSymbol.length >= 6
) {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCrypto(
aSymbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
)
) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace(new RegExp('-?USD$'), '-USD');
}
}
return aSymbol;
}
private parseAssetClass(aPrice: IYahooFinancePrice): { private parseAssetClass(aPrice: IYahooFinancePrice): {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;

View File

@ -3,9 +3,10 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module'; import { PrismaModule } from './prisma.module';
import { PropertyModule } from './property/property.module';
@Module({ @Module({
imports: [DataProviderModule, PrismaModule], imports: [DataProviderModule, PrismaModule, PropertyModule],
providers: [ExchangeRateDataService], providers: [ExchangeRateDataService],
exports: [ExchangeRateDataService] exports: [ExchangeRateDataService]
}) })

View File

@ -1,4 +1,4 @@
import { baseCurrency } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -7,6 +7,7 @@ import { isEmpty, isNumber, uniq } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
import { PropertyService } from './property/property.service';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
@ -16,7 +17,8 @@ export class ExchangeRateDataService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) { ) {
this.initialize(); this.initialize();
} }
@ -56,9 +58,9 @@ export class ExchangeRateDataService {
getYesterday() getYesterday()
); );
if (isEmpty(result)) { if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback // Load currencies directly from data provider as a fallback
// if historical data is not yet available // if historical data is not fully available
const historicalData = await this.dataProviderService.get( const historicalData = await this.dataProviderService.get(
this.currencyPairs.map(({ dataSource, symbol }) => { this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
@ -149,13 +151,18 @@ export class ExchangeRateDataService {
} }
private async prepareCurrencies(): Promise<string[]> { private async prepareCurrencies(): Promise<string[]> {
const currencies: string[] = []; let currencies: string[] = [];
( (
await this.prismaService.account.findMany({ await this.prismaService.account.findMany({
distinct: ['currency'], distinct: ['currency'],
orderBy: [{ currency: 'asc' }], orderBy: [{ currency: 'asc' }],
select: { currency: true } select: { currency: true },
where: {
currency: {
not: null
}
}
}) })
).forEach((account) => { ).forEach((account) => {
currencies.push(account.currency); currencies.push(account.currency);
@ -165,7 +172,12 @@ export class ExchangeRateDataService {
await this.prismaService.settings.findMany({ await this.prismaService.settings.findMany({
distinct: ['currency'], distinct: ['currency'],
orderBy: [{ currency: 'asc' }], orderBy: [{ currency: 'asc' }],
select: { currency: true } select: { currency: true },
where: {
currency: {
not: null
}
}
}) })
).forEach((userSettings) => { ).forEach((userSettings) => {
currencies.push(userSettings.currency); currencies.push(userSettings.currency);
@ -175,12 +187,25 @@ export class ExchangeRateDataService {
await this.prismaService.symbolProfile.findMany({ await this.prismaService.symbolProfile.findMany({
distinct: ['currency'], distinct: ['currency'],
orderBy: [{ currency: 'asc' }], orderBy: [{ currency: 'asc' }],
select: { currency: true } select: { currency: true },
where: {
currency: {
not: null
}
}
}) })
).forEach((symbolProfile) => { ).forEach((symbolProfile) => {
currencies.push(symbolProfile.currency); currencies.push(symbolProfile.currency);
}); });
const customCurrencies = (await this.propertyService.getByKey(
PROPERTY_CURRENCIES
)) as string[];
if (customCurrencies?.length > 0) {
currencies = currencies.concat(customCurrencies);
}
return uniq(currencies).sort(); return uniq(currencies).sort();
} }

View File

@ -4,18 +4,25 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid? DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_BLOG: boolean; ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean; ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
GOOGLE_SHEETS_ACCOUNT: string;
GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number;
PORT: number; PORT: number;
RAKUTEN_RAPID_API_KEY: string; RAKUTEN_RAPID_API_KEY: string;
REDIS_HOST: string; REDIS_HOST: string;

View File

@ -3,11 +3,10 @@ import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource, DataSource,
SymbolProfile SymbolProfile,
Type as TypeOfOrder
} from '@prisma/client'; } from '@prisma/client';
import { OrderType } from '../../models/order-type';
export const MarketState = { export const MarketState = {
closed: 'closed', closed: 'closed',
delayed: 'delayed', delayed: 'delayed',
@ -24,7 +23,7 @@ export interface IOrder {
quantity: number; quantity: number;
symbol: string; symbol: string;
symbolProfile: SymbolProfile; symbolProfile: SymbolProfile;
type: OrderType; type: TypeOfOrder;
unitPrice: number; unitPrice: number;
} }

View File

@ -1,8 +1,9 @@
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MarketData, Prisma } from '@prisma/client'; import { DataSource, MarketData, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
@ -53,7 +54,7 @@ export class MarketDataService {
take?: number; take?: number;
cursor?: Prisma.MarketDataWhereUniqueInput; cursor?: Prisma.MarketDataWhereUniqueInput;
where?: Prisma.MarketDataWhereInput; where?: Prisma.MarketDataWhereInput;
orderBy?: Prisma.MarketDataOrderByInput; orderBy?: Prisma.MarketDataOrderByWithRelationInput;
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
@ -65,4 +66,22 @@ export class MarketDataService {
where where
}); });
} }
public async updateMarketData(params: {
data: { dataSource: DataSource } & UpdateMarketDataDto;
where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> {
const { data, where } = params;
return this.prismaService.marketData.upsert({
where,
create: {
dataSource: data.dataSource,
date: where.date_symbol.date,
marketPrice: data.marketPrice,
symbol: where.date_symbol.symbol
},
update: { marketPrice: data.marketPrice }
});
}
} }

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class PropertyDto {
@IsString()
value: string;
}

View File

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

View File

@ -0,0 +1,49 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
@Injectable()
export class PropertyService {
public constructor(private readonly prismaService: PrismaService) {}
public async delete({ key }: { key: string }) {
return this.prismaService.property.delete({
where: { key }
});
}
public async get() {
const response: {
[key: string]: boolean | object | string | string[];
} = {
[PROPERTY_CURRENCIES]: []
};
const properties = await this.prismaService.property.findMany();
for (const property of properties) {
let value = property.value;
try {
value = JSON.parse(property.value);
} catch {}
response[property.key] = value;
}
return response;
}
public async getByKey(aKey: string) {
const properties = await this.get();
return properties?.[aKey];
}
public async put({ key, value }: { key: string; value: string }) {
return this.prismaService.property.upsert({
create: { key, value },
update: { value },
where: { key }
});
}
}

View File

@ -9,6 +9,13 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule) import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
}, },
{
path: 'about/changelog',
loadChildren: () =>
import('./pages/about/changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
},
{ {
path: 'account', path: 'account',
loadChildren: () => loadChildren: () =>
@ -33,6 +40,11 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
}, },
{
path: 'blog',
loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
},
{ {
path: 'de/blog/2021/07/hallo-ghostfolio', path: 'de/blog/2021/07/hallo-ghostfolio',
loadChildren: () => loadChildren: () =>
@ -47,6 +59,13 @@ const routes: Routes = [
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module' './pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule) ).then((m) => m.HelloGhostfolioPageModule)
}, },
{
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{ {
path: 'home', path: 'home',
loadChildren: () => loadChildren: () =>
@ -66,6 +85,13 @@ const routes: Routes = [
(m) => m.PortfolioPageModule (m) => m.PortfolioPageModule
) )
}, },
{
path: 'portfolio/activities',
loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{ {
path: 'portfolio/allocations', path: 'portfolio/allocations',
loadChildren: () => loadChildren: () =>
@ -87,13 +113,6 @@ const routes: Routes = [
(m) => m.ReportPageModule (m) => m.ReportPageModule
) )
}, },
{
path: 'portfolio/transactions',
loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{ {
path: 'pricing', path: 'pricing',
loadChildren: () => loadChildren: () =>

View File

@ -9,18 +9,27 @@
</header> </header>
<main role="main"> <main role="main">
<div *ngIf="canCreateAccount" class="container create-account-container"> <div
*ngIf="canCreateAccount || (info?.systemMessage && user)"
class="container info-message-container"
>
<div class="row"> <div class="row">
<div class="col-md-8 offset-md-2 text-center"> <div class="col-md-8 offset-md-2 text-center">
<a class="text-center" [routerLink]="['/']"> <a *ngIf="canCreateAccount" class="text-center" [routerLink]="['/']">
<div <div
class="create-account-box d-inline-block px-3 py-2" class="cursor-pointer d-inline-block info-message px-3 py-2"
(click)="onCreateAccount()" (click)="onCreateAccount()"
> >
<span i18n>You are using the Live Demo.</span> <span i18n>You are using the Live Demo.</span>
<a class="ml-2" href="#" i18n>Create Account</a> <a class="ml-2" href="#" i18n>Create Account</a>
</div></a </div></a
> >
<div
*ngIf="!canCreateAccount && info?.systemMessage && user"
class="d-inline-block info-message px-3 py-2"
>
{{ info.systemMessage }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -8,14 +8,13 @@
min-height: 100vh; min-height: 100vh;
padding-top: 5rem; padding-top: 5rem;
.create-account-container { .info-message-container {
height: 3.5rem; height: 3.5rem;
margin-top: -0.5rem; margin-top: -0.5rem;
.create-account-box { .info-message {
background-color: rgba(0, 0, 0, $alpha-hover); background-color: rgba(0, 0, 0, $alpha-hover);
border-radius: 2rem; border-radius: 2rem;
cursor: pointer;
font-size: 80%; font-size: 80%;
a { a {

View File

@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.tokenStorageService.signOut(); this.tokenStorageService.signOut();
this.userService.remove(); this.userService.remove();
window.location.reload(); document.location.href = '/';
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -15,7 +15,7 @@
>(Default)</span >(Default)</span
> >
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td> <td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td>
</ng-container> </ng-container>
<ng-container matColumnDef="currency"> <ng-container matColumnDef="currency">

View File

@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { AccountsTableComponent } from './accounts-table.component'; import { AccountsTableComponent } from './accounts-table.component';
@NgModule({ @NgModule({

View File

@ -1,21 +1,35 @@
<div class="py-2"> <div>
<gf-line-chart
class="mb-4"
[historicalDataItems]="historicalDataItems"
[showXAxis]="true"
[showYAxis]="true"
></gf-line-chart>
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex"> <div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div> <div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 px-1"> <div class="align-items-center d-flex flex-grow-1 px-1">
<div <div
*ngFor="let dayItem of days; let i = index" *ngFor="let dayItem of days; let i = index"
class="day" class="day"
[ngClass]="{
'cursor-pointer valid': isDateOfInterest(
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
),
available:
marketDataByMonth[itemByMonth.key][
i + 1 < 10 ? '0' + (i + 1) : i + 1
]?.day ===
i + 1
}"
[title]=" [title]="
(marketDataByMonth[itemByMonth.key][i + 1]?.date (itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
| date: defaultDateFormat) ?? '' | date: defaultDateFormat) ?? ''
" "
[ngClass]="{
'available cursor-pointer':
marketDataByMonth[itemByMonth.key][i + 1]?.day == i + 1
}"
(click)=" (click)="
marketDataByMonth[itemByMonth.key][i + 1] && onOpenMarketDataDetail({
onOpenMarketDataDetail(marketDataByMonth[itemByMonth.key][i + 1]) day: i + 1 < 10 ? '0' + (i + 1) : i + 1,
yearMonth: itemByMonth.key
})
" "
></div> ></div>
</div> </div>

View File

@ -10,11 +10,18 @@
} }
.day { .day {
background-color: var(--danger);
height: 0.5rem; height: 0.5rem;
margin-right: 0.25rem; margin-right: 0.25rem;
width: 0.5rem; width: 0.5rem;
&:hover {
opacity: 0.8;
}
&.valid {
background-color: var(--danger);
}
&.available { &.available {
background-color: var(--success); background-color: var(--success);
} }

View File

@ -1,14 +1,18 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
EventEmitter,
Input, Input,
OnChanges, OnChanges,
OnInit OnInit,
Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { MarketData } from '@prisma/client'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { format } from 'date-fns'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client';
import { format, isBefore, isValid, parse } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -21,11 +25,16 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
templateUrl: './admin-market-data-detail.component.html' templateUrl: './admin-market-data-detail.component.html'
}) })
export class AdminMarketDataDetailComponent implements OnChanges, OnInit { export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
@Input() dataSource: DataSource;
@Input() marketData: MarketData[]; @Input() marketData: MarketData[];
@Input() symbol: string;
@Output() marketDataChanged = new EventEmitter<boolean>();
public days = Array(31); public days = Array(31);
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public deviceType: string; public deviceType: string;
public historicalDataItems: LineChartItem[];
public marketDataByMonth: { public marketDataByMonth: {
[yearMonth: string]: { [day: string]: MarketData & { day: number } }; [yearMonth: string]: { [day: string]: MarketData & { day: number } };
} = {}; } = {};
@ -42,6 +51,12 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.historicalDataItems = this.marketData.map((marketDataItem) => {
return {
date: format(marketDataItem.date, DATE_FORMAT),
value: marketDataItem.marketPrice
};
});
this.marketDataByMonth = {}; this.marketDataByMonth = {};
for (const marketDataItem of this.marketData) { for (const marketDataItem of this.marketData) {
@ -52,19 +67,36 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
this.marketDataByMonth[key] = {}; this.marketDataByMonth[key] = {};
} }
this.marketDataByMonth[key][currentDay] = { this.marketDataByMonth[key][
currentDay < 10 ? `0${currentDay}` : currentDay
] = {
...marketDataItem, ...marketDataItem,
day: currentDay day: currentDay
}; };
} }
} }
public onOpenMarketDataDetail({ date, marketPrice, symbol }: MarketData) { public isDateOfInterest(aDateString: string) {
// Date is valid and in the past
const date = parse(aDateString, DATE_FORMAT, new Date());
return isValid(date) && isBefore(date, new Date());
}
public onOpenMarketDataDetail({
day,
yearMonth
}: {
day: string;
yearMonth: string;
}) {
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
const dialogRef = this.dialog.open(MarketDataDetailDialog, { const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: { data: {
marketPrice, marketPrice,
symbol, dataSource: this.dataSource,
date: format(date, DEFAULT_DATE_FORMAT) date: new Date(`${yearMonth}-${day}`),
symbol: this.symbol
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
@ -73,7 +105,9 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(({ withRefresh }) => {
this.marketDataChanged.next(withRefresh);
});
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component'; import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module'; import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
@ -7,7 +8,7 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark
@NgModule({ @NgModule({
declarations: [AdminMarketDataDetailComponent], declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent], exports: [AdminMarketDataDetailComponent],
imports: [CommonModule, GfMarketDataDetailDialogModule], imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -1,5 +1,8 @@
import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams { export interface MarketDataDetailDialogParams {
date: string; dataSource: DataSource;
date: Date;
marketPrice: number; marketPrice: number;
symbol: string; symbol: string;
} }

View File

@ -1,11 +1,13 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
Inject, Inject,
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { Subject, takeUntil } from 'rxjs';
import { MarketDataDetailDialogParams } from './interfaces/interfaces'; import { MarketDataDetailDialogParams } from './interfaces/interfaces';
@ -20,6 +22,8 @@ export class MarketDataDetailDialog implements OnDestroy {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
public dialogRef: MatDialogRef<MarketDataDetailDialog>, public dialogRef: MatDialogRef<MarketDataDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams @Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams
) {} ) {}
@ -27,7 +31,36 @@ export class MarketDataDetailDialog implements OnDestroy {
public ngOnInit() {} public ngOnInit() {}
public onCancel(): void { public onCancel(): void {
this.dialogRef.close(); this.dialogRef.close({ withRefresh: false });
}
public onFetchSymbolForDate() {
this.adminService
.fetchSymbolForDate({
dataSource: this.data.dataSource,
date: this.data.date,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
this.data.marketPrice = marketPrice;
this.changeDetectorRef.markForCheck();
});
}
public onUpdate() {
this.adminService
.putMarketData({
dataSource: this.data.dataSource,
date: this.data.date,
marketData: { marketPrice: this.data.marketPrice },
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dialogRef.close({ withRefresh: true });
});
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -1,25 +1,50 @@
<form class="d-flex flex-column h-100"> <form class="d-flex flex-column h-100">
<h1 mat-dialog-title i18n>Details for {{ data.symbol }}</h1> <h1 mat-dialog-title i18n>Details for {{ data.symbol }}</h1>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<div> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label> <mat-label i18n>Date</mat-label>
<input matInput name="date" readonly [(ngModel)]="data.date" /> <input
disabled
matInput
name="date"
[matDatepicker]="date"
[(ngModel)]="data.date"
/>
<mat-datepicker-toggle matSuffix [for]="date">
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
></ion-icon>
</mat-datepicker-toggle>
<mat-datepicker #date disabled="true"></mat-datepicker>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>MarketPrice</mat-label> <mat-label i18n>Market Price</mat-label>
<input <input
matInput matInput
name="marketPrice" name="marketPrice"
readonly type="number"
[(ngModel)]="data.marketPrice" [(ngModel)]="data.marketPrice"
/> />
<button
mat-icon-button
matSuffix
title="Fetch market price"
(click)="onFetchSymbolForDate()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
</button>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button> <button i18n mat-button (click)="onCancel()">Cancel</button>
<button color="primary" i18n mat-flat-button (click)="onUpdate()">
Save
</button>
</div> </div>
</form> </form>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
@ -15,6 +16,7 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
CommonModule, CommonModule,
FormsModule, FormsModule,
MatButtonModule, MatButtonModule,
MatDatepickerModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,

View File

@ -3,5 +3,21 @@
.mat-dialog-content { .mat-dialog-content {
max-height: unset; max-height: unset;
.mat-form-field-appearance-outline {
::ng-deep {
.mat-form-field-suffix {
top: -0.3rem;
}
.mat-form-field-wrapper {
padding-bottom: 0;
}
}
ion-icon {
font-size: 130%;
}
}
} }
} }

View File

@ -43,6 +43,19 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.fetchAdminMarketData(); this.fetchAdminMarketData();
} }
public onGatherProfileDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherSymbol({ public onGatherSymbol({
dataSource, dataSource,
symbol symbol
@ -68,6 +81,13 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
} }
} }
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.fetchAdminMarketData();
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
}
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -4,10 +4,11 @@
<table class="gf-table w-100"> <table class="gf-table w-100">
<thead> <thead>
<tr class="mat-header-row"> <tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th> <th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th> <th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th> <th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
<th class="mat-header-cell px-1 py-2"></th> <th class="mat-header-cell px-1 py-2"></th>
</tr> </tr>
</thead> </thead>
@ -17,12 +18,13 @@
class="cursor-pointer mat-row" class="cursor-pointer mat-row"
(click)="setCurrentSymbol(item.symbol)" (click)="setCurrentSymbol(item.symbol)"
> >
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td> <td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td> <td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
{{ (item.date | date: defaultDateFormat) ?? '' }} {{ (item.date | date: defaultDateFormat) ?? '' }}
</td> </td>
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
@ -40,14 +42,23 @@
> >
Gather Data Gather Data
</button> </button>
<button
i18n
mat-menu-item
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
>
Gather Profile Data
</button>
</mat-menu> </mat-menu>
</td> </td>
</tr> </tr>
<tr *ngIf="currentSymbol === item.symbol" class="mat-row"> <tr *ngIf="currentSymbol === item.symbol" class="mat-row">
<td></td> <td class="p-1" colspan="4">
<td colspan="4">
<gf-admin-market-data-detail <gf-admin-market-data-detail
[dataSource]="item.dataSource"
[marketData]="marketDataDetails" [marketData]="marketDataDetails"
[symbol]="item.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail> ></gf-admin-market-data-detail>
</td> </td>
</tr> </tr>

View File

@ -1,16 +1,25 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import {
import { User } from '@ghostfolio/common/interfaces'; DEFAULT_DATE_FORMAT,
PROPERTY_COUPONS,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SYSTEM_MESSAGE
} from '@ghostfolio/common/config';
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
differenceInSeconds, differenceInSeconds,
formatDistanceToNowStrict, formatDistanceToNowStrict,
isValid, isValid,
parseISO parseISO
} from 'date-fns'; } from 'date-fns';
import { uniq } from 'lodash';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -20,10 +29,16 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-overview.html' templateUrl: './admin-overview.html'
}) })
export class AdminOverviewComponent implements OnDestroy, OnInit { export class AdminOverviewComponent implements OnDestroy, OnInit {
public coupons: Coupon[];
public customCurrencies: string[];
public dataGatheringInProgress: boolean; public dataGatheringInProgress: boolean;
public dataGatheringProgress: number; public dataGatheringProgress: number;
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public exchangeRates: { label1: string; label2: string; value: number }[]; public exchangeRates: { label1: string; label2: string; value: number }[];
public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean;
public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem;
public lastDataGathering: string; public lastDataGathering: string;
public transactionCount: number; public transactionCount: number;
public userCount: number; public userCount: number;
@ -40,23 +55,95 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private userService: UserService private userService: UserService
) {} ) {
this.info = this.dataService.fetchInfo();
/**
* Initializes the controller
*/
public ngOnInit() {
this.fetchAdminData();
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionForSubscription = hasPermission(
this.info.globalPermissions,
permissions.enableSubscription
);
this.hasPermissionForSystemMessage = hasPermission(
this.info.globalPermissions,
permissions.enableSystemMessage
);
this.hasPermissionToToggleReadOnlyMode = hasPermission(
this.user.permissions,
permissions.toggleReadOnlyMode
);
} }
}); });
} }
/**
* Initializes the controller
*/
public ngOnInit() {
this.fetchAdminData();
}
public formatDistanceToNow(aDateString: string) {
if (aDateString) {
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
addSuffix: true
});
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
60
? 'just now'
: distanceString;
}
return '';
}
public onAddCoupon() {
const coupons = [...this.coupons, { code: this.generateCouponCode(16) }];
this.putCoupons(coupons);
}
public onAddCurrency() {
const currency = prompt('Please add a currency:');
if (currency) {
const currencies = uniq([...this.customCurrencies, currency]);
this.putCurrencies(currencies);
}
}
public onDeleteCoupon(aCouponCode: string) {
const confirmation = confirm('Do you really want to delete this coupon?');
if (confirmation) {
const coupons = this.coupons.filter((coupon) => {
return coupon.code !== aCouponCode;
});
this.putCoupons(coupons);
}
}
public onDeleteCurrency(aCurrency: string) {
const confirmation = confirm('Do you really want to delete this currency?');
if (confirmation) {
const currencies = this.customCurrencies.filter((currency) => {
return currency !== aCurrency;
});
this.putCurrencies(currencies);
}
}
public onDeleteSystemMessage() {
this.putSystemMessage('');
}
public onFlushCache() { public onFlushCache() {
this.cacheService this.cacheService
.flush() .flush()
@ -92,19 +179,16 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
.subscribe(() => {}); .subscribe(() => {});
} }
public formatDistanceToNow(aDateString: string) { public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
if (aDateString) { this.setReadOnlyMode(aEvent.checked);
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), { }
addSuffix: true
});
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) < public onSetSystemMessage() {
60 const systemMessage = prompt('Please set your system message:');
? 'just now'
: distanceString; if (systemMessage) {
this.putSystemMessage(systemMessage);
} }
return '';
} }
public ngOnDestroy() { public ngOnDestroy() {
@ -121,9 +205,12 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
dataGatheringProgress, dataGatheringProgress,
exchangeRates, exchangeRates,
lastDataGathering, lastDataGathering,
settings,
transactionCount, transactionCount,
userCount userCount
}) => { }) => {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.dataGatheringProgress = dataGatheringProgress; this.dataGatheringProgress = dataGatheringProgress;
this.exchangeRates = exchangeRates; this.exchangeRates = exchangeRates;
@ -147,4 +234,69 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
); );
} }
private generateCouponCode(aLength: number) {
const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ123456789';
let couponCode = '';
for (let i = 0; i < aLength; i++) {
couponCode += characters.charAt(
Math.floor(Math.random() * characters.length)
);
}
return couponCode;
}
private putCoupons(aCoupons: Coupon[]) {
this.dataService
.putAdminSetting(PROPERTY_COUPONS, {
value: JSON.stringify(aCoupons)
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
private putCurrencies(aCurrencies: string[]) {
this.dataService
.putAdminSetting(PROPERTY_CURRENCIES, {
value: JSON.stringify(aCurrencies)
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
private putSystemMessage(aSystemMessage: string) {
this.dataService
.putAdminSetting(PROPERTY_SYSTEM_MESSAGE, {
value: aSystemMessage
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
private setReadOnlyMode(aValue: boolean) {
this.dataService
.putAdminSetting(PROPERTY_IS_READ_ONLY_MODE, {
value: aValue ? 'true' : ''
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
} }

View File

@ -3,10 +3,75 @@
<div class="col"> <div class="col">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-content> <mat-card-content>
<div <div class="d-flex my-3">
*ngIf="exchangeRates?.length > 0" <div class="w-50" i18n>User Count</div>
class="align-items-start d-flex my-3" <div class="w-50">{{ userCount }}</div>
> </div>
<div class="d-flex my-3">
<div class="w-50" i18n>Transaction Count</div>
<div class="w-50">
<ng-container *ngIf="transactionCount">
{{ transactionCount }} ({{ transactionCount / userCount | number
: '1.2-2' }} <span i18n>per User</span>)
</ng-container>
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Data Gathering</div>
<div class="w-50">
<div>
<ng-container *ngIf="lastDataGathering"
>{{ lastDataGathering }}</ng-container
>
<ng-container *ngIf="dataGatheringInProgress" i18n
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
}})</ng-container
>
</div>
<div class="mt-2 overflow-hidden">
<div class="mb-2">
<button
color="accent"
mat-flat-button
(click)="onFlushCache()"
>
<ion-icon
class="mr-1"
name="close-circle-outline"
></ion-icon>
<span i18n>Reset Data Gathering</span>
</button>
</div>
<div class="mb-2">
<button
color="warn"
mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onGatherMax()"
>
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
<span i18n>Gather All Data</span>
</button>
</div>
<div>
<button
class="mb-2 mr-2"
color="accent"
mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onGatherProfileData()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather Profile Data</span>
</button>
</div>
</div>
</div>
</div>
<div class="align-items-start d-flex my-3">
<div class="w-50" i18n>Exchange Rates</div> <div class="w-50" i18n>Exchange Rates</div>
<div class="w-50"> <div class="w-50">
<table> <table>
@ -27,77 +92,89 @@
></gf-value> ></gf-value>
</td> </td>
<td class="pl-1">{{ exchangeRate.label2 }}</td> <td class="pl-1">{{ exchangeRate.label2 }}</td>
<td>
<button
*ngIf="customCurrencies.includes(exchangeRate.label2)"
class="mini-icon mx-1 no-min-width px-2"
mat-button
[disabled]="dataGatheringInProgress"
(click)="onDeleteCurrency(exchangeRate.label2)"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</td>
</tr> </tr>
</table> </table>
</div> <div class="mt-2">
</div> <button
<div class="d-flex my-3"> color="primary"
<div class="w-50" i18n>Data Gathering</div> mat-flat-button
<div class="w-50"> [disabled]="dataGatheringInProgress"
<div> (click)="onAddCurrency()"
<ng-container *ngIf="lastDataGathering"
>{{ lastDataGathering }}</ng-container
> >
<ng-container *ngIf="dataGatheringInProgress" i18n <ion-icon class="mr-1" name="add-outline"></ion-icon>
>In Progress ({{ dataGatheringProgress | percent : '1.2-2' <span i18n>Add Currency</span>
}})</ng-container </button>
>
</div>
<div class="mt-2 overflow-hidden">
<div class="mb-2">
<button
class="mw-100"
color="accent"
mat-flat-button
(click)="onFlushCache()"
>
<ion-icon
class="mr-1"
name="close-circle-outline"
></ion-icon>
<span i18n>Reset Data Gathering</span>
</button>
</div>
<div class="mb-2">
<button
class="mw-100"
color="warn"
mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onGatherMax()"
>
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
<span i18n>Gather All Data</span>
</button>
</div>
<div>
<button
class="mb-2 mr-2 mw-100"
color="accent"
mat-flat-button
(click)="onGatherProfileData()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather Profile Data</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex my-3"> <div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
<div class="w-50" i18n>User Count</div> <div class="w-50" i18n>System Message</div>
<div class="w-50">{{ userCount }}</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Transaction Count</div>
<div class="w-50"> <div class="w-50">
<ng-container *ngIf="transactionCount"> <div *ngIf="info.systemMessage">
{{ transactionCount }} ({{ transactionCount / userCount | number <span>{{ info.systemMessage }}</span>
: '1.2-2' }} <span i18n>per User</span>) <button
</ng-container> class="mini-icon mx-1 no-min-width px-2"
mat-button
[disabled]="dataGatheringInProgress"
(click)="onDeleteSystemMessage()"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</div>
<button
*ngIf="!info.systemMessage"
color="accent"
mat-flat-button
(click)="onSetSystemMessage()"
>
<ion-icon
class="mr-1"
name="information-circle-outline"
></ion-icon>
<span i18n>Set Message</span>
</button>
</div>
</div>
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
<div class="w-50" i18n>Read-only Mode</div>
<div class="w-50">
<mat-slide-toggle
color="primary"
[checked]="info?.isReadOnlyMode"
(change)="onReadOnlyModeChange($event)"
></mat-slide-toggle>
</div>
</div>
<div *ngIf="hasPermissionForSubscription" class="d-flex my-3">
<div class="w-50" i18n>Coupons</div>
<div class="w-50">
<div *ngFor="let coupon of coupons">
<span>{{ coupon.code }}</span>
<button
class="mini-icon mx-1 no-min-width px-2"
mat-button
(click)="onDeleteCoupon(coupon.code)"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</div>
<div class="mt-2">
<button color="primary" mat-flat-button (click)="onAddCoupon()">
<ion-icon class="mr-1" name="add-outline"></ion-icon>
<span i18n>Add Coupon</span>
</button>
</div>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -10,7 +11,13 @@ import { AdminOverviewComponent } from './admin-overview.component';
@NgModule({ @NgModule({
declarations: [AdminOverviewComponent], declarations: [AdminOverviewComponent],
exports: [], exports: [],
imports: [CommonModule, GfValueModule, MatButtonModule, MatCardModule], imports: [
CommonModule,
GfValueModule,
MatButtonModule,
MatCardModule,
MatSlideToggleModule
],
providers: [CacheService], providers: [CacheService],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -3,6 +3,12 @@
:host { :host {
display: block; display: block;
.mat-button {
&.mini-icon {
line-height: 1.5;
}
}
.mat-flat-button { .mat-flat-button {
::ng-deep { ::ng-deep {
.mat-button-wrapper { .mat-button-wrapper {

View File

@ -5,7 +5,7 @@
<table class="gf-table"> <table class="gf-table">
<thead> <thead>
<tr class="mat-header-row"> <tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th> <th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th> <th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n> <th class="mat-header-cell px-1 py-2 text-right" i18n>
Registration Registration

View File

@ -1,5 +1,6 @@
<button <button
*ngIf="deviceType === 'mobile'" *ngIf="deviceType === 'mobile'"
class="mt-2"
mat-button mat-button
(click)="onClickCloseButton()" (click)="onClickCloseButton()"
> >

View File

@ -1,4 +1,7 @@
:host { :host {
display: flex; display: flex;
flex: 0 0 auto;
margin-bottom: 0;
min-height: 0; min-height: 0;
padding: 0;
} }

View File

@ -1,13 +1,13 @@
<div class="align-items-center d-flex flex-row"> <div class="align-items-center d-flex flex-row">
<div class="h3 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div> <div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
<div> <div>
<div class="h3 mb-0"> <div class="h4 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText }}</span> <span class="mr-2">{{ fearAndGreedIndexText }}</span>
<small class="text-muted" <small class="text-muted"
><strong>{{ fearAndGreedIndex }}</strong ><strong>{{ fearAndGreedIndex }}</strong
>/100</small >/100</small
> >
</div> </div>
<small class="d-block" i18n>Market Mood</small> <small class="d-block" i18n>Current Market Mood</small>
</div> </div>
</div> </div>

View File

@ -270,7 +270,7 @@
Sign In Sign In
</button> </button>
<a <a
*ngIf="currentRoute !== 'register'" *ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
class="d-none d-sm-block" class="d-none d-sm-block"
color="primary" color="primary"
i18n i18n

View File

@ -1,10 +1,14 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { import {
RANGE, RANGE,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { Position, User } from '@ghostfolio/common/interfaces'; import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
@ -19,6 +23,7 @@ import { takeUntil } from 'rxjs/operators';
}) })
export class HomeHoldingsComponent implements OnDestroy, OnInit { export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange; public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public deviceType: string; public deviceType: string;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public positions: Position[]; public positions: Position[];
@ -33,9 +38,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private userService: UserService private userService: UserService
) { ) {
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({ symbol: params['symbol'] });
}
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
@ -64,12 +80,48 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.update(); this.update();
} }
public onChangeDateRange(aDateRange: DateRange) {
this.dateRange = aDateRange;
this.settingsStorageService.setSetting(RANGE, this.dateRange);
this.update();
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openPositionDialog({ symbol }: { symbol: string }) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private update() { private update() {
this.positions = undefined;
this.dataService this.dataService
.fetchPositions({ range: this.dateRange }) .fetchPositions({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

View File

@ -1,4 +1,12 @@
<div class="container justify-content-center pb-3 px-3"> <div class="container justify-content-center p-3">
<div class="mb-3 text-center">
<gf-toggle
[defaultValue]="dateRange"
[isLoading]="positions === undefined"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
<div class="row"> <div class="row">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2"> <div class="align-items-center col-xs-12 col-md-8 offset-md-2">
<mat-card class="p-0"> <mat-card class="p-0">
@ -6,6 +14,7 @@
<gf-positions <gf-positions
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions" [positions]="positions"
[range]="dateRange" [range]="dateRange"
@ -17,8 +26,8 @@
class="mt-3" class="mt-3"
i18n i18n
mat-button mat-button
[routerLink]="['/portfolio', 'transactions']" [routerLink]="['/portfolio', 'activities']"
>Manage Transactions...</a >Manage Activities...</a
> >
</div> </div>
</div> </div>

View File

@ -3,7 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module'; import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { HomeHoldingsComponent } from './home-holdings.component'; import { HomeHoldingsComponent } from './home-holdings.component';
@ -12,7 +14,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
exports: [], exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfPositionDetailDialogModule,
GfPositionsModule, GfPositionsModule,
GfToggleModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
RouterModule RouterModule

View File

@ -1,7 +1,9 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -16,6 +18,7 @@ import { takeUntil } from 'rxjs/operators';
export class HomeMarketComponent implements OnDestroy, OnInit { export class HomeMarketComponent implements OnDestroy, OnInit {
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[];
public isLoading = true; public isLoading = true;
public user: User; public user: User;
@ -46,11 +49,19 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchSymbolItem({ .fetchSymbolItem({
dataSource: DataSource.RAKUTEN, dataSource: DataSource.RAKUTEN,
includeHistoricalData: true,
symbol: ghostfolioFearAndGreedIndexSymbol symbol: ghostfolioFearAndGreedIndexSymbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => { .subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex = marketPrice; this.fearAndGreedIndex = marketPrice;
this.historicalData = [
...historicalData,
{
date: resetHours(new Date()).toISOString(),
value: marketPrice
}
];
this.isLoading = false; this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -9,17 +9,26 @@
w-100 w-100
" "
> >
<div class="row w-100"> <div class="no-gutters row w-100">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<mat-card class="h-100"> <div class="mb-2 text-center text-muted">
<mat-card-content> <small i18n>Last 30 Days</small>
<gf-fear-and-greed-index </div>
class="d-flex justify-content-center" <gf-line-chart
[fearAndGreedIndex]="fearAndGreedIndex" class="mb-5"
[hidden]="isLoading" yMax="100"
></gf-fear-and-greed-index> yMaxLabel="Greed"
</mat-card-content> yMin="0"
</mat-card> yMinLabel="Fear"
[historicalDataItems]="historicalData"
[showXAxis]="true"
[showYAxis]="true"
></gf-line-chart>
<gf-fear-and-greed-index
class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex"
[hidden]="isLoading"
></gf-fear-and-greed-index>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,14 +1,14 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module'; import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { HomeMarketComponent } from './home-market.component'; import { HomeMarketComponent } from './home-market.component';
@NgModule({ @NgModule({
declarations: [HomeMarketComponent], declarations: [HomeMarketComponent],
exports: [], exports: [],
imports: [CommonModule, GfFearAndGreedIndexModule, MatCardModule], imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

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