diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..57ee8ad6f
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+custom: ['https://www.buymeacoffee.com/ghostfolio']
diff --git a/.travis.yml b/.travis.yml
index 57b8bdfe4..ef5281c35 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,9 +3,28 @@ git:
depth: false
node_js:
- 14
-before_script:
- - yarn
-script:
- - yarn format:check
- - yarn test
- - yarn build:all
+
+services:
+ - docker
+
+cache: yarn
+
+if: (type = pull_request) OR (tag IS present)
+
+jobs:
+ include:
+ - stage: Install dependencies
+ if: type = pull_request
+ script: yarn --frozen-lockfile
+ - stage: Check formatting
+ if: type = pull_request
+ script: yarn format:check
+ - stage: Execute tests
+ if: type = pull_request
+ script: yarn test
+ - stage: Build application
+ if: type = pull_request
+ script: yarn build:all
+ - stage: Build and publish docker image
+ if: tag IS present
+ script: ./publish-docker-image.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e1edf988..1c03e17d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,12 +7,851 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
+### Changed
+
+- Skipped data enhancer (_Trackinsight_) if data is inaccurate
+
+### Fixed
+
+- Fixed an issue with countries in the symbol profile overrides
+
+## 1.149.0 - 16.05.2022
+
+### Added
+
+- Added groups to the activities filter component
+- Added support for filtering by asset class on the allocations page
+
+## 1.148.0 - 14.05.2022
+
+### Added
+
+- Supported enter key press to submit the form of the create or edit transaction dialog
+- Added a _Report Data Glitch_ button to the position detail dialog
+
+### Fixed
+
+- Fixed the date format of the date picker and support manual changes
+- Fixed the state of the account delete button (disable if account contains activities)
+- Fixed an issue in the activities filter component (typing a search term)
+
+## 1.147.0 - 10.05.2022
+
+### Changed
+
+- Improved the allocations page with no filtering (include cash positions)
+
+## 1.146.3 - 08.05.2022
+
+### Added
+
+- Set up a queue for the data gathering jobs
+- Set up _Nx Cloud_
+
+### Changed
+
+- Migrated the asset profile data gathering to the queue design pattern
+- Improved the allocations page with no filtering
+- Harmonized the _No data available_ label in the portfolio proportion chart component
+- Improved the _FIRE_ calculator for the _Live Demo_
+- Simplified the about page
+- Upgraded `angular` from version `13.2.2` to `13.3.6`
+- Upgraded `Nx` from version `13.8.5` to `14.1.4`
+- Upgraded `storybook` from version `6.4.18` to `6.4.22`
+
+### Fixed
+
+- Eliminated the circular dependencies in the `@ghostfolio/common` library
+
+## 1.145.0 - 07.05.2022
+
+### Added
+
+- Added support for filtering by accounts on the allocations page
+- Added support for private equity
+- Extended the form to set the asset and asset sub class for (wealth) items
+
+### Changed
+
+- Refactored the filtering (activities table and allocations page)
+
+### Fixed
+
+- Fixed the tooltip update in the portfolio proportion chart component
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.144.0 - 30.04.2022
+
+### Added
+
+- Added support for commodities (via futures)
+- Added support for real estate
+
+### Changed
+
+- Improved the layout of the position detail dialog
+- Upgraded `yahoo-finance2` from version `2.3.1` to `2.3.2`
+
+### Fixed
+
+- Fixed the import validation for numbers equal 0
+- Fixed the color of the spinner in the activities filter component (dark mode)
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.143.0 - 26.04.2022
+
+### Changed
+
+- Improved the filtering by tags
+
+## 1.142.0 - 25.04.2022
+
+### Added
+
+- Added the tags to the create or edit transaction dialog
+- Added the tags to the position detail dialog
+
+### Changed
+
+- Changed the date to UTC in the data gathering service
+- Reused the value component in the users table of the admin control panel
+
+## 1.141.1 - 24.04.2022
+
+### Added
+
+- Added the database migration
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.141.0 - 24.04.2022
+
+### Added
+
+- Added a tagging system for activities
+
+### Changed
+
+- Extracted the activities table filter to a dedicated component
+- Changed the url of the _Get Started_ link to `https://ghostfol.io` on the public page
+- Simplified `@@id` using multiple fields with `@id` in the database schema of (`Access`, `Order`, `Subscription`)
+- Upgraded `prisma` from version `3.11.1` to `3.12.0`
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.140.2 - 22.04.2022
+
+### Added
+
+- Added support for sub-labels in the value component
+- Added a symbol profile overrides model for manual adjustments
+
+### Changed
+
+- Reused the value component in the _Ghostfolio in Numbers_ section of the about page
+- Persisted the savings rate in the _FIRE_ calculator
+- Upgraded `yahoo-finance2` from version `2.3.0` to `2.3.1`
+
+### Fixed
+
+- Fixed the calculation of the total value for sell and dividend activities in the create or edit transaction dialog
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.139.0 - 18.04.2022
+
+### Added
+
+- Added the total amount to the tooltip in the chart of the _FIRE_ calculator
+
+### Changed
+
+- Beautified the ETF names in the symbol profile
+
+### Fixed
+
+- Fixed an issue with changing the investment horizon in the chart of the _FIRE_ calculator
+- Fixed an issue with the end dates in the `.ics` file of the future activities (drafts) export
+- Fixed the data source of the _Fear & Greed Index_ (market mood)
+
+## 1.138.0 - 16.04.2022
+
+### Added
+
+- Added support to export a single future activity (draft) as an `.ics` file
+- Added the _Boringly Getting Rich_ guide to the resources section
+
+### Changed
+
+- Separated the deposit and savings in the chart of the _FIRE_ calculator
+
+## 1.137.0 - 15.04.2022
+
+### Added
+
+- Added support to export future activities (drafts) as an `.ics` file
+
+### Changed
+
+- Migrated the search functionality to `yahoo-finance2`
+
+### Fixed
+
+- Fixed an issue in the average price / investment calculation for sell activities
+
+## 1.136.0 - 13.04.2022
+
+### Changed
+
+- Changed the _Total_ label to _Total Assets_ in the portfolio summary tab on the home page
+
+### Fixed
+
+- Fixed an issue with the calculation of the projected total amount in the _FIRE_ calculator
+- Fixed an issue with the loading state of the _FIRE_ calculator
+
+## 1.135.0 - 10.04.2022
+
+### Added
+
+- Added a calculator to the _FIRE_ section
+- Added support for the cryptocurrency _Terra_ (`LUNA1-USD`)
+- Added support for the cryptocurrency _THORChain_ (`RUNE-USD`)
+
+## 1.134.0 - 09.04.2022
+
+### Changed
+
+- Switched to the new calculation engine
+- Improved the 4% rule in the _FIRE_ section
+- Changed the background of the header to a solid color
+
+## 1.133.0 - 07.04.2022
+
+### Changed
+
+- Improved the empty state of the portfolio proportion chart component
+
+### Fixed
+
+- Fixed an issue with dates in the value component
+
+## 1.132.1 - 06.04.2022
+
+### Fixed
+
+- Fixed an issue with percentages in the value component
+
+## 1.132.0 - 06.04.2022
+
+### Added
+
+- Added support for localization (date and number format) in user settings
+
+### Changed
+
+- Improved the label of the average price from _ร Buy Price_ to _Average Unit Price_
+
+## 1.131.1 - 04.04.2022
+
+### Fixed
+
+- Fixed the missing API version in the _Stripe_ success callback url
+
+## 1.131.0 - 02.04.2022
+
+### Added
+
+- Added API versioning
+- Added more durations in the coupon system
+
+### Changed
+
+- Display the value in base currency in the accounts table on mobile
+- Display the value in base currency in the activities table on mobile
+- Renamed `orders` to `activities` in import and export functionality
+- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
+- Improved the pricing page
+- Upgraded `prisma` from version `3.10.0` to `3.11.1`
+- Upgraded `yahoo-finance2` from version `2.2.0` to `2.3.0`
+
+## 1.130.0 - 30.03.2022
+
+### Added
+
+- Added a _FIRE_ (Financial Independence, Retire Early) section including the 4% rule
+- Added more durations in the coupon system
+
+### Fixed
+
+- Fixed an issue with the currency conversion (duplicate) in the account calculations
+
+## 1.129.0 - 26.03.2022
+
+### Added
+
+- Added the calculation for developed vs. emerging markets to the allocations page
+- Added a hover effect to the page tabs
+- Extended the feature overview page by _Bonds_ and _Emergency Fund_
+
+## 1.128.0 - 19.03.2022
+
+### Added
+
+- Added the attribute `defaultMarketPrice` to the scraper configuration to improve the support for bonds
+- Added a hover effect to the table style
+
+### Fixed
+
+- Fixed an issue with the user currency of the public page
+- Fixed an issue of the performance calculation with recent activities in the new calculation engine
+
+## 1.127.0 - 16.03.2022
+
+### Changed
+
+- Improved the error handling in the scraper configuration
+
+### Fixed
+
+- Fixed the support for multiple symbols of the data source `GHOSTFOLIO`
+
+## 1.126.0 - 14.03.2022
+
+### Added
+
+- Added support for bonds
+
+### Changed
+
+- Restructured the portfolio summary tab on the home page
+- Improved the tooltips in the portfolio proportion chart component by introducing multilines
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.125.0 - 12.03.2022
+
+### Added
+
+- Added support for an emergency fund
+- Added the contexts to the logger commands
+
+### Changed
+
+- Upgraded `Nx` from version `13.8.1` to `13.8.5`
+
+## 1.124.0 - 06.03.2022
+
+### Added
+
+- Added support for setting a duration in the coupon system
+
+### Changed
+
+- Upgraded `ngx-skeleton-loader` from version `2.9.1` to `5.0.0`
+- Upgraded `prisma` from version `3.9.1` to `3.10.0`
+- Upgraded `yahoo-finance2` from version `2.1.9` to `2.2.0`
+
+## 1.123.0 - 05.03.2022
+
+### Added
+
+- Included data provider errors in the API response
+
+### Changed
+
+- Removed the redundant attributes (`currency`, `dataSource`, `symbol`) of the activity model
+- Removed the prefix for symbols with the data source `GHOSTFOLIO`
+
+### Fixed
+
+- Improved the account calculations
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.122.0 - 01.03.2022
+
+### Added
+
+- Added support for click in the portfolio proportion chart component
+
+### Fixed
+
+- Fixed an issue with undefined currencies after creating an activity
+
+## 1.121.0 - 27.02.2022
+
+### Added
+
+- Added support for mutual funds
+- Added the url to the symbol profile model
+
+### Changed
+
+- Migrated from `yahoo-finance` to `yahoo-finance2`
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.120.0 - 25.02.2022
+
+### Changed
+
+- Distinguished the labels _Other_ and _Unknown_ in the portfolio proportion chart component
+- Improved the portfolio entry page
+
+### Fixed
+
+- Fixed the _Zen Mode_
+
+## 1.119.0 - 21.02.2022
+
+### Added
+
+- Added a trial for the subscription
+
+## 1.118.0 - 20.02.2022
+
+### Changed
+
+- Improved the calculation of the overall performance percentage in the new calculation engine
+- Displayed features in features overview page based on permissions
+- Extended the data points of historical data in the admin control panel
+
+## 1.117.0 - 19.02.2022
+
+### Changed
+
+- Moved the countries and sectors charts in the position detail dialog
+- Distinguished today's data point of historical data in the admin control panel
+- Restructured the server modules
+
+### Fixed
+
+- Fixed the allocations by account for non-unique account names
+- Added a fallback to the default account if the `accountId` is invalid in the import functionality for activities
+
+## 1.116.0 - 16.02.2022
+
+### Added
+
+- Added a service to tweet the current _Fear & Greed Index_ (market mood)
+
+### Changed
+
+- Improved the mobile layout of the position detail dialog (countries and sectors charts)
+
+### Fixed
+
+- Fixed the `maxItems` attribute of the portfolio proportion chart component
+- Fixed the time in market display of the portfolio summary tab on the home page
+
+## 1.115.0 - 13.02.2022
+
+### Added
+
+- Added a feature overview page
+- Added the asset and asset sub class to the position detail dialog
+- Added the countries and sectors to the position detail dialog
+
+### Changed
+
+- Upgraded `angular` from version `13.1.2` to `13.2.2`
+- Upgraded `Nx` from version `13.4.1` to `13.8.1`
+- Upgraded `storybook` from version `6.4.9` to `6.4.18`
+
+## 1.114.1 - 10.02.2022
+
+### Fixed
+
+- Fixed the creation of (wealth) items
+
+## 1.114.0 - 10.02.2022
+
+### Added
+
+- Added support for (wealth) items
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.113.0 - 09.02.2022
+
+### Changed
+
+- Improved the position of the currency column in the accounts table
+- Improved the position of the currency column in the activities table
+
+### Fixed
+
+- Fixed an issue with the performance calculation in connection with fees in the new calculation engine
+
+## 1.112.1 - 06.02.2022
+
+### Fixed
+
+- Fixed the creation of the user account (missing access token)
+
+## 1.112.0 - 06.02.2022
+
+### Added
+
+- Added the export functionality to the position detail dialog
+
+### Changed
+
+- Improved the export functionality for activities (respect filtering)
+- Removed the _Admin_ user from the database seeding
+- Assigned the role `ADMIN` on sign up (only if there is no admin yet)
+- Upgraded `prisma` from version `3.8.1` to `3.9.1`
+
+### Fixed
+
+- Fixed an issue with the performance calculation in connection with a sell activity in the new calculation engine
+- Fixed the horizontal overflow in the accounts table
+- Fixed the horizontal overflow in the activities table
+- Fixed the total value of the activities table in the position detail dialog (absolute value)
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.111.0 - 03.02.2022
+
+### Added
+
+- Added support for deleting symbol profile data in the admin control panel
+
+### Changed
+
+- Used `dataSource` and `symbol` from `SymbolProfile` instead of the `order` object (in `ExportService` and `PortfolioService`)
+
+### Fixed
+
+- Fixed the symbol selection of the 7d data gathering
+
+## 1.110.0 - 02.02.2022
+
+### Fixed
+
+- Fixed the data source of the _Fear & Greed Index_ (market mood)
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.109.0 - 01.02.2022
+
+### Added
+
+- Added support for the (optional) `accountId` in the import functionality for activities
+- Added support for the (optional) `dataSource` in the import functionality for activities
+- Added support for the data source transformation
+- Added support for the cryptocurrency _Mina Protocol_ (`MINA-USD`)
+
+### Changed
+
+- Improved the usability of the form in the create or edit transaction dialog
+- Improved the consistent use of `symbol` in combination with `dataSource`
+- Removed the primary data source from the client
+
+### Removed
+
+- Removed the unused endpoint `GET api/order/:id`
+
+## 1.108.0 - 27.01.2022
+
+### Changed
+
+- Improved the annualized performance in the new calculation engine
+- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 90 days
+
+## 1.107.0 - 24.01.2022
+
+### Added
+
+- Added a new calculation engine (experimental)
+
+### Fixed
+
+- Fixed the styling in the footer row of the activities table
+
+## 1.106.0 - 23.01.2022
+
+### Added
+
+- Added the footer row with total fees and total value to the activities table
+
+### Changed
+
+- Extended the historical data view in the admin control panel
+- Upgraded _Stripe_ dependencies
+- Upgraded `prisma` from version `3.7.0` to `3.8.1`
+
+### Fixed
+
+- Improved the redirection on logout
+
+## 1.105.0 - 20.01.2022
+
+### Added
+
+- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
+
+### Changed
+
+- Improved the data provider with grouping by data source and thereby reducing the number of requests
+
+### Fixed
+
+- Fixed the unresolved account names in the _X-ray_ section
+- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
+
+## 1.104.0 - 16.01.2022
+
+### Fixed
+
+- Fixed the fallback to load currencies directly from the data provider
+- Fixed the missing symbol profile data connection in the import functionality for activities
+
+## 1.103.0 - 13.01.2022
+
+### Changed
+
+- Added links to the statistics section on the about page
+
+### Fixed
+
+- Fixed the currency of the value in the position detail dialog
+
+## 1.102.0 - 11.01.2022
+
+### Changed
+
+- Start eliminating `dataSource` from activity
+
+### Fixed
+
+- Fixed the support for multiple accounts with the same name
+- Fixed the preselected default account of the create activity dialog
+
+## 1.101.0 - 08.01.2022
+
+### Added
+
+- Added `GOOGLE_SHEETS` as a new data source type
+
+### Changed
+
+- Excluded the url pattern of shared portfolios in the `robots.txt` file
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.100.0 - 05.01.2022
+
+### Added
+
+- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
+- Added a blog post
+
+### Fixed
+
+- Fixed the routing of the create activity dialog
+- Fixed the link color in the blog posts
+
+## 1.99.0 - 01.01.2022
+
+### Added
+
+- Exposed the profile data gathering by symbol as an endpoint
+
+### Changed
+
+- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
+- Restructured the about page
+- Start refactoring _transactions_ to _activities_
+- Refactored the demo user id
+- Upgraded `angular` from version `13.0.2` to `13.1.1`
+- Upgraded `chart.js` from version `3.5.0` to `3.7.0`
+- Upgraded `Nx` from version `13.3.0` to `13.4.1`
+
+### Fixed
+
+- Hid the data provider warning while loading
+- Fixed an exception with the market state caused by a failed data provider request
+- Fixed an exception in the portfolio position endpoint
+- Fixed the reload of the position detail dialog (with query parameters)
+- Fixed the missing mapping for Russia in the data enhancer for symbol profile data via _Trackinsight_
+
+## 1.98.0 - 29.12.2021
+
+### Added
+
+- Added the date range component to the holdings tab
+
+### Changed
+
+- Extended the statistics section on the about page (users in Slack community)
+
+### Fixed
+
+- Fixed the creation of historical data in the admin control panel (upsert instead of update)
+- Fixed the scrolling issue in the position detail dialog on mobile
+
+## 1.97.0 - 28.12.2021
+
+### Added
+
+- Added the transactions to the position detail dialog
+- Added support for dividend
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.96.0 - 27.12.2021
+
+### Changed
+
+- Made the data provider warning more discreet
+- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
+- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
+- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
+- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
+- Upgraded `prisma` from version `3.6.0` to `3.7.0`
+
+### Fixed
+
+- Fixed the file type detection in the import functionality for transactions
+
+## 1.95.0 - 26.12.2021
+
+### Added
+
+- Added a warning to the log if the data gathering fails
+
+### Fixed
+
+- Filtered potential `null` currencies
+- Improved the 7d data gathering optimization for currencies
+
+## 1.94.0 - 25.12.2021
+
+### Added
+
+- Added support for cryptocurrencies _Cosmos_ (`ATOM-USD`) and _Polkadot_ (`DOT-USD`)
+
+### Changed
+
+- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 30 days
+- Made the import functionality for transactions by `csv` files more flexible
+- Optimized the 7d data gathering (only consider symbols with incomplete market data)
+- Upgraded `prettier` from version `2.3.2` to `2.5.1`
+
+## 1.93.0 - 21.12.2021
+
+### Added
+
+- Added support for the cryptocurrency _Solana_ (`SOL-USD`)
+- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
+
+### Fixed
+
+- Converted errors to warnings in portfolio calculator
+
+## 1.92.0 - 19.12.2021
+
+### Added
+
+- Added a line chart to the historical data view in the admin control panel
+- Supported the update of historical data in the admin control panel
+
+### Fixed
+
+- Improved the redirection on logout
+- Fixed the permission for the system status page
+
+## 1.91.0 - 18.12.2021
+
+### Changed
+
+- Removed the redundant all time high and all time low from the performance endpoint
+
+### Fixed
+
+- Fixed the symbol conversion from _Yahoo Finance_ including a hyphen
+- Fixed hidden values (`0`) in the statistics section on the about page
+
+### Todo
+
+- Apply data migration (`yarn database:migrate`)
+
+## 1.90.0 - 14.12.2021
+
+### Added
+
+- Extended the validation in the import functionality for transactions by checking the currency of the data provider service
+- Added support for cryptocurrency _Uniswap_
+- Set up pipeline for docker build
+
+### Changed
+
+- Removed the default transactions import limit
+- Improved the landing page in dark mode
+
+### Fixed
+
+- Fixed `/bin/sh: prisma: not found` in docker build
+- Added `apk` in `Dockerfile` (`python3 g++ make openssl`)
+
+## 1.89.0 - 11.12.2021
+
+### Added
+
+- Extended the data gathering by symbol endpoint with an optional date
+
+### Changed
+
+- Upgraded `Nx` from version `13.2.2` to `13.3.0`
+- Upgraded `storybook` from version `6.4.0-rc.3` to `6.4.9`
+
+## 1.88.0 - 09.12.2021
+
+### Added
+
+- Added a coupon system
+
+## 1.87.0 - 07.12.2021
+
### Added
- Supported the management of additional currencies in the admin control panel
+- Introduced the system message
+- Introduced the read only mode
### Changed
+- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 10 days
- Upgraded `prisma` from version `2.30.2` to `3.6.0`
## 1.86.0 - 04.12.2021
diff --git a/Dockerfile b/Dockerfile
index 995102f2e..c02d42d01 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,7 +12,8 @@ COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
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
COPY ./decorate-angular-cli.js decorate-angular-cli.js
@@ -21,8 +22,8 @@ RUN node decorate-angular-cli.js
COPY ./angular.json angular.json
COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js
-COPY ./jest.preset.js jest.preset.js
-COPY ./jest.config.js jest.config.js
+COPY ./jest.preset.ts jest.preset.ts
+COPY ./jest.config.ts jest.config.ts
COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps
diff --git a/README.md b/README.md
index 03f8dc8a2..fd737530f 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
Open Source Wealth Management Software made for Humans
@@ -34,28 +34,20 @@
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
-If you prefer to run Ghostfolio on your own infrastructure, please find 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?
Ghostfolio is for you if you are...
- ๐ผ trading stocks, ETFs or cryptocurrencies on multiple platforms
-
- ๐ฆ pursuing a buy & hold strategy
-
- ๐ฏ interested in getting insights of your portfolio composition
-
- ๐ป valuing privacy and data ownership
-
- ๐ง into minimalism
-
- ๐งบ caring about diversifying your financial resources
-
- ๐ interested in financial independence
-
- ๐
saying no to spreadsheets in 2021
-
- ๐ still reading this list
## Features
@@ -65,6 +57,7 @@ Ghostfolio is for you if you are...
- โ
Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- โ
Various charts
- โ
Static analysis to identify potential risks in your portfolio
+- โ
Import and export transactions
- โ
Dark Mode
- โ
Zen Mode
- โ
Mobile-first design
@@ -81,44 +74,59 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
-## Run with Docker
+## Run with Docker (self-hosting)
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
+- A local copy of this Git repository (clone)
+
+### a. Run environment
+
+Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
+
+```bash
+docker-compose -f docker/docker-compose.yml up -d
+```
-### Setup Docker Image
+#### Setup Database
-Run the following commands to build and start the Docker image:
+Run the following command to setup the database once Ghostfolio is running:
```bash
-docker-compose -f docker/docker-compose-build-local.yml build
-docker-compose -f docker/docker-compose-build-local.yml up
+docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
-### Setup Database
+### b. Build and run environment
+
+Run the following commands to build and start the Docker images:
+
+```bash
+docker-compose -f docker/docker-compose.build.yml build
+docker-compose -f docker/docker-compose.build.yml up -d
+```
+
+#### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
-docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
+docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
```
### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
-1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
+1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
-### Migrate Database
+### Upgrade Version
-With the following command you can keep your database schema in sync after a Ghostfolio version update:
-
-```bash
-docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
-```
+1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
+1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
+1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
## Development
@@ -127,16 +135,15 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install)
+- A local copy of this Git repository (clone)
### Setup
1. Run `yarn install`
-1. Run `cd docker`
-1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
-1. Run `cd -` to go back to the project root directory
+1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
-1. Start server and client (see [_Development_](#Development))
-1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
+1. Start the server and the client (see [_Development_](#Development))
+1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
@@ -155,18 +162,94 @@ Run `yarn start:client`
Run `yarn start:storybook`
+### Migrate Database
+
+With the following command you can keep your database schema in sync:
+
+```bash
+yarn database:push
+```
+
## Testing
Run `yarn test`
+## Public API (experimental)
+
+### Import Activities
+
+#### Request
+
+`POST http://localhost:3333/api/v1/import`
+
+#### Authorization: Bearer Token
+
+Set the header as follows:
+
+```
+"Authorization": "Bearer eyJh..."
+```
+
+#### Body
+
+```
+{
+ "activities": [
+ {
+ "currency": "USD",
+ "dataSource": "YAHOO",
+ "date": "2021-09-15T00:00:00.000Z",
+ "fee": 19,
+ "quantity": 5,
+ "symbol": "MSFT"
+ "type": "BUY",
+ "unitPrice": 298.58
+ }
+ ]
+}
+```
+
+| Field | Type | Description |
+| ---------- | ------------------- | -------------------------------------------------- |
+| accountId | string (`optional`) | Id of the account |
+| currency | string | `CHF` \| `EUR` \| `USD` etc. |
+| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
+| date | string | Date in the format `ISO-8601` |
+| fee | number | Fee of the activity |
+| quantity | number | Quantity of the activity |
+| symbol | string | Symbol of the activity (suitable for `dataSource`) |
+| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
+| unitPrice | number | Price per unit of the activity |
+
+#### Response
+
+##### Success
+
+`201 Created`
+
+##### Error
+
+`400 Bad Request`
+
+```
+{
+ "error": "Bad Request",
+ "message": [
+ "activities.1 is a duplicate activity"
+ ]
+}
+```
+
## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
+If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
+
## License
-ยฉ 2021 [Ghostfolio](https://ghostfol.io)
+ยฉ 2022 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
diff --git a/angular.json b/angular.json
index c2f3109e6..e8c179ff2 100644
--- a/angular.json
+++ b/angular.json
@@ -9,7 +9,7 @@
"schematics": {},
"architect": {
"build": {
- "builder": "@nrwl/node:build",
+ "builder": "@nrwl/node:webpack",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
@@ -33,7 +33,7 @@
"outputs": ["{options.outputPath}"]
},
"serve": {
- "builder": "@nrwl/node:execute",
+ "builder": "@nrwl/node:node",
"options": {
"buildTarget": "api:build"
}
@@ -47,7 +47,7 @@
"test": {
"builder": "@nrwl/jest:jest",
"options": {
- "jestConfig": "apps/api/jest.config.js",
+ "jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/api"]
@@ -180,7 +180,7 @@
"test": {
"builder": "@nrwl/jest:jest",
"options": {
- "jestConfig": "apps/client/jest.config.js",
+ "jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/client"]
@@ -225,7 +225,7 @@
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"],
"options": {
- "jestConfig": "libs/common/jest.config.js",
+ "jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true
}
}
@@ -247,7 +247,7 @@
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"],
"options": {
- "jestConfig": "libs/ui/jest.config.js",
+ "jestConfig": "libs/ui/jest.config.ts",
"passWithNoTests": true
}
},
@@ -264,7 +264,8 @@
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
- }
+ },
+ "projectBuildConfig": "ui:build-storybook"
},
"configurations": {
"ci": {
@@ -280,7 +281,8 @@
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
- }
+ },
+ "projectBuildConfig": "ui:build-storybook"
},
"configurations": {
"ci": {
diff --git a/apps/api/jest.config.js b/apps/api/jest.config.ts
similarity index 83%
rename from apps/api/jest.config.js
rename to apps/api/jest.config.ts
index c46248946..9f0ebed54 100644
--- a/apps/api/jest.config.js
+++ b/apps/api/jest.config.ts
@@ -1,6 +1,6 @@
module.exports = {
displayName: 'api',
- preset: '../../jest.preset.js',
+
globals: {
'ts-jest': {
tsconfig: '
/tsconfig.spec.json'
@@ -12,5 +12,6 @@ module.exports = {
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000,
- testEnvironment: 'node'
+ testEnvironment: 'node',
+ preset: '../../jest.preset.ts'
};
diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts
index bbf5423cc..a778d8b57 100644
--- a/apps/api/src/app/access/access.controller.ts
+++ b/apps/api/src/app/access/access.controller.ts
@@ -1,9 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces';
-import {
- getPermissions,
- hasPermission,
- permissions
-} from '@ghostfolio/common/permissions';
+import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@@ -66,10 +62,7 @@ export class AccessController {
@Body() data: CreateAccessDto
): Promise {
if (
- !hasPermission(
- getPermissions(this.request.user.role),
- permissions.createAccess
- )
+ !hasPermission(this.request.user.permissions, permissions.createAccess)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@@ -85,11 +78,12 @@ export class AccessController {
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise {
+ const access = await this.accessService.access({ id });
+
if (
- !hasPermission(
- getPermissions(this.request.user.role),
- permissions.deleteAccess
- )
+ !hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
+ !access ||
+ access.userId !== this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@@ -98,10 +92,7 @@ export class AccessController {
}
return this.accessService.deleteAccess({
- id_userId: {
- id,
- userId: this.request.user.id
- }
+ id
});
}
}
diff --git a/apps/api/src/app/access/access.module.ts b/apps/api/src/app/access/access.module.ts
index 303989b21..d70388038 100644
--- a/apps/api/src/app/access/access.module.ts
+++ b/apps/api/src/app/access/access.module.ts
@@ -1,4 +1,4 @@
-import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { AccessController } from './access.controller';
@@ -7,7 +7,7 @@ import { AccessService } from './access.service';
@Module({
controllers: [AccessController],
exports: [AccessService],
- imports: [],
- providers: [AccessService, PrismaService]
+ imports: [PrismaModule],
+ providers: [AccessService]
})
export class AccessModule {}
diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts
index a832bfca9..819fc5a0d 100644
--- a/apps/api/src/app/account/account.controller.ts
+++ b/apps/api/src/app/account/account.controller.ts
@@ -6,11 +6,7 @@ import {
} from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces';
-import {
- getPermissions,
- hasPermission,
- permissions
-} from '@ghostfolio/common/permissions';
+import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@@ -48,10 +44,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
public async deleteAccount(@Param('id') id: string): Promise {
if (
- !hasPermission(
- getPermissions(this.request.user.role),
- permissions.deleteAccount
- )
+ !hasPermission(this.request.user.permissions, permissions.deleteAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@@ -109,16 +102,18 @@ export class AccountController {
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
- 'totalBalance',
- 'totalValue'
+ 'totalBalanceInBaseCurrency',
+ 'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
+ 'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
- 'value'
+ 'value',
+ 'valueInBaseCurrency'
])
};
}
@@ -143,10 +138,7 @@ export class AccountController {
@Body() data: CreateAccountDto
): Promise {
if (
- !hasPermission(
- getPermissions(this.request.user.role),
- permissions.createAccount
- )
+ !hasPermission(this.request.user.permissions, permissions.createAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@@ -183,10 +175,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if (
- !hasPermission(
- getPermissions(this.request.user.role),
- permissions.updateAccount
- )
+ !hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts
index 2c11de472..90bf909fc 100644
--- a/apps/api/src/app/account/account.module.ts
+++ b/apps/api/src/app/account/account.module.ts
@@ -13,6 +13,7 @@ import { AccountService } from './account.service';
@Module({
controllers: [AccountController],
+ exports: [AccountService],
imports: [
ConfigurationModule,
DataProviderModule,
diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts
index 9f5aab1b2..b9b65716a 100644
--- a/apps/api/src/app/account/account.service.ts
+++ b/apps/api/src/app/account/account.service.ts
@@ -1,7 +1,10 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client';
+import Big from 'big.js';
+import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@@ -101,25 +104,51 @@ export class AccountService {
});
}
- public async getCashDetails(
- aUserId: string,
- aCurrency: string
- ): Promise {
- let totalCashBalance = 0;
-
- const accounts = await this.accounts({
- where: { userId: aUserId }
+ public async getCashDetails({
+ currency,
+ filters = [],
+ userId
+ }: {
+ currency: string;
+ filters?: Filter[];
+ userId: string;
+ }): Promise {
+ let totalCashBalanceInBaseCurrency = new Big(0);
+
+ const where: Prisma.AccountWhereInput = { userId };
+
+ const {
+ ACCOUNT: filtersByAccount,
+ ASSET_CLASS: filtersByAssetClass,
+ TAG: filtersByTag
+ } = groupBy(filters, (filter) => {
+ return filter.type;
});
- accounts.forEach((account) => {
- totalCashBalance += this.exchangeRateDataService.toCurrency(
- account.balance,
- account.currency,
- aCurrency
+ if (filtersByAccount?.length > 0) {
+ where.id = {
+ in: filtersByAccount.map(({ id }) => {
+ return id;
+ })
+ };
+ }
+
+ const accounts = await this.accounts({ where });
+
+ for (const account of accounts) {
+ totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
+ this.exchangeRateDataService.toCurrency(
+ account.balance,
+ account.currency,
+ currency
+ )
);
- });
+ }
- return { accounts, balance: totalCashBalance };
+ return {
+ accounts,
+ balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber()
+ };
}
public async updateAccount(
diff --git a/apps/api/src/app/account/interfaces/cash-details.interface.ts b/apps/api/src/app/account/interfaces/cash-details.interface.ts
index 146ee6b29..715343766 100644
--- a/apps/api/src/app/account/interfaces/cash-details.interface.ts
+++ b/apps/api/src/app/account/interfaces/cash-details.interface.ts
@@ -2,5 +2,5 @@ import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
- balance: number;
+ balanceInBaseCurrency: number;
}
diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts
index 054371458..c67b443c3 100644
--- a/apps/api/src/app/admin/admin.controller.ts
+++ b/apps/api/src/app/admin/admin.controller.ts
@@ -1,21 +1,22 @@
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
-import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
+import {
+ DATA_GATHERING_QUEUE,
+ GATHER_ASSET_PROFILE_PROCESS
+} from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
} from '@ghostfolio/common/interfaces';
-import {
- getPermissions,
- hasPermission,
- permissions
-} from '@ghostfolio/common/permissions';
+import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
+import { InjectQueue } from '@nestjs/bull';
import {
Body,
Controller,
+ Delete,
Get,
HttpException,
Inject,
@@ -26,17 +27,22 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
-import { DataSource } from '@prisma/client';
+import { DataSource, MarketData } from '@prisma/client';
+import { Queue } from 'bull';
+import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
+import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin')
export class AdminController {
public constructor(
private readonly adminService: AdminService,
+ @InjectQueue(DATA_GATHERING_QUEUE)
+ private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService,
- private readonly propertyService: PropertyService,
+ private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@@ -45,7 +51,7 @@ export class AdminController {
public async getAdminData(): Promise {
if (
!hasPermission(
- getPermissions(this.request.user.role),
+ this.request.user.permissions,
permissions.accessAdminControl
)
) {
@@ -63,7 +69,7 @@ export class AdminController {
public async gatherMax(): Promise {
if (
!hasPermission(
- getPermissions(this.request.user.role),
+ this.request.user.permissions,
permissions.accessAdminControl
)
) {
@@ -73,10 +79,65 @@ export class AdminController {
);
}
- await this.dataGatheringService.gatherProfileData();
+ const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
+
+ for (const { dataSource, symbol } of uniqueAssets) {
+ await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
+ dataSource,
+ symbol
+ });
+ }
+
this.dataGatheringService.gatherMax();
+ }
- return;
+ @Post('gather/profile-data')
+ @UseGuards(AuthGuard('jwt'))
+ public async gatherProfileData(): Promise {
+ if (
+ !hasPermission(
+ this.request.user.permissions,
+ permissions.accessAdminControl
+ )
+ ) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.FORBIDDEN),
+ StatusCodes.FORBIDDEN
+ );
+ }
+
+ const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
+
+ for (const { dataSource, symbol } of uniqueAssets) {
+ await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
+ dataSource,
+ symbol
+ });
+ }
+ }
+
+ @Post('gather/profile-data/:dataSource/:symbol')
+ @UseGuards(AuthGuard('jwt'))
+ public async gatherProfileDataForSymbol(
+ @Param('dataSource') dataSource: DataSource,
+ @Param('symbol') symbol: string
+ ): Promise {
+ if (
+ !hasPermission(
+ this.request.user.permissions,
+ permissions.accessAdminControl
+ )
+ ) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.FORBIDDEN),
+ StatusCodes.FORBIDDEN
+ );
+ }
+
+ await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
+ dataSource,
+ symbol
+ });
}
@Post('gather/:dataSource/:symbol')
@@ -87,7 +148,7 @@ export class AdminController {
): Promise {
if (
!hasPermission(
- getPermissions(this.request.user.role),
+ this.request.user.permissions,
permissions.accessAdminControl
)
) {
@@ -102,12 +163,16 @@ export class AdminController {
return;
}
- @Post('gather/profile-data')
+ @Post('gather/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
- public async gatherProfileData(): Promise {
+ public async gatherSymbolForDate(
+ @Param('dataSource') dataSource: DataSource,
+ @Param('dateString') dateString: string,
+ @Param('symbol') symbol: string
+ ): Promise {
if (
!hasPermission(
- getPermissions(this.request.user.role),
+ this.request.user.permissions,
permissions.accessAdminControl
)
) {
@@ -117,9 +182,20 @@ export class AdminController {
);
}
- this.dataGatheringService.gatherProfileData();
+ const date = new Date(dateString);
- return;
+ if (!isDate(date)) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.BAD_REQUEST),
+ StatusCodes.BAD_REQUEST
+ );
+ }
+
+ return this.dataGatheringService.gatherSymbolForDate({
+ dataSource,
+ date,
+ symbol
+ });
}
@Get('market-data')
@@ -127,7 +203,7 @@ export class AdminController {
public async getMarketData(): Promise {
if (
!hasPermission(
- getPermissions(this.request.user.role),
+ this.request.user.permissions,
permissions.accessAdminControl
)
) {
@@ -140,14 +216,69 @@ export class AdminController {
return this.adminService.getMarketData();
}
- @Get('market-data/:symbol')
+ @Get('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(
- @Param('symbol') symbol
+ @Param('dataSource') dataSource: DataSource,
+ @Param('symbol') symbol: string
): Promise {
if (
!hasPermission(
- getPermissions(this.request.user.role),
+ this.request.user.permissions,
+ permissions.accessAdminControl
+ )
+ ) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.FORBIDDEN),
+ StatusCodes.FORBIDDEN
+ );
+ }
+
+ return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
+ }
+
+ @Put('market-data/:dataSource/:symbol/:dateString')
+ @UseGuards(AuthGuard('jwt'))
+ public async update(
+ @Param('dataSource') dataSource: DataSource,
+ @Param('dateString') dateString: string,
+ @Param('symbol') symbol: string,
+ @Body() data: UpdateMarketDataDto
+ ) {
+ if (
+ !hasPermission(
+ this.request.user.permissions,
+ permissions.accessAdminControl
+ )
+ ) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.FORBIDDEN),
+ StatusCodes.FORBIDDEN
+ );
+ }
+
+ const date = new Date(dateString);
+
+ return this.marketDataService.updateMarketData({
+ data: { ...data, dataSource },
+ where: {
+ date_symbol: {
+ date,
+ symbol
+ }
+ }
+ });
+ }
+
+ @Delete('profile-data/:dataSource/:symbol')
+ @UseGuards(AuthGuard('jwt'))
+ public async deleteProfileData(
+ @Param('dataSource') dataSource: DataSource,
+ @Param('symbol') symbol: string
+ ): Promise {
+ if (
+ !hasPermission(
+ this.request.user.permissions,
permissions.accessAdminControl
)
) {
@@ -157,7 +288,7 @@ export class AdminController {
);
}
- return this.adminService.getMarketDataBySymbol(symbol);
+ return this.adminService.deleteProfileData({ dataSource, symbol });
}
@Put('settings/:key')
@@ -168,7 +299,7 @@ export class AdminController {
) {
if (
!hasPermission(
- getPermissions(this.request.user.role),
+ this.request.user.permissions,
permissions.accessAdminControl
)
) {
diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts
index b8e96fa04..8e83236e4 100644
--- a/apps/api/src/app/admin/admin.module.ts
+++ b/apps/api/src/app/admin/admin.module.ts
@@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
+import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
@@ -20,7 +21,8 @@ import { AdminService } from './admin.service';
MarketDataModule,
PrismaModule,
PropertyModule,
- SubscriptionModule
+ SubscriptionModule,
+ SymbolProfileModule
],
controllers: [AdminController],
providers: [AdminService],
diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts
index 4b9aaad4c..40a15afc2 100644
--- a/apps/api/src/app/admin/admin.service.ts
+++ b/apps/api/src/app/admin/admin.service.ts
@@ -5,13 +5,17 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
+import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
- AdminMarketDataDetails
+ AdminMarketDataDetails,
+ AdminMarketDataItem,
+ UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
+import { Property } from '@prisma/client';
import { differenceInDays } from 'date-fns';
@Injectable()
@@ -23,9 +27,15 @@ export class AdminService {
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
- private readonly subscriptionService: SubscriptionService
+ private readonly subscriptionService: SubscriptionService,
+ private readonly symbolProfileService: SymbolProfileService
) {}
+ public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
+ await this.marketDataService.deleteMany({ dataSource, symbol });
+ await this.symbolProfileService.delete({ dataSource, symbol });
+ }
+
public async get(): Promise {
return {
dataGatheringProgress:
@@ -55,32 +65,95 @@ export class AdminService {
}
public async getMarketData(): Promise {
- return {
- marketData: await (
- await this.dataGatheringService.getSymbolsMax()
- ).map((symbol) => {
- return symbol;
+ const marketData = await this.prismaService.marketData.groupBy({
+ _count: true,
+ by: ['dataSource', 'symbol']
+ });
+
+ const currencyPairsToGather: AdminMarketDataItem[] =
+ this.exchangeRateDataService
+ .getCurrencyPairs()
+ .map(({ dataSource, symbol }) => {
+ const marketDataItemCount =
+ marketData.find((marketDataItem) => {
+ return (
+ marketDataItem.dataSource === dataSource &&
+ marketDataItem.symbol === symbol
+ );
+ })?._count ?? 0;
+
+ return {
+ dataSource,
+ marketDataItemCount,
+ symbol
+ };
+ });
+
+ const symbolProfilesToGather: AdminMarketDataItem[] = (
+ await this.prismaService.symbolProfile.findMany({
+ orderBy: [{ symbol: 'asc' }],
+ select: {
+ _count: {
+ select: { Order: true }
+ },
+ dataSource: true,
+ Order: {
+ orderBy: [{ date: 'asc' }],
+ select: { date: true },
+ take: 1
+ },
+ scraperConfiguration: true,
+ symbol: true
+ }
})
+ ).map((symbolProfile) => {
+ const marketDataItemCount =
+ marketData.find((marketDataItem) => {
+ return (
+ marketDataItem.dataSource === symbolProfile.dataSource &&
+ marketDataItem.symbol === symbolProfile.symbol
+ );
+ })?._count ?? 0;
+
+ return {
+ marketDataItemCount,
+ activityCount: symbolProfile._count.Order,
+ dataSource: symbolProfile.dataSource,
+ date: symbolProfile.Order?.[0]?.date,
+ symbol: symbolProfile.symbol
+ };
+ });
+
+ return {
+ marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
};
}
- public async getMarketDataBySymbol(
- aSymbol: string
- ): Promise {
+ public async getMarketDataBySymbol({
+ dataSource,
+ symbol
+ }: UniqueAsset): Promise {
return {
marketData: await this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
- symbol: aSymbol
+ dataSource,
+ symbol
}
})
};
}
public async putSetting(key: string, value: string) {
- const response = await this.propertyService.put({ key, value });
+ 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();
diff --git a/apps/api/src/app/admin/update-market-data.dto.ts b/apps/api/src/app/admin/update-market-data.dto.ts
new file mode 100644
index 000000000..79779a318
--- /dev/null
+++ b/apps/api/src/app/admin/update-market-data.dto.ts
@@ -0,0 +1,6 @@
+import { IsNumber } from 'class-validator';
+
+export class UpdateMarketDataDto {
+ @IsNumber()
+ marketPrice: number;
+}
diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts
index 46b2c9060..f3b85fc9b 100644
--- a/apps/api/src/app/app.module.ts
+++ b/apps/api/src/app/app.module.ts
@@ -8,6 +8,8 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
+import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
+import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
@@ -35,6 +37,12 @@ import { UserModule } from './user/user.module';
AccountModule,
AuthDeviceModule,
AuthModule,
+ BullModule.forRoot({
+ redis: {
+ host: process.env.REDIS_HOST,
+ port: parseInt(process.env.REDIS_PORT, 10)
+ }
+ }),
CacheModule,
ConfigModule.forRoot(),
ConfigurationModule,
@@ -65,6 +73,7 @@ import { UserModule } from './user/user.module';
}),
SubscriptionModule,
SymbolModule,
+ TwitterBotModule,
UserModule
],
controllers: [AppController],
diff --git a/apps/api/src/app/auth-device/auth-device.controller.ts b/apps/api/src/app/auth-device/auth-device.controller.ts
index 89a44dd2f..33eae0cc0 100644
--- a/apps/api/src/app/auth-device/auth-device.controller.ts
+++ b/apps/api/src/app/auth-device/auth-device.controller.ts
@@ -1,9 +1,5 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
-import {
- getPermissions,
- hasPermission,
- permissions
-} from '@ghostfolio/common/permissions';
+import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
@@ -29,7 +25,7 @@ export class AuthDeviceController {
public async deleteAuthDevice(@Param('id') id: string): Promise {
if (
!hasPermission(
- getPermissions(this.request.user.role),
+ this.request.user.permissions,
permissions.deleteAuthDevice
)
) {
diff --git a/apps/api/src/app/auth-device/auth-device.module.ts b/apps/api/src/app/auth-device/auth-device.module.ts
index 360930cf2..de6cd1cbc 100644
--- a/apps/api/src/app/auth-device/auth-device.module.ts
+++ b/apps/api/src/app/auth-device/auth-device.module.ts
@@ -1,18 +1,20 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
-import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
-import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
+import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@Module({
controllers: [AuthDeviceController],
imports: [
+ ConfigurationModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
- })
+ }),
+ PrismaModule
],
- providers: [AuthDeviceService, ConfigurationService, PrismaService]
+ providers: [AuthDeviceService]
})
export class AuthDeviceModule {}
diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts
index 5244307dd..738f81ab2 100644
--- a/apps/api/src/app/auth/auth.controller.ts
+++ b/apps/api/src/app/auth/auth.controller.ts
@@ -9,7 +9,9 @@ import {
Post,
Req,
Res,
- UseGuards
+ UseGuards,
+ VERSION_NEUTRAL,
+ Version
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@@ -51,6 +53,7 @@ export class AuthController {
@Get('google/callback')
@UseGuards(AuthGuard('google'))
+ @Version(VERSION_NEUTRAL)
public googleLoginCallback(@Req() req, @Res() res) {
// Handles the Google OAuth2 callback
const jwt: string = req.user.jwt;
diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts
index d573f91fe..b25e4c18b 100644
--- a/apps/api/src/app/auth/auth.module.ts
+++ b/apps/api/src/app/auth/auth.module.ts
@@ -1,9 +1,9 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
-import { UserService } from '@ghostfolio/api/app/user/user.service';
-import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
-import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { UserModule } from '@ghostfolio/api/app/user/user.module';
+import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
+import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@@ -15,20 +15,20 @@ import { JwtStrategy } from './jwt.strategy';
@Module({
controllers: [AuthController],
imports: [
+ ConfigurationModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
}),
- SubscriptionModule
+ PrismaModule,
+ SubscriptionModule,
+ UserModule
],
providers: [
AuthDeviceService,
AuthService,
- ConfigurationService,
GoogleStrategy,
JwtStrategy,
- PrismaService,
- UserService,
WebAuthService
]
})
diff --git a/apps/api/src/app/auth/google.strategy.ts b/apps/api/src/app/auth/google.strategy.ts
index 43def1baf..c8fb260b7 100644
--- a/apps/api/src/app/auth/google.strategy.ts
+++ b/apps/api/src/app/auth/google.strategy.ts
@@ -42,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
done(null, user);
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'GoogleStrategy');
done(error, false);
}
}
diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts
index ba60b028b..9212a2e07 100644
--- a/apps/api/src/app/auth/web-auth.service.ts
+++ b/apps/api/src/app/auth/web-auth.service.ts
@@ -95,7 +95,7 @@ export class WebAuthService {
};
verification = await verifyRegistrationResponse(opts);
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException(error.message);
}
@@ -193,7 +193,7 @@ export class WebAuthService {
};
verification = verifyAuthenticationResponse(opts);
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message });
}
diff --git a/apps/api/src/app/cache/cache.module.ts b/apps/api/src/app/cache/cache.module.ts
index a823c2d1e..7b427b7a0 100644
--- a/apps/api/src/app/cache/cache.module.ts
+++ b/apps/api/src/app/cache/cache.module.ts
@@ -1,30 +1,27 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
-import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
+import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
-import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
-import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { CacheController } from './cache.controller';
@Module({
+ exports: [CacheService],
+ controllers: [CacheController],
imports: [
+ ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
+ PrismaModule,
RedisCacheModule,
SymbolProfileModule
],
- controllers: [CacheController],
- providers: [
- CacheService,
- ConfigurationService,
- DataGatheringService,
- PrismaService
- ]
+ providers: [CacheService]
})
export class CacheModule {}
diff --git a/apps/api/src/app/export/export.controller.ts b/apps/api/src/app/export/export.controller.ts
index ca318ce81..ce02d9835 100644
--- a/apps/api/src/app/export/export.controller.ts
+++ b/apps/api/src/app/export/export.controller.ts
@@ -1,6 +1,6 @@
import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
-import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
+import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@@ -15,8 +15,11 @@ export class ExportController {
@Get()
@UseGuards(AuthGuard('jwt'))
- public async export(): Promise {
- return await this.exportService.export({
+ public async export(
+ @Query('activityIds') activityIds?: string[]
+ ): Promise {
+ return this.exportService.export({
+ activityIds,
userId: this.request.user.id
});
}
diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts
index 5fd88d4c7..74cdf14f1 100644
--- a/apps/api/src/app/export/export.service.ts
+++ b/apps/api/src/app/export/export.service.ts
@@ -7,25 +7,61 @@ import { Injectable } from '@nestjs/common';
export class ExportService {
public constructor(private readonly prismaService: PrismaService) {}
- public async export({ userId }: { userId: string }): Promise {
- const orders = await this.prismaService.order.findMany({
+ public async export({
+ activityIds,
+ userId
+ }: {
+ activityIds?: string[];
+ userId: string;
+ }): Promise {
+ let activities = await this.prismaService.order.findMany({
orderBy: { date: 'desc' },
select: {
- currency: true,
- dataSource: true,
+ accountId: true,
date: true,
fee: true,
+ id: true,
quantity: true,
- symbol: true,
+ SymbolProfile: true,
type: true,
unitPrice: true
},
where: { userId }
});
+ if (activityIds) {
+ activities = activities.filter((activity) => {
+ return activityIds.includes(activity.id);
+ });
+ }
+
return {
meta: { date: new Date().toISOString(), version: environment.version },
- orders
+ activities: activities.map(
+ ({
+ accountId,
+ date,
+ fee,
+ id,
+ quantity,
+ SymbolProfile,
+ type,
+ unitPrice
+ }) => {
+ return {
+ accountId,
+ fee,
+ id,
+ quantity,
+ type,
+ unitPrice,
+ currency: SymbolProfile.currency,
+ dataSource: SymbolProfile.dataSource,
+ date: date.toISOString(),
+ symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
+ };
+ }
+ )
};
}
}
diff --git a/apps/api/src/app/import/import-data.dto.ts b/apps/api/src/app/import/import-data.dto.ts
index fa1b3aa99..f3a0ba8fe 100644
--- a/apps/api/src/app/import/import-data.dto.ts
+++ b/apps/api/src/app/import/import-data.dto.ts
@@ -1,5 +1,4 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
-import { Order } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
@@ -7,5 +6,5 @@ export class ImportDataDto {
@IsArray()
@Type(() => CreateOrderDto)
@ValidateNested({ each: true })
- orders: Order[];
+ activities: CreateOrderDto[];
}
diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts
index 9ae66247d..00350f819 100644
--- a/apps/api/src/app/import/import.controller.ts
+++ b/apps/api/src/app/import/import.controller.ts
@@ -36,11 +36,11 @@ export class ImportController {
try {
return await this.importService.import({
- orders: importData.orders,
+ activities: importData.activities,
userId: this.request.user.id
});
} catch (error) {
- Logger.error(error);
+ Logger.error(error, ImportController);
throw new HttpException(
{
diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts
index c7533980d..62d227bf5 100644
--- a/apps/api/src/app/import/import.module.ts
+++ b/apps/api/src/app/import/import.module.ts
@@ -1,5 +1,6 @@
-import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
-import { OrderService } from '@ghostfolio/api/app/order/order.service';
+import { AccountModule } from '@ghostfolio/api/app/account/account.module';
+import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
+import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@@ -11,14 +12,17 @@ import { ImportController } from './import.controller';
import { ImportService } from './import.service';
@Module({
+ controllers: [ImportController],
imports: [
+ AccountModule,
+ CacheModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
+ OrderModule,
PrismaModule,
RedisCacheModule
],
- controllers: [ImportController],
- providers: [CacheService, ImportService, OrderService]
+ providers: [ImportService]
})
export class ImportModule {}
diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts
index 59df401bd..40d677d9b 100644
--- a/apps/api/src/app/import/import.service.ts
+++ b/apps/api/src/app/import/import.service.ts
@@ -1,26 +1,44 @@
+import { AccountService } from '@ghostfolio/api/app/account/account.service';
+import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
+import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common';
-import { Order } from '@prisma/client';
import { isSameDay, parseISO } from 'date-fns';
@Injectable()
export class ImportService {
- private static MAX_ORDERS_TO_IMPORT = 20;
-
public constructor(
+ private readonly accountService: AccountService,
+ private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService
) {}
public async import({
- orders,
+ activities,
userId
}: {
- orders: Partial[];
+ activities: Partial[];
userId: string;
}): Promise {
- await this.validateOrders({ orders, userId });
+ for (const activity of activities) {
+ if (!activity.dataSource) {
+ if (activity.type === 'ITEM') {
+ activity.dataSource = 'MANUAL';
+ } else {
+ activity.dataSource = this.dataProviderService.getPrimaryDataSource();
+ }
+ }
+ }
+
+ await this.validateActivities({ activities, userId });
+
+ const accountIds = (await this.accountService.getAccounts(userId)).map(
+ (account) => {
+ return account.id;
+ }
+ );
for (const {
accountId,
@@ -32,38 +50,54 @@ export class ImportService {
symbol,
type,
unitPrice
- } of orders) {
+ } of activities) {
await this.orderService.createOrder({
- Account: {
- connect: {
- id_userId: { userId, id: accountId }
- }
- },
- currency,
- dataSource,
fee,
quantity,
- symbol,
type,
unitPrice,
+ userId,
+ accountId: accountIds.includes(accountId) ? accountId : undefined,
date: parseISO((date)),
+ SymbolProfile: {
+ connectOrCreate: {
+ create: {
+ currency,
+ dataSource,
+ symbol
+ },
+ where: {
+ dataSource_symbol: {
+ dataSource,
+ symbol
+ }
+ }
+ }
+ },
User: { connect: { id: userId } }
});
}
}
- private async validateOrders({
- orders,
+ private async validateActivities({
+ activities,
userId
}: {
- orders: Partial[];
+ activities: Partial[];
userId: string;
}) {
- if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
- throw new Error('Too many transactions');
+ if (
+ activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
+ ) {
+ throw new Error(
+ `Too many activities (${this.configurationService.get(
+ 'MAX_ORDERS_TO_IMPORT'
+ )} at most)`
+ );
}
- const existingOrders = await this.orderService.orders({
+ const existingActivities = await this.orderService.orders({
+ include: { SymbolProfile: true },
orderBy: { date: 'desc' },
where: { userId }
});
@@ -71,32 +105,40 @@ export class ImportService {
for (const [
index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
- ] of orders.entries()) {
- const duplicateOrder = existingOrders.find((order) => {
+ ] of activities.entries()) {
+ const duplicateActivity = existingActivities.find((activity) => {
return (
- order.currency === currency &&
- order.dataSource === dataSource &&
- isSameDay(order.date, parseISO((date))) &&
- order.fee === fee &&
- order.quantity === quantity &&
- order.symbol === symbol &&
- order.type === type &&
- order.unitPrice === unitPrice
+ activity.SymbolProfile.currency === currency &&
+ activity.SymbolProfile.dataSource === dataSource &&
+ isSameDay(activity.date, parseISO((date))) &&
+ activity.fee === fee &&
+ activity.quantity === quantity &&
+ activity.SymbolProfile.symbol === symbol &&
+ activity.type === type &&
+ activity.unitPrice === unitPrice
);
});
- if (duplicateOrder) {
- throw new Error(`orders.${index} is a duplicate transaction`);
+ if (duplicateActivity) {
+ throw new Error(`activities.${index} is a duplicate activity`);
}
- const result = await this.dataProviderService.get([
- { dataSource, symbol }
- ]);
+ if (dataSource !== 'MANUAL') {
+ const quotes = await this.dataProviderService.getQuotes([
+ { dataSource, symbol }
+ ]);
- if (result[symbol] === undefined) {
- throw new Error(
- `orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
- );
+ if (quotes[symbol] === undefined) {
+ throw new Error(
+ `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
+ );
+ }
+
+ if (quotes[symbol].currency !== currency) {
+ throw new Error(
+ `activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
+ );
+ }
}
}
}
diff --git a/apps/api/src/app/info/info.module.ts b/apps/api/src/app/info/info.module.ts
index bbb47d4d1..338747ebc 100644
--- a/apps/api/src/app/info/info.module.ts
+++ b/apps/api/src/app/info/info.module.ts
@@ -1,11 +1,12 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
-import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
+import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
-import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
-import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
+import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
+import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@@ -13,7 +14,9 @@ import { InfoController } from './info.controller';
import { InfoService } from './info.service';
@Module({
+ controllers: [InfoController],
imports: [
+ ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
@@ -21,15 +24,12 @@ import { InfoService } from './info.service';
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
}),
+ PrismaModule,
+ PropertyModule,
RedisCacheModule,
- SymbolProfileModule
+ SymbolProfileModule,
+ TagModule
],
- controllers: [InfoController],
- providers: [
- ConfigurationService,
- DataGatheringService,
- InfoService,
- PrismaService
- ]
+ providers: [InfoService]
})
export class InfoModule {}
diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts
index 25c5c9906..032b05f27 100644
--- a/apps/api/src/app/info/info.service.ts
+++ b/apps/api/src/app/info/info.service.ts
@@ -1,10 +1,19 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
-import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
-import { PROPERTY_STRIPE_CONFIG } from '@ghostfolio/common/config';
+import { PropertyService } from '@ghostfolio/api/services/property/property.service';
+import { TagService } from '@ghostfolio/api/services/tag/tag.service';
+import {
+ DEMO_USER_ID,
+ PROPERTY_IS_READ_ONLY_MODE,
+ PROPERTY_SLACK_COMMUNITY_USERS,
+ PROPERTY_STRIPE_CONFIG,
+ PROPERTY_SYSTEM_MESSAGE,
+ ghostfolioFearAndGreedIndexDataSource
+} from '@ghostfolio/common/config';
+import { encodeDataSource } from '@ghostfolio/common/helper';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
@@ -16,25 +25,27 @@ import { subDays } from 'date-fns';
@Injectable()
export class InfoService {
- private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
private static CACHE_KEY_STATISTICS = 'STATISTICS';
public constructor(
private readonly configurationService: ConfigurationService,
- private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
- private readonly redisCacheService: RedisCacheService
+ private readonly propertyService: PropertyService,
+ private readonly redisCacheService: RedisCacheService,
+ private readonly tagService: TagService
) {}
public async get(): Promise {
const info: Partial = {};
+ let isReadOnlyMode: boolean;
const platforms = await this.prismaService.platform.findMany({
orderBy: { name: 'asc' },
select: { id: true, name: true }
});
+ let systemMessage: string;
const globalPermissions: string[] = [];
@@ -42,10 +53,28 @@ export class InfoService {
globalPermissions.push(permissions.enableBlog);
}
+ if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
+ if (
+ this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
+ ) {
+ info.fearAndGreedDataSource = encodeDataSource(
+ ghostfolioFearAndGreedIndexDataSource
+ );
+ } else {
+ info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
+ }
+ }
+
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
+ if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
+ isReadOnlyMode = (await this.propertyService.getByKey(
+ PROPERTY_IS_READ_ONLY_MODE
+ )) as boolean;
+ }
+
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin);
}
@@ -60,16 +89,26 @@ export class InfoService {
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
+ if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
+ globalPermissions.push(permissions.enableSystemMessage);
+
+ systemMessage = (await this.propertyService.getByKey(
+ PROPERTY_SYSTEM_MESSAGE
+ )) as string;
+ }
+
return {
...info,
globalPermissions,
+ isReadOnlyMode,
platforms,
+ systemMessage,
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(),
- primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
statistics: await this.getStatistics(),
- subscriptions: await this.getSubscriptions()
+ subscriptions: await this.getSubscriptions(),
+ tags: await this.tagService.get()
};
}
@@ -114,7 +153,7 @@ export class InfoService {
const contributors = await get();
return contributors?.length;
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'InfoService');
return undefined;
}
@@ -135,7 +174,7 @@ export class InfoService {
const { stargazers_count } = await get();
return stargazers_count;
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'InfoService');
return undefined;
}
@@ -163,9 +202,15 @@ export class InfoService {
});
}
+ private async countSlackCommunityUsers() {
+ return (await this.propertyService.getByKey(
+ PROPERTY_SLACK_COMMUNITY_USERS
+ )) as string;
+ }
+
private getDemoAuthToken() {
return this.jwtService.sign({
- id: InfoService.DEMO_USER_ID
+ id: DEMO_USER_ID
});
}
@@ -194,19 +239,19 @@ export class InfoService {
} catch {}
const activeUsers1d = await this.countActiveUsers(1);
- const activeUsers7d = await this.countActiveUsers(7);
const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30);
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
+ const slackCommunityUsers = await this.countSlackCommunityUsers();
statistics = {
activeUsers1d,
- activeUsers7d,
activeUsers30d,
gitHubContributors,
gitHubStargazers,
- newUsers30d
+ newUsers30d,
+ slackCommunityUsers
};
await this.redisCacheService.set(
diff --git a/apps/api/src/app/order/create-order.dto.ts b/apps/api/src/app/order/create-order.dto.ts
index bd52578e2..3211fc4ed 100644
--- a/apps/api/src/app/order/create-order.dto.ts
+++ b/apps/api/src/app/order/create-order.dto.ts
@@ -1,15 +1,31 @@
-import { DataSource, Type } from '@prisma/client';
-import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
+import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
+import {
+ IsEnum,
+ IsISO8601,
+ IsNumber,
+ IsOptional,
+ IsString
+} from 'class-validator';
export class CreateOrderDto {
@IsString()
- accountId: string;
+ @IsOptional()
+ accountId?: string;
+
+ @IsEnum(AssetClass, { each: true })
+ @IsOptional()
+ assetClass?: AssetClass;
+
+ @IsEnum(AssetSubClass, { each: true })
+ @IsOptional()
+ assetSubClass?: AssetSubClass;
@IsString()
currency: string;
@IsEnum(DataSource, { each: true })
- dataSource: DataSource;
+ @IsOptional()
+ dataSource?: DataSource;
@IsISO8601()
date: string;
diff --git a/apps/api/src/app/order/interfaces/activities.interface.ts b/apps/api/src/app/order/interfaces/activities.interface.ts
new file mode 100644
index 000000000..e14adce0b
--- /dev/null
+++ b/apps/api/src/app/order/interfaces/activities.interface.ts
@@ -0,0 +1,10 @@
+import { OrderWithAccount } from '@ghostfolio/common/types';
+
+export interface Activities {
+ activities: Activity[];
+}
+
+export interface Activity extends OrderWithAccount {
+ feeInBaseCurrency: number;
+ valueInBaseCurrency: number;
+}
diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts
index d2c5d02e6..73c546d83 100644
--- a/apps/api/src/app/order/order.controller.ts
+++ b/apps/api/src/app/order/order.controller.ts
@@ -1,11 +1,9 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
+import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
+import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
-import {
- getPermissions,
- hasPermission,
- permissions
-} from '@ghostfolio/common/permissions';
+import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@@ -18,7 +16,8 @@ import {
Param,
Post,
Put,
- UseGuards
+ UseGuards,
+ UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@@ -27,6 +26,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
+import { Activities } from './interfaces/activities.interface';
import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@@ -42,11 +42,12 @@ export class OrderController {
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise {
+ const order = await this.orderService.order({ id });
+
if (
- !hasPermission(
- getPermissions(this.request.user.role),
- permissions.deleteOrder
- )
+ !hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
+ !order ||
+ order.userId !== this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@@ -55,70 +56,52 @@ export class OrderController {
}
return this.orderService.deleteOrder({
- id_userId: {
- id,
- userId: this.request.user.id
- }
+ id
});
}
@Get()
@UseGuards(AuthGuard('jwt'))
+ @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId
- ): Promise {
+ ): Promise {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
+ const userCurrency = this.request.user.Settings.currency;
- let orders = await this.orderService.orders({
- include: {
- Account: {
- include: {
- Platform: true
- }
- },
- SymbolProfile: {
- select: {
- name: true
- }
- }
- },
- orderBy: { date: 'desc' },
- where: { userId: impersonationUserId || this.request.user.id }
+ let activities = await this.orderService.getOrders({
+ userCurrency,
+ includeDrafts: true,
+ userId: impersonationUserId || this.request.user.id
});
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
- orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
+ activities = nullifyValuesInObjects(activities, [
+ 'fee',
+ 'feeInBaseCurrency',
+ 'quantity',
+ 'unitPrice',
+ 'value',
+ 'valueInBaseCurrency'
+ ]);
}
- return orders;
- }
-
- @Get(':id')
- @UseGuards(AuthGuard('jwt'))
- public async getOrderById(@Param('id') id: string): Promise {
- return this.orderService.order({
- id_userId: {
- id,
- userId: this.request.user.id
- }
- });
+ return { activities };
}
@Post()
@UseGuards(AuthGuard('jwt'))
+ @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise {
if (
- !hasPermission(
- getPermissions(this.request.user.role),
- permissions.createOrder
- )
+ !hasPermission(this.request.user.permissions, permissions.createOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@@ -126,60 +109,42 @@ export class OrderController {
);
}
- const date = parseISO(data.date);
-
- const accountId = data.accountId;
- delete data.accountId;
-
return this.orderService.createOrder({
...data,
- Account: {
- connect: {
- id_userId: { id: accountId, userId: this.request.user.id }
- }
- },
- date,
+ date: parseISO(data.date),
SymbolProfile: {
connectOrCreate: {
+ create: {
+ currency: data.currency,
+ dataSource: data.dataSource,
+ symbol: data.symbol
+ },
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
- },
- create: {
- dataSource: data.dataSource,
- symbol: data.symbol
}
}
},
- User: { connect: { id: this.request.user.id } }
+ User: { connect: { id: this.request.user.id } },
+ userId: this.request.user.id
});
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
+ @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
- if (
- !hasPermission(
- getPermissions(this.request.user.role),
- permissions.updateOrder
- )
- ) {
- throw new HttpException(
- getReasonPhrase(StatusCodes.FORBIDDEN),
- StatusCodes.FORBIDDEN
- );
- }
-
const originalOrder = await this.orderService.order({
- id_userId: {
- id,
- userId: this.request.user.id
- }
+ id
});
- if (!originalOrder) {
+ if (
+ !hasPermission(this.request.user.permissions, permissions.updateOrder) ||
+ !originalOrder ||
+ originalOrder.userId !== this.request.user.id
+ ) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@@ -200,13 +165,23 @@ export class OrderController {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
+ SymbolProfile: {
+ connect: {
+ dataSource_symbol: {
+ dataSource: data.dataSource,
+ symbol: data.symbol
+ }
+ },
+ update: {
+ assetClass: data.assetClass,
+ assetSubClass: data.assetSubClass,
+ name: data.symbol
+ }
+ },
User: { connect: { id: this.request.user.id } }
},
where: {
- id_userId: {
- id,
- userId: this.request.user.id
- }
+ id
}
});
}
diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/order/order.module.ts
index 92a503d68..52ffc0266 100644
--- a/apps/api/src/app/order/order.module.ts
+++ b/apps/api/src/app/order/order.module.ts
@@ -1,28 +1,34 @@
-import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
+import { AccountService } from '@ghostfolio/api/app/account/account.service';
+import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
+import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
+import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';
@Module({
+ controllers: [OrderController],
+ exports: [OrderService],
imports: [
+ CacheModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
+ ExchangeRateDataModule,
ImpersonationModule,
PrismaModule,
RedisCacheModule,
+ SymbolProfileModule,
UserModule
],
- controllers: [OrderController],
- providers: [CacheService, OrderService],
- exports: [OrderService]
+ providers: [AccountService, OrderService]
})
export class OrderModule {}
diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts
index 4fabb98f1..7b09ec559 100644
--- a/apps/api/src/app/order/order.service.ts
+++ b/apps/api/src/app/order/order.service.ts
@@ -1,17 +1,44 @@
+import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
+import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
+import {
+ DATA_GATHERING_QUEUE,
+ GATHER_ASSET_PROFILE_PROCESS
+} from '@ghostfolio/common/config';
+import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
+import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
-import { DataSource, Order, Prisma } from '@prisma/client';
+import {
+ AssetClass,
+ AssetSubClass,
+ DataSource,
+ Order,
+ Prisma,
+ Type as TypeOfOrder
+} from '@prisma/client';
+import Big from 'big.js';
+import { Queue } from 'bull';
import { endOfToday, isAfter } from 'date-fns';
+import { groupBy } from 'lodash';
+import { v4 as uuidv4 } from 'uuid';
+
+import { Activity } from './interfaces/activities.interface';
@Injectable()
export class OrderService {
public constructor(
+ private readonly accountService: AccountService,
private readonly cacheService: CacheService,
+ @InjectQueue(DATA_GATHERING_QUEUE)
+ private readonly dataGatheringQueue: Queue,
+ private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
- private readonly prismaService: PrismaService
+ private readonly prismaService: PrismaService,
+ private readonly symbolProfileService: SymbolProfileService
) {}
public async order(
@@ -42,34 +69,92 @@ export class OrderService {
});
}
- public async createOrder(data: Prisma.OrderCreateInput): Promise {
- const isDraft = isAfter(data.date as Date, endOfToday());
+ public async createOrder(
+ data: Prisma.OrderCreateInput & {
+ accountId?: string;
+ assetClass?: AssetClass;
+ assetSubClass?: AssetSubClass;
+ currency?: string;
+ dataSource?: DataSource;
+ symbol?: string;
+ userId: string;
+ }
+ ): Promise {
+ const defaultAccount = (
+ await this.accountService.getAccounts(data.userId)
+ ).find((account) => {
+ return account.isDefault === true;
+ });
- // Convert the symbol to uppercase to avoid case-sensitive duplicates
- const symbol = data.symbol.toUpperCase();
+ let Account = {
+ connect: {
+ id_userId: {
+ userId: data.userId,
+ id: data.accountId ?? defaultAccount?.id
+ }
+ }
+ };
+
+ if (data.type === 'ITEM') {
+ const assetClass = data.assetClass;
+ const assetSubClass = data.assetSubClass;
+ const currency = data.SymbolProfile.connectOrCreate.create.currency;
+ const dataSource: DataSource = 'MANUAL';
+ const id = uuidv4();
+ const name = data.SymbolProfile.connectOrCreate.create.symbol;
+
+ Account = undefined;
+ data.id = id;
+ data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
+ data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
+ data.SymbolProfile.connectOrCreate.create.currency = currency;
+ data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
+ data.SymbolProfile.connectOrCreate.create.name = name;
+ data.SymbolProfile.connectOrCreate.create.symbol = id;
+ data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
+ dataSource,
+ symbol: id
+ };
+ } else {
+ data.SymbolProfile.connectOrCreate.create.symbol =
+ data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
+ }
+
+ await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
+ dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
+ symbol: data.SymbolProfile.connectOrCreate.create.symbol
+ });
+
+ const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
- symbol,
- dataSource: data.dataSource,
- date: data.date
+ dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
+ date: data.date,
+ symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
}
- this.dataGatheringService.gatherProfileData([
- { symbol, dataSource: data.dataSource }
- ]);
-
await this.cacheService.flush();
+ delete data.accountId;
+ delete data.assetClass;
+ delete data.assetSubClass;
+ delete data.currency;
+ delete data.dataSource;
+ delete data.symbol;
+ delete data.userId;
+
+ const orderData: Prisma.OrderCreateInput = data;
+
return this.prismaService.order.create({
data: {
- ...data,
- isDraft,
- symbol
+ ...orderData,
+ Account,
+ isDraft
}
});
}
@@ -77,57 +162,182 @@ export class OrderService {
public async deleteOrder(
where: Prisma.OrderWhereUniqueInput
): Promise {
- return this.prismaService.order.delete({
+ const order = await this.prismaService.order.delete({
where
});
+
+ if (order.type === 'ITEM') {
+ await this.symbolProfileService.deleteById(order.symbolProfileId);
+ }
+
+ return order;
}
- public getOrders({
+ public async getOrders({
+ filters,
includeDrafts = false,
+ types,
+ userCurrency,
userId
}: {
+ filters?: Filter[];
includeDrafts?: boolean;
+ types?: TypeOfOrder[];
+ userCurrency: string;
userId: string;
- }) {
+ }): Promise {
const where: Prisma.OrderWhereInput = { userId };
+ const {
+ ACCOUNT: filtersByAccount,
+ ASSET_CLASS: filtersByAssetClass,
+ TAG: filtersByTag
+ } = groupBy(filters, (filter) => {
+ return filter.type;
+ });
+
+ if (filtersByAccount?.length > 0) {
+ where.accountId = {
+ in: filtersByAccount.map(({ id }) => {
+ return id;
+ })
+ };
+ }
+
if (includeDrafts === false) {
where.isDraft = false;
}
- return this.orders({
- where,
- include: {
- // eslint-disable-next-line @typescript-eslint/naming-convention
- Account: true,
- // eslint-disable-next-line @typescript-eslint/naming-convention
- SymbolProfile: true
- },
- orderBy: { date: 'asc' }
+ if (filtersByAssetClass?.length > 0) {
+ where.SymbolProfile = {
+ OR: [
+ {
+ AND: [
+ {
+ OR: filtersByAssetClass.map(({ id }) => {
+ return { assetClass: AssetClass[id] };
+ })
+ },
+ {
+ SymbolProfileOverrides: {
+ is: null
+ }
+ }
+ ]
+ },
+ {
+ SymbolProfileOverrides: {
+ OR: filtersByAssetClass.map(({ id }) => {
+ return { assetClass: AssetClass[id] };
+ })
+ }
+ }
+ ]
+ };
+ }
+
+ if (filtersByTag?.length > 0) {
+ where.tags = {
+ some: {
+ OR: filtersByTag.map(({ id }) => {
+ return { id };
+ })
+ }
+ };
+ }
+
+ if (types) {
+ where.OR = types.map((type) => {
+ return {
+ type: {
+ equals: type
+ }
+ };
+ });
+ }
+
+ return (
+ await this.orders({
+ where,
+ include: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ Account: {
+ include: {
+ Platform: true
+ }
+ },
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ SymbolProfile: true,
+ tags: true
+ },
+ orderBy: { date: 'asc' }
+ })
+ ).map((order) => {
+ const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
+
+ return {
+ ...order,
+ value,
+ feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
+ order.fee,
+ order.SymbolProfile.currency,
+ userCurrency
+ ),
+ valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
+ value,
+ order.SymbolProfile.currency,
+ userCurrency
+ )
+ };
});
}
- public async updateOrder(params: {
+ public async updateOrder({
+ data,
+ where
+ }: {
+ data: Prisma.OrderUpdateInput & {
+ assetClass?: AssetClass;
+ assetSubClass?: AssetSubClass;
+ currency?: string;
+ dataSource?: DataSource;
+ symbol?: string;
+ };
where: Prisma.OrderWhereUniqueInput;
- data: Prisma.OrderUpdateInput;
}): Promise {
- const { data, where } = params;
+ if (data.Account.connect.id_userId.id === null) {
+ delete data.Account;
+ }
- const isDraft = isAfter(data.date as Date, endOfToday());
+ let isDraft = false;
- if (!isDraft) {
- // Gather symbol data of order in the background, if not draft
- this.dataGatheringService.gatherSymbols([
- {
- dataSource: data.dataSource,
- date: data.date,
- symbol: data.symbol
- }
- ]);
+ if (data.type === 'ITEM') {
+ delete data.SymbolProfile.connect;
+ } else {
+ delete data.SymbolProfile.update;
+
+ isDraft = isAfter(data.date as Date, endOfToday());
+
+ if (!isDraft) {
+ // Gather symbol data of order in the background, if not draft
+ this.dataGatheringService.gatherSymbols([
+ {
+ dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
+ date: data.date,
+ symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
+ }
+ ]);
+ }
}
await this.cacheService.flush();
+ delete data.assetClass;
+ delete data.assetSubClass;
+ delete data.currency;
+ delete data.dataSource;
+ delete data.symbol;
+
return this.prismaService.order.update({
data: {
...data,
diff --git a/apps/api/src/app/order/update-order.dto.ts b/apps/api/src/app/order/update-order.dto.ts
index 58a046c5b..0ad46180d 100644
--- a/apps/api/src/app/order/update-order.dto.ts
+++ b/apps/api/src/app/order/update-order.dto.ts
@@ -1,9 +1,24 @@
-import { DataSource, Type } from '@prisma/client';
-import { IsISO8601, IsNumber, IsString } from 'class-validator';
+import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
+import {
+ IsEnum,
+ IsISO8601,
+ IsNumber,
+ IsOptional,
+ IsString
+} from 'class-validator';
export class UpdateOrderDto {
+ @IsOptional()
@IsString()
- accountId: string;
+ accountId?: string;
+
+ @IsEnum(AssetClass, { each: true })
+ @IsOptional()
+ assetClass?: AssetClass;
+
+ @IsEnum(AssetSubClass, { each: true })
+ @IsOptional()
+ assetSubClass?: AssetSubClass;
@IsString()
currency: string;
diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts
new file mode 100644
index 000000000..c91ed9d9a
--- /dev/null
+++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts
@@ -0,0 +1,67 @@
+import { parseDate, resetHours } from '@ghostfolio/common/helper';
+import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
+
+import { GetValuesParams } from './interfaces/get-values-params.interface';
+
+function mockGetValue(symbol: string, date: Date) {
+ switch (symbol) {
+ case 'BALN.SW':
+ if (isSameDay(parseDate('2021-11-12'), date)) {
+ return { marketPrice: 146 };
+ } else if (isSameDay(parseDate('2021-11-22'), date)) {
+ return { marketPrice: 142.9 };
+ } else if (isSameDay(parseDate('2021-11-26'), date)) {
+ return { marketPrice: 139.9 };
+ } else if (isSameDay(parseDate('2021-11-30'), date)) {
+ return { marketPrice: 136.6 };
+ } else if (isSameDay(parseDate('2021-12-18'), date)) {
+ return { marketPrice: 148.9 };
+ }
+
+ return { marketPrice: 0 };
+
+ case 'NOVN.SW':
+ if (isSameDay(parseDate('2022-04-11'), date)) {
+ return { marketPrice: 87.8 };
+ }
+
+ return { marketPrice: 0 };
+
+ default:
+ return { marketPrice: 0 };
+ }
+}
+
+export const CurrentRateServiceMock = {
+ getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
+ const result = [];
+ if (dateQuery.lt) {
+ for (
+ let date = resetHours(dateQuery.gte);
+ isBefore(date, endOfDay(dateQuery.lt));
+ date = addDays(date, 1)
+ ) {
+ for (const dataGatheringItem of dataGatheringItems) {
+ result.push({
+ date,
+ marketPrice: mockGetValue(dataGatheringItem.symbol, date)
+ .marketPrice,
+ symbol: dataGatheringItem.symbol
+ });
+ }
+ }
+ } else {
+ for (const date of dateQuery.in) {
+ for (const dataGatheringItem of dataGatheringItems) {
+ result.push({
+ date,
+ marketPrice: mockGetValue(dataGatheringItem.symbol, date)
+ .marketPrice,
+ symbol: dataGatheringItem.symbol
+ });
+ }
+ }
+ }
+ return Promise.resolve(result);
+ }
+};
diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts
index bb9aa78b6..ca45483e1 100644
--- a/apps/api/src/app/portfolio/current-rate.service.spec.ts
+++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts
@@ -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 () => {
expect(
await currentRateService.getValues({
diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts
index 3c7a6bde5..0549596ce 100644
--- a/apps/api/src/app/portfolio/current-rate.service.ts
+++ b/apps/api/src/app/portfolio/current-rate.service.ts
@@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface';
-import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable()
@@ -18,46 +17,6 @@ export class CurrentRateService {
private readonly marketDataService: MarketDataService
) {}
- public async getValue({
- currency,
- date,
- symbol,
- userCurrency
- }: GetValueParams): Promise {
- 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({
currencies,
dataGatheringItems,
@@ -81,7 +40,7 @@ export class CurrentRateService {
const today = resetHours(new Date());
promises.push(
this.dataProviderService
- .get(dataGatheringItems)
+ .getQuotes(dataGatheringItems)
.then((dataResultProvider) => {
const result = [];
for (const dataGatheringItem of dataGatheringItems) {
diff --git a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts b/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts
index a02d2a6a6..48e6038f3 100644
--- a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts
+++ b/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts
@@ -1,12 +1,11 @@
-import { TimelinePosition } from '@ghostfolio/common/interfaces';
+import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
-export interface CurrentPositions {
- hasErrors: boolean;
+export interface CurrentPositions extends ResponseError {
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;
- netAnnualizedPerformance: Big;
+ netAnnualizedPerformance?: Big;
netPerformance: Big;
netPerformancePercentage: Big;
currentValue: Big;
diff --git a/apps/api/src/app/portfolio/interfaces/get-value-params.interface.ts b/apps/api/src/app/portfolio/interfaces/get-value-params.interface.ts
deleted file mode 100644
index a9b934271..000000000
--- a/apps/api/src/app/portfolio/interfaces/get-value-params.interface.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export interface GetValueParams {
- currency: string;
- date: Date;
- symbol: string;
- userCurrency: string;
-}
diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts
new file mode 100644
index 000000000..88026cdc7
--- /dev/null
+++ b/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts
@@ -0,0 +1,5 @@
+import { PortfolioOrder } from './portfolio-order.interface';
+
+export interface PortfolioOrderItem extends PortfolioOrder {
+ itemType?: '' | 'start' | 'end';
+}
diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
index 5fd3baf8d..2466e81af 100644
--- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
+++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
@@ -1,5 +1,4 @@
-import { OrderType } from '@ghostfolio/api/models/order-type';
-import { DataSource } from '@prisma/client';
+import { DataSource, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
export interface PortfolioOrder {
@@ -10,6 +9,6 @@ export interface PortfolioOrder {
name: string;
quantity: Big;
symbol: string;
- type: OrderType;
+ type: TypeOfOrder;
unitPrice: Big;
}
diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts
index 069230983..f400923e8 100644
--- a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts
+++ b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts
@@ -1,10 +1,10 @@
-import { AssetClass, AssetSubClass } from '@prisma/client';
+import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
+import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
+import { OrderWithAccount } from '@ghostfolio/common/types';
+import { Tag } from '@prisma/client';
export interface PortfolioPositionDetail {
- assetClass?: AssetClass;
- assetSubClass?: AssetSubClass;
averagePrice: number;
- currency: string;
firstBuyDate: string;
grossPerformance: number;
grossPerformancePercent: number;
@@ -13,11 +13,12 @@ export interface PortfolioPositionDetail {
marketPrice: number;
maxPrice: number;
minPrice: number;
- name: string;
netPerformance: number;
netPerformancePercent: number;
+ orders: OrderWithAccount[];
quantity: number;
- symbol: string;
+ SymbolProfile: EnhancedSymbolProfile;
+ tags: Tag[];
transactionCount: number;
value: number;
}
@@ -27,10 +28,3 @@ export interface HistoricalDataContainer {
isAllTimeLow: boolean;
items: HistoricalDataItem[];
}
-
-export interface HistoricalDataItem {
- averagePrice?: number;
- date: string;
- grossPerformancePercent?: number;
- value: number;
-}
diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts
new file mode 100644
index 000000000..ea35cdd79
--- /dev/null
+++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts
@@ -0,0 +1,96 @@
+import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
+import { parseDate } from '@ghostfolio/common/helper';
+import Big from 'big.js';
+
+import { CurrentRateServiceMock } from './current-rate.service.mock';
+import { PortfolioCalculator } from './portfolio-calculator';
+
+jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
+ return {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ CurrentRateService: jest.fn().mockImplementation(() => {
+ return CurrentRateServiceMock;
+ })
+ };
+});
+
+describe('PortfolioCalculator', () => {
+ let currentRateService: CurrentRateService;
+
+ beforeEach(() => {
+ currentRateService = new CurrentRateService(null, null, null);
+ });
+
+ describe('get current positions', () => {
+ it.only('with BALN.SW buy and sell', async () => {
+ const portfolioCalculator = new PortfolioCalculator({
+ currentRateService,
+ currency: 'CHF',
+ orders: [
+ {
+ currency: 'CHF',
+ date: '2021-11-22',
+ dataSource: 'YAHOO',
+ fee: new Big(1.55),
+ name: 'Bรขloise Holding AG',
+ quantity: new Big(2),
+ symbol: 'BALN.SW',
+ type: 'BUY',
+ unitPrice: new Big(142.9)
+ },
+ {
+ currency: 'CHF',
+ date: '2021-11-30',
+ dataSource: 'YAHOO',
+ fee: new Big(1.65),
+ name: 'Bรขloise Holding AG',
+ quantity: new Big(2),
+ symbol: 'BALN.SW',
+ type: 'SELL',
+ unitPrice: new Big(136.6)
+ }
+ ]
+ });
+
+ portfolioCalculator.computeTransactionPoints();
+
+ const spy = jest
+ .spyOn(Date, 'now')
+ .mockImplementation(() => parseDate('2021-12-18').getTime());
+
+ const currentPositions = await portfolioCalculator.getCurrentPositions(
+ parseDate('2021-11-22')
+ );
+
+ spy.mockRestore();
+
+ expect(currentPositions).toEqual({
+ currentValue: new Big('0'),
+ errors: [],
+ grossPerformance: new Big('-12.6'),
+ grossPerformancePercentage: new Big('-0.0440867739678096571'),
+ hasErrors: false,
+ netPerformance: new Big('-15.8'),
+ netPerformancePercentage: new Big('-0.0552834149755073478'),
+ positions: [
+ {
+ averagePrice: new Big('0'),
+ currency: 'CHF',
+ dataSource: 'YAHOO',
+ firstBuyDate: '2021-11-22',
+ grossPerformance: new Big('-12.6'),
+ grossPerformancePercentage: new Big('-0.0440867739678096571'),
+ investment: new Big('0'),
+ netPerformance: new Big('-15.8'),
+ netPerformancePercentage: new Big('-0.0552834149755073478'),
+ marketPrice: 148.9,
+ quantity: new Big('0'),
+ symbol: 'BALN.SW',
+ transactionCount: 2
+ }
+ ],
+ totalInvestment: new Big('0')
+ });
+ });
+ });
+});
diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts
new file mode 100644
index 000000000..a6fe1af40
--- /dev/null
+++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts
@@ -0,0 +1,85 @@
+import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
+import { parseDate } from '@ghostfolio/common/helper';
+import Big from 'big.js';
+
+import { CurrentRateServiceMock } from './current-rate.service.mock';
+import { PortfolioCalculator } from './portfolio-calculator';
+
+jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
+ return {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ CurrentRateService: jest.fn().mockImplementation(() => {
+ return CurrentRateServiceMock;
+ })
+ };
+});
+
+describe('PortfolioCalculator', () => {
+ let currentRateService: CurrentRateService;
+
+ beforeEach(() => {
+ currentRateService = new CurrentRateService(null, null, null);
+ });
+
+ describe('get current positions', () => {
+ it.only('with BALN.SW buy', async () => {
+ const portfolioCalculator = new PortfolioCalculator({
+ currentRateService,
+ currency: 'CHF',
+ orders: [
+ {
+ currency: 'CHF',
+ date: '2021-11-30',
+ dataSource: 'YAHOO',
+ fee: new Big(1.55),
+ name: 'Bรขloise Holding AG',
+ quantity: new Big(2),
+ symbol: 'BALN.SW',
+ type: 'BUY',
+ unitPrice: new Big(136.6)
+ }
+ ]
+ });
+
+ portfolioCalculator.computeTransactionPoints();
+
+ const spy = jest
+ .spyOn(Date, 'now')
+ .mockImplementation(() => parseDate('2021-12-18').getTime());
+
+ const currentPositions = await portfolioCalculator.getCurrentPositions(
+ parseDate('2021-11-30')
+ );
+
+ spy.mockRestore();
+
+ expect(currentPositions).toEqual({
+ currentValue: new Big('297.8'),
+ errors: [],
+ grossPerformance: new Big('24.6'),
+ grossPerformancePercentage: new Big('0.09004392386530014641'),
+ hasErrors: false,
+ netPerformance: new Big('23.05'),
+ netPerformancePercentage: new Big('0.08437042459736456808'),
+ positions: [
+ {
+ averagePrice: new Big('136.6'),
+ currency: 'CHF',
+ dataSource: 'YAHOO',
+ firstBuyDate: '2021-11-30',
+ grossPerformance: new Big('24.6'),
+ grossPerformancePercentage: new Big('0.09004392386530014641'),
+ investment: new Big('273.2'),
+ netPerformance: new Big('23.05'),
+ netPerformancePercentage: new Big('0.08437042459736456808'),
+ marketPrice: 148.9,
+ quantity: new Big('2'),
+ symbol: 'BALN.SW',
+ transactionCount: 1
+ }
+ ],
+ totalInvestment: new Big('273.2')
+ });
+ });
+ });
+});
diff --git a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts
new file mode 100644
index 000000000..18d6cb34d
--- /dev/null
+++ b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts
@@ -0,0 +1,56 @@
+import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
+import { parseDate } from '@ghostfolio/common/helper';
+import Big from 'big.js';
+
+import { CurrentRateServiceMock } from './current-rate.service.mock';
+import { PortfolioCalculator } from './portfolio-calculator';
+
+jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
+ return {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ CurrentRateService: jest.fn().mockImplementation(() => {
+ return CurrentRateServiceMock;
+ })
+ };
+});
+
+describe('PortfolioCalculator', () => {
+ let currentRateService: CurrentRateService;
+
+ beforeEach(() => {
+ currentRateService = new CurrentRateService(null, null, null);
+ });
+
+ describe('get current positions', () => {
+ it('with no orders', async () => {
+ const portfolioCalculator = new PortfolioCalculator({
+ currentRateService,
+ currency: 'CHF',
+ orders: []
+ });
+
+ portfolioCalculator.computeTransactionPoints();
+
+ const spy = jest
+ .spyOn(Date, 'now')
+ .mockImplementation(() => parseDate('2021-12-18').getTime());
+
+ const currentPositions = await portfolioCalculator.getCurrentPositions(
+ new Date()
+ );
+
+ spy.mockRestore();
+
+ expect(currentPositions).toEqual({
+ currentValue: new Big(0),
+ grossPerformance: new Big(0),
+ grossPerformancePercentage: new Big(0),
+ hasErrors: false,
+ netPerformance: new Big(0),
+ netPerformancePercentage: new Big(0),
+ positions: [],
+ totalInvestment: new Big(0)
+ });
+ });
+ });
+});
diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
new file mode 100644
index 000000000..d215f9e1e
--- /dev/null
+++ b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
@@ -0,0 +1,96 @@
+import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
+import { parseDate } from '@ghostfolio/common/helper';
+import Big from 'big.js';
+
+import { CurrentRateServiceMock } from './current-rate.service.mock';
+import { PortfolioCalculator } from './portfolio-calculator';
+
+jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
+ return {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ CurrentRateService: jest.fn().mockImplementation(() => {
+ return CurrentRateServiceMock;
+ })
+ };
+});
+
+describe('PortfolioCalculator', () => {
+ let currentRateService: CurrentRateService;
+
+ beforeEach(() => {
+ currentRateService = new CurrentRateService(null, null, null);
+ });
+
+ describe('get current positions', () => {
+ it.only('with BALN.SW buy and sell', async () => {
+ const portfolioCalculator = new PortfolioCalculator({
+ currentRateService,
+ currency: 'CHF',
+ orders: [
+ {
+ currency: 'CHF',
+ date: '2022-03-07',
+ dataSource: 'YAHOO',
+ fee: new Big(1.3),
+ name: 'Novartis AG',
+ quantity: new Big(2),
+ symbol: 'NOVN.SW',
+ type: 'BUY',
+ unitPrice: new Big(75.8)
+ },
+ {
+ currency: 'CHF',
+ date: '2022-04-08',
+ dataSource: 'YAHOO',
+ fee: new Big(2.95),
+ name: 'Novartis AG',
+ quantity: new Big(1),
+ symbol: 'NOVN.SW',
+ type: 'SELL',
+ unitPrice: new Big(85.73)
+ }
+ ]
+ });
+
+ portfolioCalculator.computeTransactionPoints();
+
+ const spy = jest
+ .spyOn(Date, 'now')
+ .mockImplementation(() => parseDate('2022-04-11').getTime());
+
+ const currentPositions = await portfolioCalculator.getCurrentPositions(
+ parseDate('2022-03-07')
+ );
+
+ spy.mockRestore();
+
+ expect(currentPositions).toEqual({
+ currentValue: new Big('87.8'),
+ errors: [],
+ grossPerformance: new Big('21.93'),
+ grossPerformancePercentage: new Big('0.14465699208443271768'),
+ hasErrors: false,
+ netPerformance: new Big('17.68'),
+ netPerformancePercentage: new Big('0.11662269129287598945'),
+ positions: [
+ {
+ averagePrice: new Big('75.80'),
+ currency: 'CHF',
+ dataSource: 'YAHOO',
+ firstBuyDate: '2022-03-07',
+ grossPerformance: new Big('21.93'),
+ grossPerformancePercentage: new Big('0.14465699208443271768'),
+ investment: new Big('75.80'),
+ netPerformance: new Big('17.68'),
+ netPerformancePercentage: new Big('0.11662269129287598945'),
+ marketPrice: 87.8,
+ quantity: new Big('1'),
+ symbol: 'NOVN.SW',
+ transactionCount: 2
+ }
+ ],
+ totalInvestment: new Big('75.80')
+ });
+ });
+ });
+});
diff --git a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts
index 1e3d6b576..23f0a8a8d 100644
--- a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts
+++ b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts
@@ -1,127 +1,8 @@
-import { OrderType } from '@ghostfolio/api/models/order-type';
-import { parseDate, resetHours } from '@ghostfolio/common/helper';
-import { DataSource } from '@prisma/client';
import Big from 'big.js';
-import {
- addDays,
- differenceInCalendarDays,
- endOfDay,
- isBefore,
- isSameDay
-} from 'date-fns';
import { CurrentRateService } from './current-rate.service';
-import { GetValueParams } from './interfaces/get-value-params.interface';
-import { GetValuesParams } from './interfaces/get-values-params.interface';
-import { PortfolioOrder } from './interfaces/portfolio-order.interface';
-import { TimelinePeriod } from './interfaces/timeline-period.interface';
-import { TimelineSpecification } from './interfaces/timeline-specification.interface';
-import { TransactionPoint } from './interfaces/transaction-point.interface';
import { PortfolioCalculator } from './portfolio-calculator';
-function mockGetValue(symbol: string, date: Date) {
- switch (symbol) {
- case 'AMZN':
- return { marketPrice: 2021.99 };
- case 'MFA':
- if (isSameDay(parseDate('2010-12-31'), date)) {
- return { marketPrice: 1 };
- } else if (isSameDay(parseDate('2011-08-15'), date)) {
- return { marketPrice: 1.162484 }; // 1162484 / 1000000
- } else if (isSameDay(parseDate('2011-12-31'), date)) {
- return { marketPrice: 1.097884981 }; // 1192328 / 1086022.689344541
- }
-
- return { marketPrice: 0 };
- case 'SPA':
- if (isSameDay(parseDate('2013-12-31'), date)) {
- return { marketPrice: 1.025 }; // 205 / 200
- }
-
- return { marketPrice: 0 };
- case 'SPB':
- if (isSameDay(parseDate('2013-12-31'), date)) {
- return { marketPrice: 1.04 }; // 312 / 300
- }
-
- return { marketPrice: 0 };
- case 'TSLA':
- if (isSameDay(parseDate('2021-07-26'), date)) {
- return { marketPrice: 657.62 };
- } else if (isSameDay(parseDate('2021-01-02'), date)) {
- return { marketPrice: 666.66 };
- }
-
- return { marketPrice: 0 };
- case 'VTI':
- return {
- marketPrice: new Big('144.38')
- .plus(
- new Big('0.08').mul(
- differenceInCalendarDays(date, parseDate('2019-02-01'))
- )
- )
- .toNumber()
- };
- default:
- return { marketPrice: 0 };
- }
-}
-
-jest.mock('./current-rate.service', () => {
- return {
- // eslint-disable-next-line @typescript-eslint/naming-convention
- CurrentRateService: jest.fn().mockImplementation(() => {
- return {
- getValue: ({
- currency,
- date,
- symbol,
- userCurrency
- }: GetValueParams) => {
- return Promise.resolve(mockGetValue(symbol, date));
- },
- getValues: ({
- currencies,
- dateQuery,
- dataGatheringItems,
- userCurrency
- }: GetValuesParams) => {
- const result = [];
- if (dateQuery.lt) {
- for (
- let date = resetHours(dateQuery.gte);
- isBefore(date, endOfDay(dateQuery.lt));
- date = addDays(date, 1)
- ) {
- for (const dataGatheringItem of dataGatheringItems) {
- result.push({
- date,
- marketPrice: mockGetValue(dataGatheringItem.symbol, date)
- .marketPrice,
- symbol: dataGatheringItem.symbol
- });
- }
- }
- } else {
- for (const date of dateQuery.in) {
- for (const dataGatheringItem of dataGatheringItems) {
- result.push({
- date,
- marketPrice: mockGetValue(dataGatheringItem.symbol, date)
- .marketPrice,
- symbol: dataGatheringItem.symbol
- });
- }
- }
- }
- return Promise.resolve(result);
- }
- };
- })
- };
-});
-
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
@@ -129,2206 +10,12 @@ describe('PortfolioCalculator', () => {
currentRateService = new CurrentRateService(null, null, null);
});
- describe('calculate transaction points', () => {
- it('with orders of only one symbol', () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.computeTransactionPoints(ordersVTI);
- const portfolioItemsAtTransactionPoints =
- portfolioCalculator.getTransactionPoints();
-
- expect(portfolioItemsAtTransactionPoints).toEqual(
- ordersVTITransactionPoints
- );
- });
-
- it('with orders of only one symbol and a fee', () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- const orders: PortfolioOrder[] = [
- {
- date: '2019-02-01',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('10'),
- symbol: 'VTI',
- type: OrderType.Buy,
- unitPrice: new Big('144.38'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big('5')
- },
- {
- date: '2019-08-03',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('10'),
- symbol: 'VTI',
- type: OrderType.Buy,
- unitPrice: new Big('147.99'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big('10')
- },
- {
- date: '2020-02-02',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('15'),
- symbol: 'VTI',
- type: OrderType.Sell,
- unitPrice: new Big('151.41'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big('5')
- }
- ];
- portfolioCalculator.computeTransactionPoints(orders);
- const portfolioItemsAtTransactionPoints =
- portfolioCalculator.getTransactionPoints();
-
- expect(portfolioItemsAtTransactionPoints).toEqual([
- {
- date: '2019-02-01',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- transactionCount: 1,
- fee: new Big('5')
- }
- ]
- },
- {
- date: '2019-08-03',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- transactionCount: 2,
- fee: new Big('15')
- }
- ]
- },
- {
- date: '2020-02-02',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('5'),
- symbol: 'VTI',
- investment: new Big('652.55'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- transactionCount: 3,
- fee: new Big('20')
- }
- ]
- }
- ]);
- });
-
- it('with orders of two different symbols and a fee', () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- const orders: PortfolioOrder[] = [
- {
- date: '2019-02-01',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('10'),
- symbol: 'VTI',
- type: OrderType.Buy,
- unitPrice: new Big('144.38'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big('5')
- },
- {
- date: '2019-08-03',
- name: 'Something else',
- quantity: new Big('10'),
- symbol: 'VTX',
- type: OrderType.Buy,
- unitPrice: new Big('147.99'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big('10')
- },
- {
- date: '2020-02-02',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('5'),
- symbol: 'VTI',
- type: OrderType.Sell,
- unitPrice: new Big('151.41'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big('5')
- }
- ];
- portfolioCalculator.computeTransactionPoints(orders);
- const portfolioItemsAtTransactionPoints =
- portfolioCalculator.getTransactionPoints();
-
- expect(portfolioItemsAtTransactionPoints).toEqual([
- {
- date: '2019-02-01',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- transactionCount: 1,
- fee: new Big('5')
- }
- ]
- },
- {
- date: '2019-08-03',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- transactionCount: 1,
- fee: new Big('5')
- },
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('10'),
- symbol: 'VTX',
- investment: new Big('1479.9'),
- currency: 'USD',
- firstBuyDate: '2019-08-03',
- transactionCount: 1,
- fee: new Big('10')
- }
- ]
- },
- {
- date: '2020-02-02',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('5'),
- symbol: 'VTI',
- investment: new Big('686.75'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- transactionCount: 2,
- fee: new Big('10')
- },
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('10'),
- symbol: 'VTX',
- investment: new Big('1479.9'),
- currency: 'USD',
- firstBuyDate: '2019-08-03',
- transactionCount: 1,
- fee: new Big('10')
- }
- ]
- }
- ]);
- });
-
- it('with two orders at the same day of the same type', () => {
- const orders: PortfolioOrder[] = [
- ...ordersVTI,
- {
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- date: '2021-02-01',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('20'),
- symbol: 'VTI',
- type: OrderType.Buy,
- unitPrice: new Big('197.15'),
- fee: new Big(0)
- }
- ];
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.computeTransactionPoints(orders);
- const portfolioItemsAtTransactionPoints =
- portfolioCalculator.getTransactionPoints();
-
- expect(portfolioItemsAtTransactionPoints).toEqual([
- {
- date: '2019-02-01',
- items: [
- {
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- investment: new Big('1443.8'),
- quantity: new Big('10'),
- symbol: 'VTI',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2019-08-03',
- items: [
- {
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- investment: new Big('2923.7'),
- quantity: new Big('20'),
- symbol: 'VTI',
- fee: new Big(0),
- transactionCount: 2
- }
- ]
- },
- {
- date: '2020-02-02',
- items: [
- {
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- investment: new Big('652.55'),
- quantity: new Big('5'),
- symbol: 'VTI',
- fee: new Big(0),
- transactionCount: 3
- }
- ]
- },
- {
- date: '2021-02-01',
- items: [
- {
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- investment: new Big('6627.05'),
- quantity: new Big('35'),
- symbol: 'VTI',
- fee: new Big(0),
- transactionCount: 5
- }
- ]
- },
- {
- date: '2021-08-01',
- items: [
- {
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- investment: new Big('8403.95'),
- quantity: new Big('45'),
- symbol: 'VTI',
- fee: new Big(0),
- transactionCount: 6
- }
- ]
- }
- ]);
- });
-
- it('with additional order', () => {
- const orders: PortfolioOrder[] = [
- ...ordersVTI,
- {
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- date: '2019-09-01',
- name: 'Amazon.com, Inc.',
- quantity: new Big('5'),
- symbol: 'AMZN',
- type: OrderType.Buy,
- unitPrice: new Big('2021.99'),
- fee: new Big(0)
- }
- ];
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.computeTransactionPoints(orders);
- const portfolioItemsAtTransactionPoints =
- portfolioCalculator.getTransactionPoints();
-
- expect(portfolioItemsAtTransactionPoints).toEqual([
- {
- date: '2019-02-01',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2019-08-03',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 2
- }
- ]
- },
- {
- date: '2019-09-01',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('5'),
- symbol: 'AMZN',
- investment: new Big('10109.95'),
- currency: 'USD',
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 2
- }
- ]
- },
- {
- date: '2020-02-02',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('5'),
- symbol: 'AMZN',
- investment: new Big('10109.95'),
- currency: 'USD',
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('5'),
- symbol: 'VTI',
- investment: new Big('652.55'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 3
- }
- ]
- },
- {
- date: '2021-02-01',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('5'),
- symbol: 'AMZN',
- investment: new Big('10109.95'),
- currency: 'USD',
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('15'),
- symbol: 'VTI',
- investment: new Big('2684.05'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 4
- }
- ]
- },
- {
- date: '2021-08-01',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('5'),
- symbol: 'AMZN',
- investment: new Big('10109.95'),
- currency: 'USD',
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('25'),
- symbol: 'VTI',
- investment: new Big('4460.95'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 5
- }
- ]
- }
- ]);
- });
-
- it('with additional buy & sell', () => {
- const orders: PortfolioOrder[] = [
- ...ordersVTI,
- {
- date: '2019-09-01',
- name: 'Amazon.com, Inc.',
- quantity: new Big('5'),
- symbol: 'AMZN',
- type: OrderType.Buy,
- unitPrice: new Big('2021.99'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big(0)
- },
- {
- date: '2020-08-02',
- name: 'Amazon.com, Inc.',
- quantity: new Big('5'),
- symbol: 'AMZN',
- type: OrderType.Sell,
- unitPrice: new Big('2412.23'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big(0)
- }
- ];
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.computeTransactionPoints(orders);
- const portfolioItemsAtTransactionPoints =
- portfolioCalculator.getTransactionPoints();
-
- expect(portfolioItemsAtTransactionPoints).toEqual(
- transactionPointsBuyAndSell
- );
- });
-
- it('with mixed symbols', () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.computeTransactionPoints(ordersMixedSymbols);
- const portfolioItemsAtTransactionPoints =
- portfolioCalculator.getTransactionPoints();
-
- expect(portfolioItemsAtTransactionPoints).toEqual([
- {
- date: '2017-01-03',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('50'),
- symbol: 'TSLA',
- investment: new Big('2148.5'),
- currency: 'USD',
- firstBuyDate: '2017-01-03',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2017-07-01',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('0.5614682'),
- symbol: 'BTCUSD',
- investment: new Big('1999.9999999999998659756'),
- currency: 'USD',
- firstBuyDate: '2017-07-01',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('50'),
- symbol: 'TSLA',
- investment: new Big('2148.5'),
- currency: 'USD',
- firstBuyDate: '2017-01-03',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2018-09-01',
- items: [
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('5'),
- symbol: 'AMZN',
- investment: new Big('10109.95'),
- currency: 'USD',
- firstBuyDate: '2018-09-01',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('0.5614682'),
- symbol: 'BTCUSD',
- investment: new Big('1999.9999999999998659756'),
- currency: 'USD',
- firstBuyDate: '2017-07-01',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- dataSource: DataSource.YAHOO,
- quantity: new Big('50'),
- symbol: 'TSLA',
- investment: new Big('2148.5'),
- currency: 'USD',
- firstBuyDate: '2017-01-03',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- }
- ]);
- });
- });
-
- describe('get current positions', () => {
- it('with single TSLA and early start', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints(orderTslaTransactionPoint);
-
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2021, 6, 26)).getTime()); // 2021-07-26
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2020-01-21')
- );
- spy.mockRestore();
-
- expect(currentPositions).toEqual(
- expect.objectContaining({
- hasErrors: false,
- currentValue: new Big('657.62'),
- grossPerformance: new Big('-61.84'),
- grossPerformancePercentage: new Big('-0.08595335390431712673'),
- totalInvestment: new Big('719.46'),
- positions: [
- expect.objectContaining({
- averagePrice: new Big('719.46'),
- currency: 'USD',
- firstBuyDate: '2021-01-01',
- grossPerformance: new Big('-61.84'), // 657.62-719.46=-61.84
- grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673
- investment: new Big('719.46'),
- marketPrice: 657.62,
- quantity: new Big('1'),
- symbol: 'TSLA',
- transactionCount: 1
- })
- ]
- })
- );
- });
-
- it('with single TSLA and buy day start', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints(orderTslaTransactionPoint);
-
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2021, 6, 26)).getTime()); // 2021-07-26
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2021-01-01')
- );
- spy.mockRestore();
-
- expect(currentPositions).toEqual(
- expect.objectContaining({
- hasErrors: false,
- currentValue: new Big('657.62'),
- grossPerformance: new Big('-61.84'),
- grossPerformancePercentage: new Big('-0.08595335390431712673'),
- totalInvestment: new Big('719.46'),
- positions: [
- expect.objectContaining({
- averagePrice: new Big('719.46'),
- currency: 'USD',
- firstBuyDate: '2021-01-01',
- grossPerformance: new Big('-61.84'), // 657.62-719.46=-61.84
- grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673
- investment: new Big('719.46'),
- marketPrice: 657.62,
- quantity: new Big('1'),
- symbol: 'TSLA',
- transactionCount: 1
- })
- ]
- })
- );
- });
-
- it('with single TSLA and late start', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints(orderTslaTransactionPoint);
-
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2021, 6, 26)).getTime()); // 2021-07-26
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2021-01-02')
- );
- spy.mockRestore();
-
- expect(currentPositions).toEqual(
- expect.objectContaining({
- hasErrors: false,
- currentValue: new Big('657.62'),
- grossPerformance: new Big('-9.04'),
- grossPerformancePercentage: new Big('-0.01356013560135601356'),
- totalInvestment: new Big('719.46'),
- positions: [
- expect.objectContaining({
- averagePrice: new Big('719.46'),
- currency: 'USD',
- firstBuyDate: '2021-01-01',
- grossPerformance: new Big('-9.04'), // 657.62-666.66=-9.04
- grossPerformancePercentage: new Big('-0.01356013560135601356'), // 657.62/666.66-1=-0.013560136
- investment: new Big('719.46'),
- marketPrice: 657.62,
- quantity: new Big('1'),
- symbol: 'TSLA',
- transactionCount: 1
- })
- ]
- })
- );
- });
-
- it('with VTI only', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints);
-
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2019-01-01')
- );
- spy.mockRestore();
-
- expect(currentPositions).toEqual(
- expect.objectContaining({
- hasErrors: false,
- currentValue: new Big('4871.5'),
- grossPerformance: new Big('240.4'),
- grossPerformancePercentage: new Big('0.08839407904876477102'),
- totalInvestment: new Big('4460.95'),
- positions: [
- expect.objectContaining({
- averagePrice: new Big('178.438'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- // see next test for details about how to calculate this
- grossPerformance: new Big('240.4'),
- grossPerformancePercentage: new Big(
- '0.0883940790487647710162214425767848424215253864940558186258745429269647266073266478435285352186572448'
- ),
- investment: new Big('4460.95'),
- marketPrice: 194.86,
- quantity: new Big('25'),
- symbol: 'VTI',
- transactionCount: 5
- })
- ]
- })
- );
- });
-
- it('with buy and sell', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints(transactionPointsBuyAndSell);
-
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2019-01-01')
- );
- spy.mockRestore();
-
- expect(currentPositions).toEqual(
- expect.objectContaining({
- hasErrors: false,
- currentValue: new Big('4871.5'),
- grossPerformance: new Big('240.4'),
- grossPerformancePercentage: new Big('0.01104605615757711361'),
- totalInvestment: new Big('4460.95'),
- positions: [
- expect.objectContaining({
- averagePrice: new Big('0'),
- currency: 'USD',
- firstBuyDate: '2019-09-01',
- grossPerformance: new Big('0'),
- grossPerformancePercentage: new Big('0'),
- investment: new Big('0'),
- marketPrice: 2021.99,
- quantity: new Big('0'),
- symbol: 'AMZN',
- transactionCount: 2
- }),
- expect.objectContaining({
- averagePrice: new Big('178.438'),
- currency: 'USD',
- firstBuyDate: '2019-02-01',
- grossPerformance: new Big('240.4'),
- grossPerformancePercentage: new Big(
- '0.08839407904876477101219019935616297754969945667391763908415656216989674494965785538864363782688167989866968512455219637257546280462751601552'
- ),
- investment: new Big('4460.95'),
- marketPrice: 194.86,
- quantity: new Big('25'),
- symbol: 'VTI',
- transactionCount: 5
- })
- ]
- })
- );
- });
-
- it('with buy, sell, buy', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints([
- {
- date: '2019-09-01',
- items: [
- {
- quantity: new Big('5'),
- symbol: 'VTI',
- investment: new Big('805.9'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2020-08-02',
- items: [
- {
- quantity: new Big('0'),
- symbol: 'VTI',
- investment: new Big('0'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 2
- }
- ]
- },
- {
- date: '2021-02-01',
- items: [
- {
- quantity: new Big('5'),
- symbol: 'VTI',
- investment: new Big('1013.9'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 3
- }
- ]
- }
- ]);
-
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2021, 7, 1)).getTime()); // 2021-08-01
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2019-02-01')
- );
- spy.mockRestore();
-
- expect(currentPositions).toEqual(
- expect.objectContaining({
- hasErrors: false,
- currentValue: new Big('1086.7'),
- grossPerformance: new Big('207.6'),
- grossPerformancePercentage: new Big('0.2516103956224511062'),
- totalInvestment: new Big('1013.9'),
- positions: [
- expect.objectContaining({
- averagePrice: new Big('202.78'),
- currency: 'USD',
- firstBuyDate: '2019-09-01',
- grossPerformance: new Big('207.6'),
- grossPerformancePercentage: new Big(
- '0.2516103956224511061954915466429950404846'
- ),
- investment: new Big('1013.9'),
- marketPrice: 217.34,
- quantity: new Big('5'),
- symbol: 'VTI',
- transactionCount: 3
- })
- ]
- })
- );
- });
-
- it('with performance since Jan 1st, 2020', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- const transactionPoints: TransactionPoint[] = [
- {
- date: '2019-02-01',
- items: [
- {
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2020-08-03',
- items: [
- {
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 2
- }
- ]
- }
- ];
-
- portfolioCalculator.setTransactionPoints(transactionPoints);
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24
-
- // 2020-01-01 -> days 334 => value: VTI: 144.38+334*0.08=171.1 => 10*171.10=1711
- // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 10*188.30=1883 => 1883/1711 = 1.100526008
- // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 20*188.30=3766
- // cash flow: 2923.7-1443.8=1479.9
- // 2020-10-24 [today] -> days 631 => value: VTI: 144.38+631*0.08=194.86 => 20*194.86=3897.2 => 3897.2/(1883+1479.9) = 1.158880728
- // gross performance: 1883-1711 + 3897.2-3766 = 303.2
- // gross performance percentage: 1.100526008 * 1.158880728 = 1.275378381 => 27.5378381 %
-
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2020-01-01')
- );
-
- spy.mockRestore();
- expect(currentPositions).toEqual(
- expect.objectContaining({
- hasErrors: false,
- currentValue: new Big('3897.2'),
- grossPerformance: new Big('303.2'),
- grossPerformancePercentage: new Big('0.27537838148272398344'),
- totalInvestment: new Big('2923.7'),
- positions: [
- expect.objectContaining({
- averagePrice: new Big('146.185'),
- firstBuyDate: '2019-02-01',
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- marketPrice: 194.86,
- transactionCount: 2,
- grossPerformance: new Big('303.2'),
- grossPerformancePercentage: new Big(
- '0.2753783814827239834392742298083677500037'
- ),
- currency: 'USD'
- })
- ]
- })
- );
- });
-
- it('with net performance since Jan 1st, 2020 - include fees', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- const transactionPoints: TransactionPoint[] = [
- {
- date: '2019-02-01',
- items: [
- {
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(50),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2020-08-03',
- items: [
- {
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(100),
- transactionCount: 2
- }
- ]
- }
- ];
-
- portfolioCalculator.setTransactionPoints(transactionPoints);
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24
-
- // 2020-01-01 -> days 334 => value: VTI: 144.38+334*0.08=171.1 => 10*171.10=1711
- // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 10*188.30=1883 => 1883/1711 = 1.100526008
- // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 20*188.30=3766
- // cash flow: 2923.7-1443.8=1479.9
- // 2020-10-24 [today] -> days 631 => value: VTI: 144.38+631*0.08=194.86 => 20*194.86=3897.2 => 3897.2/(1883+1479.9) = 1.158880728
- // and net: 3897.2/(1883+1479.9+50) = 1.14190278
- // gross performance: 1883-1711 + 3897.2-3766 = 303.2
- // gross performance percentage: 1.100526008 * 1.158880728 = 1.275378381 => 27.5378381 %
- // net performance percentage: 1.100526008 * 1.14190278 = 1.25669371 => 25.669371 %
-
- // more details: https://github.com/ghostfolio/ghostfolio/issues/324#issuecomment-910530823
-
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2020-01-01')
- );
-
- spy.mockRestore();
- expect(currentPositions).toEqual({
- hasErrors: false,
- currentValue: new Big('3897.2'),
- grossPerformance: new Big('303.2'),
- grossPerformancePercentage: new Big('0.27537838148272398344'),
- netAnnualizedPerformance: new Big('0.1412977563032074'),
- netPerformance: new Big('253.2'),
- netPerformancePercentage: new Big('0.2566937088951485493'),
- totalInvestment: new Big('2923.7'),
- positions: [
- {
- averagePrice: new Big('146.185'),
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- marketPrice: 194.86,
- transactionCount: 2,
- grossPerformance: new Big('303.2'),
- grossPerformancePercentage: new Big(
- '0.2753783814827239834392742298083677500037'
- ),
- netPerformance: new Big('253.2'), // gross - 50 fees
- netPerformancePercentage: new Big(
- '0.2566937088951485493029975263687800261527'
- ), // see details above
- currency: 'USD'
- }
- ]
- });
- });
-
- it('with net performance since Feb 1st, 2019 - include fees', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- const transactionPoints: TransactionPoint[] = [
- {
- date: '2019-02-01',
- items: [
- {
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(50),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2020-08-03',
- items: [
- {
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(100),
- transactionCount: 2
- }
- ]
- }
- ];
-
- portfolioCalculator.setTransactionPoints(transactionPoints);
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24
-
- // 2019-02-01 -> value: VTI: 1443.8
- // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 10*188.30=1883 => net: 1883/(1443.8+50) = 1.26054358
- // 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 20*188.30=3766
- // cash flow: 2923.7-1443.8=1479.9
- // 2020-10-24 [today] -> days 631 => value: VTI: 144.38+631*0.08=194.86 => 20*194.86=3897.2 => net: 3897.2/(1883+1479.9+50) = 1.14190278
- // gross performance: 1883-1443.8 + 3897.2-3766 = 570.4 => net performance: 470.4
- // net performance percentage: 1.26054358 * 1.14190278 = 1.43941822 => 43.941822 %
-
- // more details: https://github.com/ghostfolio/ghostfolio/issues/324#issuecomment-910530823
-
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2019-02-01')
- );
-
- spy.mockRestore();
- expect(currentPositions).toEqual(
- expect.objectContaining({
- hasErrors: false,
- currentValue: new Big('3897.2'),
- netPerformance: new Big('470.4'),
- netPerformancePercentage: new Big('0.4394182192526437059'),
- totalInvestment: new Big('2923.7'),
- positions: [
- expect.objectContaining({
- averagePrice: new Big('146.185'),
- firstBuyDate: '2019-02-01',
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- marketPrice: 194.86,
- transactionCount: 2,
- netPerformance: new Big('470.4'),
- netPerformancePercentage: new Big(
- '0.4394182192526437058970248283134805555953'
- ), // see details above
- currency: 'USD'
- })
- ]
- })
- );
- });
-
- /**
- * Source: https://www.investopedia.com/terms/t/time-weightedror.asp
- */
- it('with TWR example from Investopedia: Scenario 1', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints([
- {
- date: '2010-12-31',
- items: [
- {
- quantity: new Big('1000000'), // 1 million
- symbol: 'MFA', // Mutual Fund A
- investment: new Big('1000000'), // 1 million
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2010-12-31',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2011-08-15',
- items: [
- {
- quantity: new Big('1086022.689344541'), // 1,000,000 + 100,000 / 1.162484
- symbol: 'MFA', // Mutual Fund A
- investment: new Big('1100000'), // 1,000,000 + 100,000
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2010-12-31',
- fee: new Big(0),
- transactionCount: 2
- }
- ]
- }
- ]);
-
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2011, 11, 31)).getTime()); // 2011-12-31
-
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2010-12-31')
- );
- spy.mockRestore();
-
- expect(currentPositions).toEqual(
- expect.objectContaining({
- hasErrors: false,
- currentValue: new Big('1192327.999656600298238721'),
- grossPerformance: new Big('92327.999656600898394721'),
- grossPerformancePercentage: new Big('0.09788498099999947809'),
- totalInvestment: new Big('1100000'),
- positions: [
- expect.objectContaining({
- averagePrice: new Big('1.01287018290924923237'), // 1'100'000 / 1'086'022.689344542
- firstBuyDate: '2010-12-31',
- quantity: new Big('1086022.689344541'),
- symbol: 'MFA',
- investment: new Big('1100000'),
- marketPrice: 1.097884981,
- transactionCount: 2,
- grossPerformance: new Big('92327.999656600898394721'), // 1'192'328 - 1'100'000 = 92'328
- grossPerformancePercentage: new Big(
- '0.09788498099999947808927632'
- ), // 9.79 %
- currency: 'USD'
- })
- ]
- })
- );
- });
-
- /**
- * Source: https://www.chsoft.ch/en/assets/Dateien/files/PDF/ePoca/en/Practical%20Performance%20Calculation.pdf
- */
- it('with example from chsoft.ch: Performance of a Combination of Investments', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'CHF'
- );
- portfolioCalculator.setTransactionPoints([
- {
- date: '2012-12-31',
- items: [
- {
- quantity: new Big('200'),
- symbol: 'SPA', // Sub Portfolio A
- investment: new Big('200'),
- currency: 'CHF',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2012-12-31',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- quantity: new Big('300'),
- symbol: 'SPB', // Sub Portfolio B
- investment: new Big('300'),
- currency: 'CHF',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2012-12-31',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2013-12-31',
- items: [
- {
- quantity: new Big('200'),
- symbol: 'SPA', // Sub Portfolio A
- investment: new Big('200'),
- currency: 'CHF',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2012-12-31',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- quantity: new Big('300'),
- symbol: 'SPB', // Sub Portfolio B
- investment: new Big('300'),
- currency: 'CHF',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2012-12-31',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- }
- ]);
-
- const spy = jest
- .spyOn(Date, 'now')
- .mockImplementation(() => new Date(Date.UTC(2013, 11, 31)).getTime()); // 2013-12-31
-
- const currentPositions = await portfolioCalculator.getCurrentPositions(
- parseDate('2012-12-31')
- );
- spy.mockRestore();
-
- expect(currentPositions).toEqual(
- expect.objectContaining({
- currentValue: new Big('517'),
- grossPerformance: new Big('17'), // 517 - 500
- grossPerformancePercentage: new Big('0.034'), // ((200 * 0.025) + (300 * 0.04)) / (200 + 300) = 3.4%
- totalInvestment: new Big('500'),
- hasErrors: false,
- positions: [
- expect.objectContaining({
- averagePrice: new Big('1'),
- firstBuyDate: '2012-12-31',
- quantity: new Big('200'),
- symbol: 'SPA',
- investment: new Big('200'),
- marketPrice: 1.025, // 205 / 200
- transactionCount: 1,
- grossPerformance: new Big('5'), // 205 - 200
- grossPerformancePercentage: new Big('0.025'),
- currency: 'CHF'
- }),
- expect.objectContaining({
- averagePrice: new Big('1'),
- firstBuyDate: '2012-12-31',
- quantity: new Big('300'),
- symbol: 'SPB',
- investment: new Big('300'),
- marketPrice: 1.04, // 312 / 300
- transactionCount: 1,
- grossPerformance: new Big('12'), // 312 - 300
- grossPerformancePercentage: new Big('0.04'),
- currency: 'CHF'
- })
- ]
- })
- );
- });
- });
-
- describe('calculate timeline', () => {
- it('with yearly', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints);
- const timelineSpecification: TimelineSpecification[] = [
- {
- start: '2019-01-01',
- accuracy: 'year'
- }
- ];
- const timelineInfo = await portfolioCalculator.calculateTimeline(
- timelineSpecification,
- '2021-06-30'
- );
- const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
-
- expect(timeline).toEqual([
- {
- date: '2019-01-01',
- grossPerformance: new Big('0'),
- netPerformance: new Big('0'),
- investment: new Big('0'),
- value: new Big('0')
- },
- {
- date: '2020-01-01',
- grossPerformance: new Big('498.3'),
- netPerformance: new Big('498.3'),
- investment: new Big('2923.7'),
- value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
- },
- {
- date: '2021-01-01',
- grossPerformance: new Big('349.35'),
- netPerformance: new Big('349.35'),
- investment: new Big('652.55'),
- value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
- }
- ]);
- });
-
- it('with yearly and fees', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- const transactionPoints: TransactionPoint[] = [
- {
- date: '2019-02-01',
- items: [
- {
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(50),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2019-08-03',
- items: [
- {
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(100),
- transactionCount: 2
- }
- ]
- },
- {
- date: '2020-02-02',
- items: [
- {
- quantity: new Big('5'),
- symbol: 'VTI',
- investment: new Big('652.55'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(150),
- transactionCount: 3
- }
- ]
- },
- {
- date: '2021-02-01',
- items: [
- {
- quantity: new Big('15'),
- symbol: 'VTI',
- investment: new Big('2684.05'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(200),
- transactionCount: 4
- }
- ]
- },
- {
- date: '2021-08-01',
- items: [
- {
- quantity: new Big('25'),
- symbol: 'VTI',
- investment: new Big('4460.95'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(250),
- transactionCount: 5
- }
- ]
- }
- ];
- portfolioCalculator.setTransactionPoints(transactionPoints);
- const timelineSpecification: TimelineSpecification[] = [
- {
- start: '2019-01-01',
- accuracy: 'year'
- }
- ];
- const timelineInfo = await portfolioCalculator.calculateTimeline(
- timelineSpecification,
- '2021-06-30'
- );
- const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
-
- expect(timeline).toEqual([
- {
- date: '2019-01-01',
- grossPerformance: new Big('0'),
- netPerformance: new Big('0'),
- investment: new Big('0'),
- value: new Big('0')
- },
- {
- date: '2020-01-01',
- grossPerformance: new Big('498.3'),
- netPerformance: new Big('398.3'), // 100 fees
- investment: new Big('2923.7'),
- value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
- },
- {
- date: '2021-01-01',
- grossPerformance: new Big('349.35'),
- netPerformance: new Big('199.35'), // 150 fees
- investment: new Big('652.55'),
- value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
- }
- ]);
- });
-
- it('with monthly', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints);
- const timelineSpecification: TimelineSpecification[] = [
- {
- start: '2019-01-01',
- accuracy: 'month'
- }
- ];
- const timelineInfo = await portfolioCalculator.calculateTimeline(
- timelineSpecification,
- '2021-06-30'
- );
- const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
-
- expect(timeline).toEqual([
- {
- date: '2019-01-01',
- grossPerformance: new Big('0'),
- netPerformance: new Big('0'),
- investment: new Big('0'),
- value: new Big('0')
- },
- {
- date: '2019-02-01',
- grossPerformance: new Big('0'),
- netPerformance: new Big('0'),
- investment: new Big('1443.8'),
- value: new Big('1443.8') // 10 * (144.38 + days=0 * 0.08)
- },
- {
- date: '2019-03-01',
- grossPerformance: new Big('22.4'),
- netPerformance: new Big('22.4'),
- investment: new Big('1443.8'),
- value: new Big('1466.2') // 10 * (144.38 + days=28 * 0.08)
- },
- {
- date: '2019-04-01',
- grossPerformance: new Big('47.2'),
- netPerformance: new Big('47.2'),
- investment: new Big('1443.8'),
- value: new Big('1491') // 10 * (144.38 + days=59 * 0.08)
- },
- {
- date: '2019-05-01',
- grossPerformance: new Big('71.2'),
- netPerformance: new Big('71.2'),
- investment: new Big('1443.8'),
- value: new Big('1515') // 10 * (144.38 + days=89 * 0.08)
- },
- {
- date: '2019-06-01',
- grossPerformance: new Big('96'),
- netPerformance: new Big('96'),
- investment: new Big('1443.8'),
- value: new Big('1539.8') // 10 * (144.38 + days=120 * 0.08)
- },
- {
- date: '2019-07-01',
- grossPerformance: new Big('120'),
- netPerformance: new Big('120'),
- investment: new Big('1443.8'),
- value: new Big('1563.8') // 10 * (144.38 + days=150 * 0.08)
- },
- {
- date: '2019-08-01',
- grossPerformance: new Big('144.8'),
- netPerformance: new Big('144.8'),
- investment: new Big('1443.8'),
- value: new Big('1588.6') // 10 * (144.38 + days=181 * 0.08)
- },
- {
- date: '2019-09-01',
- grossPerformance: new Big('303.1'),
- netPerformance: new Big('303.1'),
- investment: new Big('2923.7'),
- value: new Big('3226.8') // 20 * (144.38 + days=212 * 0.08)
- },
- {
- date: '2019-10-01',
- grossPerformance: new Big('351.1'),
- netPerformance: new Big('351.1'),
- investment: new Big('2923.7'),
- value: new Big('3274.8') // 20 * (144.38 + days=242 * 0.08)
- },
- {
- date: '2019-11-01',
- grossPerformance: new Big('400.7'),
- netPerformance: new Big('400.7'),
- investment: new Big('2923.7'),
- value: new Big('3324.4') // 20 * (144.38 + days=273 * 0.08)
- },
- {
- date: '2019-12-01',
- grossPerformance: new Big('448.7'),
- netPerformance: new Big('448.7'),
- investment: new Big('2923.7'),
- value: new Big('3372.4') // 20 * (144.38 + days=303 * 0.08)
- },
- {
- date: '2020-01-01',
- grossPerformance: new Big('498.3'),
- netPerformance: new Big('498.3'),
- investment: new Big('2923.7'),
- value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
- },
- {
- date: '2020-02-01',
- grossPerformance: new Big('547.9'),
- netPerformance: new Big('547.9'),
- investment: new Big('2923.7'),
- value: new Big('3471.6') // 20 * (144.38 + days=365 * 0.08)
- },
- {
- date: '2020-03-01',
- grossPerformance: new Big('226.95'),
- netPerformance: new Big('226.95'),
- investment: new Big('652.55'),
- value: new Big('879.5') // 5 * (144.38 + days=394 * 0.08)
- },
- {
- date: '2020-04-01',
- grossPerformance: new Big('239.35'),
- netPerformance: new Big('239.35'),
- investment: new Big('652.55'),
- value: new Big('891.9') // 5 * (144.38 + days=425 * 0.08)
- },
- {
- date: '2020-05-01',
- grossPerformance: new Big('251.35'),
- netPerformance: new Big('251.35'),
- investment: new Big('652.55'),
- value: new Big('903.9') // 5 * (144.38 + days=455 * 0.08)
- },
- {
- date: '2020-06-01',
- grossPerformance: new Big('263.75'),
- netPerformance: new Big('263.75'),
- investment: new Big('652.55'),
- value: new Big('916.3') // 5 * (144.38 + days=486 * 0.08)
- },
- {
- date: '2020-07-01',
- grossPerformance: new Big('275.75'),
- netPerformance: new Big('275.75'),
- investment: new Big('652.55'),
- value: new Big('928.3') // 5 * (144.38 + days=516 * 0.08)
- },
- {
- date: '2020-08-01',
- grossPerformance: new Big('288.15'),
- netPerformance: new Big('288.15'),
- investment: new Big('652.55'),
- value: new Big('940.7') // 5 * (144.38 + days=547 * 0.08)
- },
- {
- date: '2020-09-01',
- grossPerformance: new Big('300.55'),
- netPerformance: new Big('300.55'),
- investment: new Big('652.55'),
- value: new Big('953.1') // 5 * (144.38 + days=578 * 0.08)
- },
- {
- date: '2020-10-01',
- grossPerformance: new Big('312.55'),
- netPerformance: new Big('312.55'),
- investment: new Big('652.55'),
- value: new Big('965.1') // 5 * (144.38 + days=608 * 0.08)
- },
- {
- date: '2020-11-01',
- grossPerformance: new Big('324.95'),
- netPerformance: new Big('324.95'),
- investment: new Big('652.55'),
- value: new Big('977.5') // 5 * (144.38 + days=639 * 0.08)
- },
- {
- date: '2020-12-01',
- grossPerformance: new Big('336.95'),
- netPerformance: new Big('336.95'),
- investment: new Big('652.55'),
- value: new Big('989.5') // 5 * (144.38 + days=669 * 0.08)
- },
- {
- date: '2021-01-01',
- grossPerformance: new Big('349.35'),
- netPerformance: new Big('349.35'),
- investment: new Big('652.55'),
- value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
- },
- {
- date: '2021-02-01',
- grossPerformance: new Big('358.85'),
- netPerformance: new Big('358.85'),
- investment: new Big('2684.05'),
- value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
- },
- {
- date: '2021-03-01',
- grossPerformance: new Big('392.45'),
- netPerformance: new Big('392.45'),
- investment: new Big('2684.05'),
- value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
- },
- {
- date: '2021-04-01',
- grossPerformance: new Big('429.65'),
- netPerformance: new Big('429.65'),
- investment: new Big('2684.05'),
- value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
- },
- {
- date: '2021-05-01',
- grossPerformance: new Big('465.65'),
- netPerformance: new Big('465.65'),
- investment: new Big('2684.05'),
- value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
- },
- {
- date: '2021-06-01',
- grossPerformance: new Big('502.85'),
- netPerformance: new Big('502.85'),
- investment: new Big('2684.05'),
- value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
- }
- ]);
-
- expect(timelineInfo.maxNetPerformance).toEqual(new Big('547.9'));
- expect(timelineInfo.minNetPerformance).toEqual(new Big('0'));
- });
-
- it('with yearly and monthly mixed', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints);
- const timelineSpecification: TimelineSpecification[] = [
- {
- start: '2019-01-01',
- accuracy: 'year'
- },
- {
- start: '2021-01-01',
- accuracy: 'month'
- }
- ];
- const timelineInfo = await portfolioCalculator.calculateTimeline(
- timelineSpecification,
- '2021-06-30'
- );
- const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
-
- expect(timeline).toEqual([
- {
- date: '2019-01-01',
- grossPerformance: new Big('0'),
- netPerformance: new Big('0'),
- investment: new Big('0'),
- value: new Big('0')
- },
- {
- date: '2020-01-01',
- grossPerformance: new Big('498.3'),
- netPerformance: new Big('498.3'),
- investment: new Big('2923.7'),
- value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
- },
- {
- date: '2021-01-01',
- grossPerformance: new Big('349.35'),
- netPerformance: new Big('349.35'),
- investment: new Big('652.55'),
- value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
- },
- {
- date: '2021-02-01',
- grossPerformance: new Big('358.85'),
- netPerformance: new Big('358.85'),
- investment: new Big('2684.05'),
- value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
- },
- {
- date: '2021-03-01',
- grossPerformance: new Big('392.45'),
- netPerformance: new Big('392.45'),
- investment: new Big('2684.05'),
- value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
- },
- {
- date: '2021-04-01',
- grossPerformance: new Big('429.65'),
- netPerformance: new Big('429.65'),
- investment: new Big('2684.05'),
- value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
- },
- {
- date: '2021-05-01',
- grossPerformance: new Big('465.65'),
- netPerformance: new Big('465.65'),
- investment: new Big('2684.05'),
- value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
- },
- {
- date: '2021-06-01',
- grossPerformance: new Big('502.85'),
- netPerformance: new Big('502.85'),
- investment: new Big('2684.05'),
- value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
- }
- ]);
- });
-
- it('with all mixed', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints);
- const timelineSpecification: TimelineSpecification[] = [
- {
- start: '2019-01-01',
- accuracy: 'year'
- },
- {
- start: '2021-01-01',
- accuracy: 'month'
- },
- {
- start: '2021-06-01',
- accuracy: 'day'
- }
- ];
- const timelineInfo = await portfolioCalculator.calculateTimeline(
- timelineSpecification,
- '2021-06-30'
- );
- const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
-
- expect(timeline).toEqual(
- expect.objectContaining([
- {
- date: '2019-01-01',
- grossPerformance: new Big('0'),
- netPerformance: new Big('0'),
- investment: new Big('0'),
- value: new Big('0')
- },
- {
- date: '2020-01-01',
- grossPerformance: new Big('498.3'),
- netPerformance: new Big('498.3'),
- investment: new Big('2923.7'),
- value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
- },
- {
- date: '2021-01-01',
- grossPerformance: new Big('349.35'),
- netPerformance: new Big('349.35'),
- investment: new Big('652.55'),
- value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
- },
- {
- date: '2021-02-01',
- grossPerformance: new Big('358.85'),
- netPerformance: new Big('358.85'),
- investment: new Big('2684.05'),
- value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
- },
- {
- date: '2021-03-01',
- grossPerformance: new Big('392.45'),
- netPerformance: new Big('392.45'),
- investment: new Big('2684.05'),
- value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
- },
- {
- date: '2021-04-01',
- grossPerformance: new Big('429.65'),
- netPerformance: new Big('429.65'),
- investment: new Big('2684.05'),
- value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
- },
- {
- date: '2021-05-01',
- grossPerformance: new Big('465.65'),
- netPerformance: new Big('465.65'),
- investment: new Big('2684.05'),
- value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
- },
- {
- date: '2021-06-01',
- grossPerformance: new Big('502.85'),
- netPerformance: new Big('502.85'),
- investment: new Big('2684.05'),
- value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
- },
- {
- date: '2021-06-02',
- grossPerformance: new Big('504.05'),
- netPerformance: new Big('504.05'),
- investment: new Big('2684.05'),
- value: new Big('3188.1') // 15 * (144.38 + days=852 * 0.08) / +1.2
- },
- {
- date: '2021-06-03',
- grossPerformance: new Big('505.25'),
- netPerformance: new Big('505.25'),
- investment: new Big('2684.05'),
- value: new Big('3189.3') // +1.2
- },
- {
- date: '2021-06-04',
- grossPerformance: new Big('506.45'),
- netPerformance: new Big('506.45'),
- investment: new Big('2684.05'),
- value: new Big('3190.5') // +1.2
- },
- {
- date: '2021-06-05',
- grossPerformance: new Big('507.65'),
- netPerformance: new Big('507.65'),
- investment: new Big('2684.05'),
- value: new Big('3191.7') // +1.2
- },
- {
- date: '2021-06-06',
- grossPerformance: new Big('508.85'),
- netPerformance: new Big('508.85'),
- investment: new Big('2684.05'),
- value: new Big('3192.9') // +1.2
- },
- {
- date: '2021-06-07',
- grossPerformance: new Big('510.05'),
- netPerformance: new Big('510.05'),
- investment: new Big('2684.05'),
- value: new Big('3194.1') // +1.2
- },
- {
- date: '2021-06-08',
- grossPerformance: new Big('511.25'),
- netPerformance: new Big('511.25'),
- investment: new Big('2684.05'),
- value: new Big('3195.3') // +1.2
- },
- {
- date: '2021-06-09',
- grossPerformance: new Big('512.45'),
- netPerformance: new Big('512.45'),
- investment: new Big('2684.05'),
- value: new Big('3196.5') // +1.2
- },
- {
- date: '2021-06-10',
- grossPerformance: new Big('513.65'),
- netPerformance: new Big('513.65'),
- investment: new Big('2684.05'),
- value: new Big('3197.7') // +1.2
- },
- {
- date: '2021-06-11',
- grossPerformance: new Big('514.85'),
- netPerformance: new Big('514.85'),
- investment: new Big('2684.05'),
- value: new Big('3198.9') // +1.2
- },
- {
- date: '2021-06-12',
- grossPerformance: new Big('516.05'),
- netPerformance: new Big('516.05'),
- investment: new Big('2684.05'),
- value: new Big('3200.1') // +1.2
- },
- {
- date: '2021-06-13',
- grossPerformance: new Big('517.25'),
- netPerformance: new Big('517.25'),
- investment: new Big('2684.05'),
- value: new Big('3201.3') // +1.2
- },
- {
- date: '2021-06-14',
- grossPerformance: new Big('518.45'),
- netPerformance: new Big('518.45'),
- investment: new Big('2684.05'),
- value: new Big('3202.5') // +1.2
- },
- {
- date: '2021-06-15',
- grossPerformance: new Big('519.65'),
- netPerformance: new Big('519.65'),
- investment: new Big('2684.05'),
- value: new Big('3203.7') // +1.2
- },
- {
- date: '2021-06-16',
- grossPerformance: new Big('520.85'),
- netPerformance: new Big('520.85'),
- investment: new Big('2684.05'),
- value: new Big('3204.9') // +1.2
- },
- {
- date: '2021-06-17',
- grossPerformance: new Big('522.05'),
- netPerformance: new Big('522.05'),
- investment: new Big('2684.05'),
- value: new Big('3206.1') // +1.2
- },
- {
- date: '2021-06-18',
- grossPerformance: new Big('523.25'),
- netPerformance: new Big('523.25'),
- investment: new Big('2684.05'),
- value: new Big('3207.3') // +1.2
- },
- {
- date: '2021-06-19',
- grossPerformance: new Big('524.45'),
- netPerformance: new Big('524.45'),
- investment: new Big('2684.05'),
- value: new Big('3208.5') // +1.2
- },
- {
- date: '2021-06-20',
- grossPerformance: new Big('525.65'),
- netPerformance: new Big('525.65'),
- investment: new Big('2684.05'),
- value: new Big('3209.7') // +1.2
- },
- {
- date: '2021-06-21',
- grossPerformance: new Big('526.85'),
- netPerformance: new Big('526.85'),
- investment: new Big('2684.05'),
- value: new Big('3210.9') // +1.2
- },
- {
- date: '2021-06-22',
- grossPerformance: new Big('528.05'),
- netPerformance: new Big('528.05'),
- investment: new Big('2684.05'),
- value: new Big('3212.1') // +1.2
- },
- {
- date: '2021-06-23',
- grossPerformance: new Big('529.25'),
- netPerformance: new Big('529.25'),
- investment: new Big('2684.05'),
- value: new Big('3213.3') // +1.2
- },
- {
- date: '2021-06-24',
- grossPerformance: new Big('530.45'),
- netPerformance: new Big('530.45'),
- investment: new Big('2684.05'),
- value: new Big('3214.5') // +1.2
- },
- {
- date: '2021-06-25',
- grossPerformance: new Big('531.65'),
- netPerformance: new Big('531.65'),
- investment: new Big('2684.05'),
- value: new Big('3215.7') // +1.2
- },
- {
- date: '2021-06-26',
- grossPerformance: new Big('532.85'),
- netPerformance: new Big('532.85'),
- investment: new Big('2684.05'),
- value: new Big('3216.9') // +1.2
- },
- {
- date: '2021-06-27',
- grossPerformance: new Big('534.05'),
- netPerformance: new Big('534.05'),
- investment: new Big('2684.05'),
- value: new Big('3218.1') // +1.2
- },
- {
- date: '2021-06-28',
- grossPerformance: new Big('535.25'),
- netPerformance: new Big('535.25'),
- investment: new Big('2684.05'),
- value: new Big('3219.3') // +1.2
- },
- {
- date: '2021-06-29',
- grossPerformance: new Big('536.45'),
- netPerformance: new Big('536.45'),
- investment: new Big('2684.05'),
- value: new Big('3220.5') // +1.2
- },
- {
- date: '2021-06-30',
- grossPerformance: new Big('537.65'),
- netPerformance: new Big('537.65'),
- investment: new Big('2684.05'),
- value: new Big('3221.7') // +1.2
- }
- ])
- );
- });
-
- it('with mixed portfolio', async () => {
- const portfolioCalculator = new PortfolioCalculator(
- currentRateService,
- 'USD'
- );
- portfolioCalculator.setTransactionPoints([
- {
- date: '2019-02-01',
- items: [
- {
- quantity: new Big('5'),
- symbol: 'AMZN',
- investment: new Big('10109.95'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- }
- ]);
- const timelineSpecification: TimelineSpecification[] = [
- {
- start: '2019-01-01',
- accuracy: 'year'
- }
- ];
- const timelineInfo = await portfolioCalculator.calculateTimeline(
- timelineSpecification,
- '2020-01-01'
- );
- const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
-
- expect(timeline).toEqual([
- {
- date: '2019-01-01',
- grossPerformance: new Big('0'),
- netPerformance: new Big('0'),
- investment: new Big('0'),
- value: new Big('0')
- },
- {
- date: '2020-01-01',
- grossPerformance: new Big('267.2'),
- netPerformance: new Big('267.2'),
- investment: new Big('11553.75'),
- value: new Big('11820.95') // 10 * (144.38 + days=334 * 0.08) + 5 * 2021.99
- }
- ]);
- });
- });
-
describe('annualized performance percentage', () => {
- const portfolioCalculator = new PortfolioCalculator(
+ const portfolioCalculator = new PortfolioCalculator({
currentRateService,
- 'USD'
- );
+ currency: 'USD',
+ orders: []
+ });
it('Get annualized performance', async () => {
expect(
@@ -2384,351 +71,3 @@ describe('PortfolioCalculator', () => {
});
});
});
-
-const ordersMixedSymbols: PortfolioOrder[] = [
- {
- date: '2017-01-03',
- name: 'Tesla, Inc.',
- quantity: new Big('50'),
- symbol: 'TSLA',
- type: OrderType.Buy,
- unitPrice: new Big('42.97'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big(0)
- },
- {
- date: '2017-07-01',
- name: 'Bitcoin USD',
- quantity: new Big('0.5614682'),
- symbol: 'BTCUSD',
- type: OrderType.Buy,
- unitPrice: new Big('3562.089535970158'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big(0)
- },
- {
- date: '2018-09-01',
- name: 'Amazon.com, Inc.',
- quantity: new Big('5'),
- symbol: 'AMZN',
- type: OrderType.Buy,
- unitPrice: new Big('2021.99'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big(0)
- }
-];
-
-const ordersVTI: PortfolioOrder[] = [
- {
- date: '2019-02-01',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('10'),
- symbol: 'VTI',
- type: OrderType.Buy,
- unitPrice: new Big('144.38'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big(0)
- },
- {
- date: '2019-08-03',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('10'),
- symbol: 'VTI',
- type: OrderType.Buy,
- unitPrice: new Big('147.99'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big(0)
- },
- {
- date: '2020-02-02',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('15'),
- symbol: 'VTI',
- type: OrderType.Sell,
- unitPrice: new Big('151.41'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big(0)
- },
- {
- date: '2021-08-01',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('10'),
- symbol: 'VTI',
- type: OrderType.Buy,
- unitPrice: new Big('177.69'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big(0)
- },
- {
- date: '2021-02-01',
- name: 'Vanguard Total Stock Market Index Fund ETF Shares',
- quantity: new Big('10'),
- symbol: 'VTI',
- type: OrderType.Buy,
- unitPrice: new Big('203.15'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- fee: new Big(0)
- }
-];
-
-const orderTslaTransactionPoint: TransactionPoint[] = [
- {
- date: '2021-01-01',
- items: [
- {
- quantity: new Big('1'),
- symbol: 'TSLA',
- investment: new Big('719.46'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2021-01-01',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- }
-];
-
-const ordersVTITransactionPoints: TransactionPoint[] = [
- {
- date: '2019-02-01',
- items: [
- {
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2019-08-03',
- items: [
- {
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 2
- }
- ]
- },
- {
- date: '2020-02-02',
- items: [
- {
- quantity: new Big('5'),
- symbol: 'VTI',
- investment: new Big('652.55'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 3
- }
- ]
- },
- {
- date: '2021-02-01',
- items: [
- {
- quantity: new Big('15'),
- symbol: 'VTI',
- investment: new Big('2684.05'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 4
- }
- ]
- },
- {
- date: '2021-08-01',
- items: [
- {
- quantity: new Big('25'),
- symbol: 'VTI',
- investment: new Big('4460.95'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 5
- }
- ]
- }
-];
-
-const transactionPointsBuyAndSell: TransactionPoint[] = [
- {
- date: '2019-02-01',
- items: [
- {
- quantity: new Big('10'),
- symbol: 'VTI',
- investment: new Big('1443.8'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 1
- }
- ]
- },
- {
- date: '2019-08-03',
- items: [
- {
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 2
- }
- ]
- },
- {
- date: '2019-09-01',
- items: [
- {
- quantity: new Big('5'),
- symbol: 'AMZN',
- investment: new Big('10109.95'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- quantity: new Big('20'),
- symbol: 'VTI',
- investment: new Big('2923.7'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 2
- }
- ]
- },
- {
- date: '2020-02-02',
- items: [
- {
- quantity: new Big('5'),
- symbol: 'AMZN',
- investment: new Big('10109.95'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 1
- },
- {
- quantity: new Big('5'),
- symbol: 'VTI',
- investment: new Big('652.55'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 3
- }
- ]
- },
- {
- date: '2020-08-02',
- items: [
- {
- quantity: new Big('0'),
- symbol: 'AMZN',
- investment: new Big('0'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 2
- },
- {
- quantity: new Big('5'),
- symbol: 'VTI',
- investment: new Big('652.55'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 3
- }
- ]
- },
- {
- date: '2021-02-01',
- items: [
- {
- quantity: new Big('0'),
- symbol: 'AMZN',
- investment: new Big('0'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 2
- },
- {
- quantity: new Big('15'),
- symbol: 'VTI',
- investment: new Big('2684.05'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 4
- }
- ]
- },
- {
- date: '2021-08-01',
- items: [
- {
- quantity: new Big('0'),
- symbol: 'AMZN',
- investment: new Big('0'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-09-01',
- fee: new Big(0),
- transactionCount: 2
- },
- {
- quantity: new Big('25'),
- symbol: 'VTI',
- investment: new Big('4460.95'),
- currency: 'USD',
- dataSource: DataSource.YAHOO,
- firstBuyDate: '2019-02-01',
- fee: new Big(0),
- transactionCount: 5
- }
- ]
- }
-];
diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts
index 0a41bcb02..4f8631a3f 100644
--- a/apps/api/src/app/portfolio/portfolio-calculator.ts
+++ b/apps/api/src/app/portfolio/portfolio-calculator.ts
@@ -1,15 +1,15 @@
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 { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
-import { TimelinePosition } from '@ghostfolio/common/interfaces';
+import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common';
+import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
+ addMilliseconds,
addMonths,
addYears,
- differenceInDays,
endOfDay,
format,
isAfter,
@@ -17,11 +17,12 @@ import {
max,
min
} from 'date-fns';
-import { flatten, isNumber } from 'lodash';
+import { first, flatten, isNumber, sortBy } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface';
+import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
import {
@@ -32,22 +33,39 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator {
- private transactionPoints: TransactionPoint[];
+ private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
+ true;
+
+ private static readonly ENABLE_LOGGING = false;
- public constructor(
- private currentRateService: CurrentRateService,
- private currency: string
- ) {}
+ private currency: string;
+ private currentRateService: CurrentRateService;
+ private orders: PortfolioOrder[];
+ private transactionPoints: TransactionPoint[];
- public computeTransactionPoints(orders: PortfolioOrder[]) {
- orders.sort((a, b) => a.date.localeCompare(b.date));
+ public constructor({
+ currency,
+ currentRateService,
+ orders
+ }: {
+ currency: string;
+ currentRateService: CurrentRateService;
+ orders: PortfolioOrder[];
+ }) {
+ this.currency = currency;
+ this.currentRateService = currentRateService;
+ this.orders = orders;
+
+ this.orders.sort((a, b) => a.date.localeCompare(b.date));
+ }
+ public computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
- for (const order of orders) {
+ for (const order of this.orders) {
const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol;
@@ -59,17 +77,30 @@ export class PortfolioCalculator {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
+
+ let investment = new Big(0);
+
+ if (newQuantity.gt(0)) {
+ if (order.type === 'BUY') {
+ investment = oldAccumulatedSymbol.investment.plus(
+ order.quantity.mul(unitPrice)
+ );
+ } else if (order.type === 'SELL') {
+ const averagePrice = oldAccumulatedSymbol.investment.div(
+ oldAccumulatedSymbol.quantity
+ );
+ investment = oldAccumulatedSymbol.investment.minus(
+ order.quantity.mul(averagePrice)
+ );
+ }
+ }
+
currentTransactionPointItem = {
+ investment,
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
- investment: newQuantity.eq(0)
- ? new Big(0)
- : unitPrice
- .mul(order.quantity)
- .mul(factor)
- .add(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
@@ -140,7 +171,6 @@ export class PortfolioCalculator {
hasErrors: false,
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
- netAnnualizedPerformance: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
@@ -195,6 +225,7 @@ export class PortfolioCalculator {
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
+
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
@@ -207,110 +238,37 @@ export class PortfolioCalculator {
}
}
- let hasErrors = false;
- const startString = format(start, DATE_FORMAT);
-
- const holdingPeriodReturns: { [symbol: string]: Big } = {};
- const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
- const grossPerformance: { [symbol: string]: Big } = {};
- const netPerformance: { [symbol: string]: Big } = {};
const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
}
- const invalidSymbols = [];
- const lastInvestments: { [symbol: string]: Big } = {};
- const lastQuantities: { [symbol: string]: Big } = {};
- const lastFees: { [symbol: string]: Big } = {};
const initialValues: { [symbol: string]: Big } = {};
- for (let i = firstIndex; i < this.transactionPoints.length; i++) {
- const currentDate =
- i === firstIndex ? startString : this.transactionPoints[i].date;
- const nextDate =
- i + 1 < this.transactionPoints.length
- ? this.transactionPoints[i + 1].date
- : todayString;
-
- const items = this.transactionPoints[i].items;
- for (const item of items) {
- if (!marketSymbolMap[nextDate]?.[item.symbol]) {
- invalidSymbols.push(item.symbol);
- hasErrors = true;
- Logger.error(
- `Missing value for symbol ${item.symbol} at ${nextDate}`
- );
- continue;
- }
- let lastInvestment: Big = new Big(0);
- let lastQuantity: Big = item.quantity;
- if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) {
- lastInvestment = item.investment.minus(lastInvestments[item.symbol]);
- lastQuantity = lastQuantities[item.symbol];
- }
-
- const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
- let initialValue = itemValue?.mul(lastQuantity);
- let investedValue = itemValue?.mul(item.quantity);
- const isFirstOrderAndIsStartBeforeCurrentDate =
- i === firstIndex &&
- isBefore(parseDate(this.transactionPoints[i].date), start);
- const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
- const fee = isFirstOrderAndIsStartBeforeCurrentDate
- ? new Big(0)
- : item.fee.minus(lastFee);
- if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
- initialValue = item.investment;
- investedValue = item.investment;
- }
- if (i === firstIndex || !initialValues[item.symbol]) {
- initialValues[item.symbol] = initialValue;
- }
- if (!item.quantity.eq(0)) {
- if (!initialValue) {
- invalidSymbols.push(item.symbol);
- hasErrors = true;
- Logger.error(
- `Missing value for symbol ${item.symbol} at ${currentDate}`
- );
- continue;
- }
+ const positions: TimelinePosition[] = [];
+ let hasAnySymbolMetricsErrors = false;
- const cashFlow = lastInvestment;
- const endValue = marketSymbolMap[nextDate][item.symbol].mul(
- item.quantity
- );
+ const errors: ResponseError['errors'] = [];
- const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
- holdingPeriodReturns[item.symbol] = (
- holdingPeriodReturns[item.symbol] ?? new Big(1)
- ).mul(holdingPeriodReturn);
- grossPerformance[item.symbol] = (
- grossPerformance[item.symbol] ?? new Big(0)
- ).plus(endValue.minus(investedValue));
+ for (const item of lastTransactionPoint.items) {
+ const marketValue = marketSymbolMap[todayString]?.[item.symbol];
- const netHoldingPeriodReturn = endValue.div(
- initialValue.plus(cashFlow).plus(fee)
- );
- netHoldingPeriodReturns[item.symbol] = (
- netHoldingPeriodReturns[item.symbol] ?? new Big(1)
- ).mul(netHoldingPeriodReturn);
- netPerformance[item.symbol] = (
- netPerformance[item.symbol] ?? new Big(0)
- ).plus(endValue.minus(investedValue).minus(fee));
- }
- lastInvestments[item.symbol] = item.investment;
- lastQuantities[item.symbol] = item.quantity;
- lastFees[item.symbol] = item.fee;
- }
- }
+ const {
+ grossPerformance,
+ grossPerformancePercentage,
+ hasErrors,
+ initialValue,
+ netPerformance,
+ netPerformancePercentage
+ } = this.getSymbolMetrics({
+ marketSymbolMap,
+ start,
+ symbol: item.symbol
+ });
- const positions: TimelinePosition[] = [];
+ hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
+ initialValues[item.symbol] = initialValue;
- for (const item of lastTransactionPoint.items) {
- const marketValue = marketSymbolMap[todayString]?.[item.symbol];
- const isValid = invalidSymbols.indexOf(item.symbol) === -1;
positions.push({
averagePrice: item.quantity.eq(0)
? new Big(0)
@@ -318,31 +276,33 @@ export class PortfolioCalculator {
currency: item.currency,
dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate,
- grossPerformance: isValid
- ? grossPerformance[item.symbol] ?? null
+ grossPerformance: !hasErrors ? grossPerformance ?? null : null,
+ grossPerformancePercentage: !hasErrors
+ ? grossPerformancePercentage ?? null
: null,
- grossPerformancePercentage:
- isValid && holdingPeriodReturns[item.symbol]
- ? holdingPeriodReturns[item.symbol].minus(1)
- : null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
- netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
- netPerformancePercentage:
- isValid && netHoldingPeriodReturns[item.symbol]
- ? netHoldingPeriodReturns[item.symbol].minus(1)
- : null,
+ netPerformance: !hasErrors ? netPerformance ?? null : null,
+ netPerformancePercentage: !hasErrors
+ ? netPerformancePercentage ?? null
+ : null,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
+
+ if (hasErrors) {
+ errors.push({ dataSource: item.dataSource, symbol: item.symbol });
+ }
}
+
const overall = this.calculateOverallPerformance(positions, initialValues);
return {
...overall,
+ errors,
positions,
- hasErrors: hasErrors || overall.hasErrors
+ hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
};
}
@@ -356,7 +316,7 @@ export class PortfolioCalculator {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
- investment.add(transactionPointSymbol.investment),
+ investment.plus(transactionPointSymbol.investment),
new Big(0)
)
};
@@ -460,75 +420,69 @@ export class PortfolioCalculator {
private calculateOverallPerformance(
positions: TimelinePosition[],
- initialValues: { [p: string]: Big }
+ initialValues: { [symbol: string]: Big }
) {
- let hasErrors = false;
let currentValue = new Big(0);
- let totalInvestment = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
+ let hasErrors = false;
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
- let completeInitialValue = new Big(0);
- let netAnnualizedPerformance = new Big(0);
-
- // use Date.now() to use the mock for today
- const today = new Date(Date.now());
+ let sumOfWeights = new Big(0);
+ let totalInvestment = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
- currentValue = currentValue.add(
+ currentValue = currentValue.plus(
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
);
} else {
hasErrors = true;
}
- totalInvestment = totalInvestment.add(currentPosition.investment);
+
+ totalInvestment = totalInvestment.plus(currentPosition.investment);
+
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
+
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
- if (
- currentPosition.grossPerformancePercentage &&
- initialValues[currentPosition.symbol]
- ) {
- const currentInitialValue = initialValues[currentPosition.symbol];
- completeInitialValue = completeInitialValue.plus(currentInitialValue);
+ if (currentPosition.grossPerformancePercentage) {
+ // Use the average from the initial value and the current investment as
+ // a weight
+ const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
+ .plus(currentPosition.investment)
+ .div(2);
+
+ sumOfWeights = sumOfWeights.plus(weight);
+
grossPerformancePercentage = grossPerformancePercentage.plus(
- currentPosition.grossPerformancePercentage.mul(currentInitialValue)
- );
- netAnnualizedPerformance = netAnnualizedPerformance.plus(
- this.getAnnualizedPerformancePercent({
- daysInMarket: differenceInDays(
- today,
- parseDate(currentPosition.firstBuyDate)
- ),
- netPerformancePercent: currentPosition.netPerformancePercentage
- }).mul(currentInitialValue)
+ currentPosition.grossPerformancePercentage.mul(weight)
);
+
netPerformancePercentage = netPerformancePercentage.plus(
- currentPosition.netPerformancePercentage.mul(currentInitialValue)
+ currentPosition.netPerformancePercentage.mul(weight)
);
} else if (!currentPosition.quantity.eq(0)) {
- Logger.error(
- `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
+ Logger.warn(
+ `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
+ 'PortfolioCalculator'
);
hasErrors = true;
}
}
- if (!completeInitialValue.eq(0)) {
- grossPerformancePercentage =
- grossPerformancePercentage.div(completeInitialValue);
- netPerformancePercentage =
- netPerformancePercentage.div(completeInitialValue);
- netAnnualizedPerformance =
- netAnnualizedPerformance.div(completeInitialValue);
+ if (sumOfWeights.gt(0)) {
+ grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
+ netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
+ } else {
+ grossPerformancePercentage = new Big(0);
+ netPerformancePercentage = new Big(0);
}
return {
@@ -536,7 +490,6 @@ export class PortfolioCalculator {
grossPerformance,
grossPerformancePercentage,
hasErrors,
- netAnnualizedPerformance,
netPerformance,
netPerformancePercentage,
totalInvestment
@@ -564,8 +517,8 @@ export class PortfolioCalculator {
dataSource: item.dataSource,
symbol: item.symbol
});
- investment = investment.add(item.investment);
- fees = fees.add(item.fee);
+ investment = investment.plus(item.investment);
+ fees = fees.plus(item.fee);
}
let marketSymbols: GetValueObject[] = [];
@@ -583,7 +536,8 @@ export class PortfolioCalculator {
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
- error
+ error,
+ 'PortfolioCalculator'
);
return null;
}
@@ -621,7 +575,7 @@ export class PortfolioCalculator {
invalid = true;
break;
}
- value = value.add(
+ value = value.plus(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
@@ -660,14 +614,14 @@ export class PortfolioCalculator {
};
}
- private getFactor(type: OrderType) {
+ private getFactor(type: TypeOfOrder) {
let factor: number;
switch (type) {
- case OrderType.Buy:
+ case 'BUY':
factor = 1;
break;
- case OrderType.Sell:
+ case 'SELL':
factor = -1;
break;
default:
@@ -689,6 +643,356 @@ export class PortfolioCalculator {
}
}
+ private getSymbolMetrics({
+ marketSymbolMap,
+ start,
+ symbol
+ }: {
+ marketSymbolMap: {
+ [date: string]: { [symbol: string]: Big };
+ };
+ start: Date;
+ symbol: string;
+ }) {
+ let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
+ return order.symbol === symbol;
+ });
+
+ if (orders.length <= 0) {
+ return {
+ hasErrors: false,
+ initialValue: new Big(0),
+ netPerformance: new Big(0),
+ netPerformancePercentage: new Big(0),
+ grossPerformance: new Big(0),
+ grossPerformancePercentage: new Big(0)
+ };
+ }
+
+ const dateOfFirstTransaction = new Date(first(orders).date);
+ const endDate = new Date(Date.now());
+
+ const unitPriceAtStartDate =
+ marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
+
+ const unitPriceAtEndDate =
+ marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
+
+ if (
+ !unitPriceAtEndDate ||
+ (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
+ ) {
+ return {
+ hasErrors: true,
+ initialValue: new Big(0),
+ netPerformance: new Big(0),
+ netPerformancePercentage: new Big(0),
+ grossPerformance: new Big(0),
+ grossPerformancePercentage: new Big(0)
+ };
+ }
+
+ let averagePriceAtEndDate = new Big(0);
+ let averagePriceAtStartDate = new Big(0);
+ let feesAtStartDate = new Big(0);
+ let fees = new Big(0);
+ let grossPerformance = new Big(0);
+ let grossPerformanceAtStartDate = new Big(0);
+ let grossPerformanceFromSells = new Big(0);
+ let initialValue: Big;
+ let investmentAtStartDate: Big;
+ let lastAveragePrice = new Big(0);
+ let lastTransactionInvestment = new Big(0);
+ let lastValueOfInvestmentBeforeTransaction = new Big(0);
+ let maxTotalInvestment = new Big(0);
+ let timeWeightedGrossPerformancePercentage = new Big(1);
+ let timeWeightedNetPerformancePercentage = new Big(1);
+ let totalInvestment = new Big(0);
+ let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
+ let totalUnits = new Big(0);
+ let valueAtStartDate: Big;
+
+ // Add a synthetic order at the start and the end date
+ orders.push({
+ symbol,
+ currency: null,
+ date: format(start, DATE_FORMAT),
+ dataSource: null,
+ fee: new Big(0),
+ itemType: 'start',
+ name: '',
+ quantity: new Big(0),
+ type: TypeOfOrder.BUY,
+ unitPrice: unitPriceAtStartDate
+ });
+
+ orders.push({
+ symbol,
+ currency: null,
+ date: format(endDate, DATE_FORMAT),
+ dataSource: null,
+ fee: new Big(0),
+ itemType: 'end',
+ name: '',
+ quantity: new Big(0),
+ type: TypeOfOrder.BUY,
+ unitPrice: unitPriceAtEndDate
+ });
+
+ // Sort orders so that the start and end placeholder order are at the right
+ // position
+ orders = sortBy(orders, (order) => {
+ let sortIndex = new Date(order.date);
+
+ if (order.itemType === 'start') {
+ sortIndex = addMilliseconds(sortIndex, -1);
+ }
+
+ if (order.itemType === 'end') {
+ sortIndex = addMilliseconds(sortIndex, 1);
+ }
+
+ return sortIndex.getTime();
+ });
+
+ const indexOfStartOrder = orders.findIndex((order) => {
+ return order.itemType === 'start';
+ });
+
+ const indexOfEndOrder = orders.findIndex((order) => {
+ return order.itemType === 'end';
+ });
+
+ for (let i = 0; i < orders.length; i += 1) {
+ const order = orders[i];
+
+ if (order.itemType === 'start') {
+ // Take the unit price of the order as the market price if there are no
+ // orders of this symbol before the start date
+ order.unitPrice =
+ indexOfStartOrder === 0
+ ? orders[i + 1]?.unitPrice
+ : unitPriceAtStartDate;
+ }
+
+ // Calculate the average start price as soon as any units are held
+ if (
+ averagePriceAtStartDate.eq(0) &&
+ i >= indexOfStartOrder &&
+ totalUnits.gt(0)
+ ) {
+ averagePriceAtStartDate = totalInvestment.div(totalUnits);
+ }
+
+ const valueOfInvestmentBeforeTransaction = totalUnits.mul(
+ order.unitPrice
+ );
+
+ if (!investmentAtStartDate && i >= indexOfStartOrder) {
+ investmentAtStartDate = totalInvestment ?? new Big(0);
+ valueAtStartDate = valueOfInvestmentBeforeTransaction;
+ }
+
+ const transactionInvestment = order.quantity
+ .mul(order.unitPrice)
+ .mul(this.getFactor(order.type));
+
+ totalInvestment = totalInvestment.plus(transactionInvestment);
+
+ if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
+ maxTotalInvestment = totalInvestment;
+ }
+
+ if (i === indexOfEndOrder && totalUnits.gt(0)) {
+ averagePriceAtEndDate = totalInvestment.div(totalUnits);
+ }
+
+ if (i >= indexOfStartOrder && !initialValue) {
+ if (
+ i === indexOfStartOrder &&
+ !valueOfInvestmentBeforeTransaction.eq(0)
+ ) {
+ initialValue = valueOfInvestmentBeforeTransaction;
+ } else if (transactionInvestment.gt(0)) {
+ initialValue = transactionInvestment;
+ }
+ }
+
+ fees = fees.plus(order.fee);
+
+ totalUnits = totalUnits.plus(
+ order.quantity.mul(this.getFactor(order.type))
+ );
+
+ const valueOfInvestment = totalUnits.mul(order.unitPrice);
+
+ const grossPerformanceFromSell =
+ order.type === TypeOfOrder.SELL
+ ? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
+ : new Big(0);
+
+ grossPerformanceFromSells = grossPerformanceFromSells.plus(
+ grossPerformanceFromSell
+ );
+
+ totalInvestmentWithGrossPerformanceFromSell =
+ totalInvestmentWithGrossPerformanceFromSell
+ .plus(transactionInvestment)
+ .plus(grossPerformanceFromSell);
+
+ lastAveragePrice = totalUnits.eq(0)
+ ? new Big(0)
+ : totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
+
+ const newGrossPerformance = valueOfInvestment
+ .minus(totalInvestmentWithGrossPerformanceFromSell)
+ .plus(grossPerformanceFromSells);
+
+ if (
+ i > indexOfStartOrder &&
+ !lastValueOfInvestmentBeforeTransaction
+ .plus(lastTransactionInvestment)
+ .eq(0)
+ ) {
+ const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
+ .minus(
+ lastValueOfInvestmentBeforeTransaction.plus(
+ lastTransactionInvestment
+ )
+ )
+ .div(
+ lastValueOfInvestmentBeforeTransaction.plus(
+ lastTransactionInvestment
+ )
+ );
+
+ timeWeightedGrossPerformancePercentage =
+ timeWeightedGrossPerformancePercentage.mul(
+ new Big(1).plus(grossHoldingPeriodReturn)
+ );
+
+ const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
+ .minus(fees.minus(feesAtStartDate))
+ .minus(
+ lastValueOfInvestmentBeforeTransaction.plus(
+ lastTransactionInvestment
+ )
+ )
+ .div(
+ lastValueOfInvestmentBeforeTransaction.plus(
+ lastTransactionInvestment
+ )
+ );
+
+ timeWeightedNetPerformancePercentage =
+ timeWeightedNetPerformancePercentage.mul(
+ new Big(1).plus(netHoldingPeriodReturn)
+ );
+ }
+
+ grossPerformance = newGrossPerformance;
+
+ lastTransactionInvestment = transactionInvestment;
+
+ lastValueOfInvestmentBeforeTransaction =
+ valueOfInvestmentBeforeTransaction;
+
+ if (order.itemType === 'start') {
+ feesAtStartDate = fees;
+ grossPerformanceAtStartDate = grossPerformance;
+ }
+ }
+
+ timeWeightedGrossPerformancePercentage =
+ timeWeightedGrossPerformancePercentage.minus(1);
+
+ timeWeightedNetPerformancePercentage =
+ timeWeightedNetPerformancePercentage.minus(1);
+
+ const totalGrossPerformance = grossPerformance.minus(
+ grossPerformanceAtStartDate
+ );
+
+ const totalNetPerformance = grossPerformance
+ .minus(grossPerformanceAtStartDate)
+ .minus(fees.minus(feesAtStartDate));
+
+ const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
+ maxTotalInvestment.minus(investmentAtStartDate)
+ );
+
+ const grossPerformancePercentage =
+ PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
+ averagePriceAtStartDate.eq(0) ||
+ averagePriceAtEndDate.eq(0) ||
+ orders[indexOfStartOrder].unitPrice.eq(0)
+ ? maxInvestmentBetweenStartAndEndDate.gt(0)
+ ? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
+ : new Big(0)
+ : // This formula has the issue that buying more units with a price
+ // lower than the average buying price results in a positive
+ // performance even if the market price stays constant
+ unitPriceAtEndDate
+ .div(averagePriceAtEndDate)
+ .div(
+ orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
+ )
+ .minus(1);
+
+ const feesPerUnit = totalUnits.gt(0)
+ ? fees.minus(feesAtStartDate).div(totalUnits)
+ : new Big(0);
+
+ const netPerformancePercentage =
+ PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
+ averagePriceAtStartDate.eq(0) ||
+ averagePriceAtEndDate.eq(0) ||
+ orders[indexOfStartOrder].unitPrice.eq(0)
+ ? maxInvestmentBetweenStartAndEndDate.gt(0)
+ ? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
+ : new Big(0)
+ : // This formula has the issue that buying more units with a price
+ // lower than the average buying price results in a positive
+ // performance even if the market price stays constant
+ unitPriceAtEndDate
+ .minus(feesPerUnit)
+ .div(averagePriceAtEndDate)
+ .div(
+ orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
+ )
+ .minus(1);
+
+ if (PortfolioCalculator.ENABLE_LOGGING) {
+ console.log(
+ `
+ ${symbol}
+ Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
+ 2
+ )} -> ${unitPriceAtEndDate.toFixed(2)}
+ Average price: ${averagePriceAtStartDate.toFixed(
+ 2
+ )} -> ${averagePriceAtEndDate.toFixed(2)}
+ Max. total investment: ${maxTotalInvestment.toFixed(2)}
+ Gross performance: ${totalGrossPerformance.toFixed(
+ 2
+ )} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
+ Fees per unit: ${feesPerUnit.toFixed(2)}
+ Net performance: ${totalNetPerformance.toFixed(
+ 2
+ )} / ${netPerformancePercentage.mul(100).toFixed(2)}%`
+ );
+ }
+
+ return {
+ initialValue,
+ grossPerformancePercentage,
+ netPerformancePercentage,
+ hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
+ netPerformance: totalNetPerformance,
+ grossPerformance: totalGrossPerformance
+ };
+ }
+
private isNextItemActive(
timelineSpecification: TimelineSpecification[],
currentDate: Date,
diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts
index f4578e070..5d3c683f1 100644
--- a/apps/api/src/app/portfolio/portfolio.controller.ts
+++ b/apps/api/src/app/portfolio/portfolio.controller.ts
@@ -4,19 +4,23 @@ import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
+import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
+import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
+import { parseDate } from '@ghostfolio/common/helper';
import {
+ Filter,
PortfolioChart,
PortfolioDetails,
- PortfolioPerformance,
+ PortfolioInvestments,
+ PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
-import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
-import type { RequestWithUser } from '@ghostfolio/common/types';
+import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@@ -25,12 +29,12 @@ import {
Inject,
Param,
Query,
- Res,
- UseGuards
+ UseGuards,
+ UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
-import { Response } from 'express';
+import { ViewMode } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
@@ -48,48 +52,11 @@ export class PortfolioController {
private readonly userService: UserService
) {}
- @Get('investments')
- @UseGuards(AuthGuard('jwt'))
- public async findAll(
- @Headers('impersonation-id') impersonationId,
- @Res() res: Response
- ): Promise {
- if (
- this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
- this.request.user.subscription.type === 'Basic'
- ) {
- res.status(StatusCodes.FORBIDDEN);
- return 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 res.json(investments);
- }
-
@Get('chart')
@UseGuards(AuthGuard('jwt'))
public async getChart(
- @Headers('impersonation-id') impersonationId,
- @Query('range') range,
- @Res() res: Response
+ @Headers('impersonation-id') impersonationId: string,
+ @Query('range') range
): Promise {
const historicalDataContainer = await this.portfolioService.getChart(
impersonationId,
@@ -98,18 +65,14 @@ export class PortfolioController {
let chartData = historicalDataContainer.items;
- let hasNullValue = false;
+ let hasError = false;
chartData.forEach((chartDataItem) => {
if (hasNotDefinedValuesInObject(chartDataItem)) {
- hasNullValue = true;
+ hasError = true;
}
});
- if (hasNullValue) {
- res.status(StatusCodes.ACCEPTED);
- }
-
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
@@ -130,37 +93,61 @@ export class PortfolioController {
});
}
- return res.json({
+ return {
+ hasError,
chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow
- });
+ };
}
@Get('details')
@UseGuards(AuthGuard('jwt'))
+ @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
- @Headers('impersonation-id') impersonationId,
- @Query('range') range,
- @Res() res: Response
- ): Promise {
- if (
- this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
- this.request.user.subscription.type === 'Basic'
- ) {
- res.status(StatusCodes.FORBIDDEN);
- return res.json({ accounts: {}, holdings: {} });
- }
+ @Headers('impersonation-id') impersonationId: string,
+ @Query('accounts') filterByAccounts?: string,
+ @Query('assetClasses') filterByAssetClasses?: string,
+ @Query('range') range?: DateRange,
+ @Query('tags') filterByTags?: string
+ ): Promise {
+ let hasError = false;
+
+ const accountIds = filterByAccounts?.split(',') ?? [];
+ const assetClasses = filterByAssetClasses?.split(',') ?? [];
+ const tagIds = filterByTags?.split(',') ?? [];
+
+ const filters: Filter[] = [
+ ...accountIds.map((accountId) => {
+ return {
+ id: accountId,
+ type: 'ACCOUNT'
+ };
+ }),
+ ...assetClasses.map((assetClass) => {
+ return {
+ id: assetClass,
+ type: 'ASSET_CLASS'
+ };
+ }),
+ ...tagIds.map((tagId) => {
+ return {
+ id: tagId,
+ type: 'TAG'
+ };
+ })
+ ];
const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
- range
+ range,
+ filters
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
- res.status(StatusCodes.ACCEPTED);
+ hasError = true;
}
if (
@@ -198,55 +185,92 @@ export class PortfolioController {
}
}
- return res.json({ accounts, holdings });
+ const isBasicUser =
+ this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
+ this.request.user.subscription.type === 'Basic';
+
+ return {
+ accounts,
+ hasError,
+ holdings: isBasicUser ? {} : holdings
+ };
+ }
+
+ @Get('investments')
+ @UseGuards(AuthGuard('jwt'))
+ public async getInvestments(
+ @Headers('impersonation-id') impersonationId: string
+ ): Promise {
+ if (
+ this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
+ this.request.user.subscription.type === 'Basic'
+ ) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.FORBIDDEN),
+ StatusCodes.FORBIDDEN
+ );
+ }
+
+ 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 { firstOrderDate: parseDate(investments[0]?.date), investments };
}
@Get('performance')
@UseGuards(AuthGuard('jwt'))
+ @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPerformance(
- @Headers('impersonation-id') impersonationId,
- @Query('range') range,
- @Res() res: Response
- ): Promise {
+ @Headers('impersonation-id') impersonationId: string,
+ @Query('range') range
+ ): Promise {
const performanceInformation = await this.portfolioService.getPerformance(
impersonationId,
range
);
- if (performanceInformation?.hasErrors) {
- res.status(StatusCodes.ACCEPTED);
- }
-
- let performance = performanceInformation.performance;
if (
impersonationId ||
+ this.request.user.Settings.viewMode === ViewMode.ZEN ||
this.userService.isRestrictedView(this.request.user)
) {
- performance = nullifyValuesInObject(performance, [
- 'currentGrossPerformance',
- 'currentValue'
- ]);
+ performanceInformation.performance = nullifyValuesInObject(
+ performanceInformation.performance,
+ ['currentGrossPerformance', 'currentValue']
+ );
}
- return res.json(performance);
+ return performanceInformation;
}
@Get('positions')
@UseGuards(AuthGuard('jwt'))
+ @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
- @Headers('impersonation-id') impersonationId,
- @Query('range') range,
- @Res() res: Response
+ @Headers('impersonation-id') impersonationId: string,
+ @Query('range') range
): Promise {
const result = await this.portfolioService.getPositions(
impersonationId,
range
);
- if (result?.hasErrors) {
- res.status(StatusCodes.ACCEPTED);
- }
-
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
@@ -261,13 +285,12 @@ export class PortfolioController {
});
}
- return res.json(result);
+ return result;
}
@Get('public/:accessId')
public async getPublic(
- @Param('accessId') accessId,
- @Res() res: Response
+ @Param('accessId') accessId
): Promise {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
@@ -275,8 +298,10 @@ export class PortfolioController {
});
if (!access) {
- res.status(StatusCodes.NOT_FOUND);
- return res.json({ accounts: {}, holdings: {} });
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.NOT_FOUND),
+ StatusCodes.NOT_FOUND
+ );
}
let hasDetails = true;
@@ -313,6 +338,7 @@ export class PortfolioController {
allocationCurrent: portfolioPosition.allocationCurrent,
countries: hasDetails ? portfolioPosition.countries : [],
currency: portfolioPosition.currency,
+ markets: portfolioPosition.markets,
name: portfolioPosition.name,
sectors: hasDetails ? portfolioPosition.sectors : [],
value: portfolioPosition.value / totalValue
@@ -320,7 +346,7 @@ export class PortfolioController {
}
}
- return res.json(portfolioPublicDetails);
+ return portfolioPublicDetails;
}
@Get('summary')
@@ -328,6 +354,16 @@ export class PortfolioController {
public async getSummary(
@Headers('impersonation-id') impersonationId
): Promise {
+ if (
+ this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
+ this.request.user.subscription.type === 'Basic'
+ ) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.FORBIDDEN),
+ StatusCodes.FORBIDDEN
+ );
+ }
+
let summary = await this.portfolioService.getSummary(impersonationId);
if (
@@ -340,7 +376,10 @@ export class PortfolioController {
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
+ 'dividend',
+ 'emergencyFund',
'fees',
+ 'items',
'netWorth',
'totalBuy',
'totalSell'
@@ -350,13 +389,17 @@ export class PortfolioController {
return summary;
}
- @Get('position/:symbol')
+ @Get('position/:dataSource/:symbol')
+ @UseInterceptors(TransformDataSourceInRequestInterceptor)
+ @UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'))
public async getPosition(
- @Headers('impersonation-id') impersonationId,
+ @Headers('impersonation-id') impersonationId: string,
+ @Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise {
let position = await this.portfolioService.getPosition(
+ dataSource,
impersonationId,
symbol
);
@@ -370,6 +413,7 @@ export class PortfolioController {
'grossPerformance',
'investment',
'netPerformance',
+ 'orders',
'quantity',
'value'
]);
@@ -387,19 +431,18 @@ export class PortfolioController {
@Get('report')
@UseGuards(AuthGuard('jwt'))
public async getReport(
- @Headers('impersonation-id') impersonationId,
- @Res() res: Response
+ @Headers('impersonation-id') impersonationId: string
): Promise {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
- res.status(StatusCodes.FORBIDDEN);
- return res.json({ rules: [] });
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.FORBIDDEN),
+ StatusCodes.FORBIDDEN
+ );
}
- return (
- res.json(await this.portfolioService.getReport(impersonationId))
- );
+ return await this.portfolioService.getReport(impersonationId);
}
}
diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts
index 515330516..7e6dfe88d 100644
--- a/apps/api/src/app/portfolio/portfolio.module.ts
+++ b/apps/api/src/app/portfolio/portfolio.module.ts
@@ -18,6 +18,7 @@ import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service';
@Module({
+ controllers: [PortfolioController],
exports: [PortfolioService],
imports: [
AccessModule,
@@ -32,7 +33,6 @@ import { RulesService } from './rules.service';
SymbolProfileModule,
UserModule
],
- controllers: [PortfolioController],
providers: [
AccountService,
CurrentRateService,
diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts
index 887b1cbe5..409a40017 100644
--- a/apps/api/src/app/portfolio/portfolio.service.ts
+++ b/apps/api/src/app/portfolio/portfolio.service.ts
@@ -5,8 +5,8 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
-import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
-import { OrderType } from '@ghostfolio/api/models/order-type';
+import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface';
+import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
@@ -18,19 +18,20 @@ import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
-import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
+ ASSET_SUB_CLASS_EMERGENCY_FUND,
UNKNOWN_KEY,
- baseCurrency,
- ghostfolioCashSymbol
+ baseCurrency
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
+ Filter,
+ HistoricalDataItem,
PortfolioDetails,
- PortfolioPerformance,
+ PortfolioPerformanceResponse,
PortfolioReport,
PortfolioSummary,
Position,
@@ -40,14 +41,21 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import type {
AccountWithValue,
DateRange,
+ Market,
OrderWithAccount,
RequestWithUser
} from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
-import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
+import {
+ AssetClass,
+ DataSource,
+ Tag,
+ Type as TypeOfOrder
+} from '@prisma/client';
import Big from 'big.js';
import {
+ differenceInDays,
endOfToday,
format,
isAfter,
@@ -60,15 +68,18 @@ import {
subDays,
subYears
} from 'date-fns';
-import { isEmpty } from 'lodash';
+import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
import {
HistoricalDataContainer,
- HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
+import { PortfolioCalculator } from './portfolio-calculator';
import { RulesService } from './rules.service';
+const developedMarkets = require('../../assets/countries/developed-markets.json');
+const emergingMarkets = require('../../assets/countries/emerging-markets.json');
+
@Injectable()
export class PortfolioService {
public constructor(
@@ -80,7 +91,8 @@ export class PortfolioService {
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService,
- private readonly symbolProfileService: SymbolProfileService
+ private readonly symbolProfileService: SymbolProfileService,
+ private readonly userService: UserService
) {}
public async getAccounts(aUserId: string): Promise {
@@ -104,15 +116,22 @@ export class PortfolioService {
}
}
+ const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0;
+
const result = {
...account,
transactionCount,
- convertedBalance: this.exchangeRateDataService.toCurrency(
+ valueInBaseCurrency,
+ balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
- value: details.accounts[account.name]?.current ?? 0
+ value: this.exchangeRateDataService.toCurrency(
+ valueInBaseCurrency,
+ userCurrency,
+ account.currency
+ )
};
delete result.Order;
@@ -123,17 +142,26 @@ export class PortfolioService {
public async getAccountsWithAggregations(aUserId: string): Promise {
const accounts = await this.getAccounts(aUserId);
- let totalBalance = 0;
- let totalValue = 0;
+ let totalBalanceInBaseCurrency = new Big(0);
+ let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
for (const account of accounts) {
- totalBalance += account.convertedBalance;
- totalValue += account.value;
+ totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
+ account.balanceInBaseCurrency
+ );
+ totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
+ account.valueInBaseCurrency
+ );
transactionCount += account.transactionCount;
}
- return { accounts, totalBalance, totalValue, transactionCount };
+ return {
+ accounts,
+ transactionCount,
+ totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
+ totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber()
+ };
}
public async getInvestments(
@@ -141,26 +169,50 @@ export class PortfolioService {
): Promise {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
- const portfolioCalculator = new PortfolioCalculator(
- this.currentRateService,
- this.request.user.Settings.currency
- );
+ const { portfolioOrders, transactionPoints } =
+ await this.getTransactionPoints({
+ userId,
+ includeDrafts: true
+ });
- const { transactionPoints } = await this.getTransactionPoints({
- userId,
- includeDrafts: true
+ const portfolioCalculator = new PortfolioCalculator({
+ currency: this.request.user.Settings.currency,
+ currentRateService: this.currentRateService,
+ orders: portfolioOrders
});
+
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return [];
}
- return portfolioCalculator.getInvestments().map((item) => {
+ const investments = portfolioCalculator.getInvestments().map((item) => {
return {
date: item.date,
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(
@@ -169,12 +221,17 @@ export class PortfolioService {
): Promise {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
- const portfolioCalculator = new PortfolioCalculator(
- this.currentRateService,
- this.request.user.Settings.currency
- );
+ const { portfolioOrders, transactionPoints } =
+ await this.getTransactionPoints({
+ userId
+ });
+
+ const portfolioCalculator = new PortfolioCalculator({
+ currency: this.request.user.Settings.currency,
+ currentRateService: this.currentRateService,
+ orders: portfolioOrders
+ });
- const { transactionPoints } = await this.getTransactionPoints({ userId });
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return {
@@ -251,42 +308,55 @@ export class PortfolioService {
public async getDetails(
aImpersonationId: string,
aUserId: string,
- aDateRange: DateRange = 'max'
+ aDateRange: DateRange = 'max',
+ aFilters?: Filter[]
): Promise {
const userId = await this.getUserId(aImpersonationId, aUserId);
+ const user = await this.userService.user({ id: userId });
- const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
- const portfolioCalculator = new PortfolioCalculator(
- this.currentRateService,
- userCurrency
+ const emergencyFund = new Big(
+ (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
+ const userCurrency =
+ user.Settings?.currency ??
+ this.request.user?.Settings?.currency ??
+ baseCurrency;
+
+ const { orders, portfolioOrders, transactionPoints } =
+ await this.getTransactionPoints({
+ userId,
+ filters: aFilters
+ });
- const { orders, transactionPoints } = await this.getTransactionPoints({
- userId
+ const portfolioCalculator = new PortfolioCalculator({
+ currency: userCurrency,
+ currentRateService: this.currentRateService,
+ orders: portfolioOrders
});
- if (transactionPoints?.length <= 0) {
- return { accounts: {}, holdings: {}, hasErrors: false };
- }
-
portfolioCalculator.setTransactionPoints(transactionPoints);
- const portfolioStart = parseDate(transactionPoints[0].date);
+ const portfolioStart = parseDate(
+ transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
+ );
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
- const cashDetails = await this.accountService.getCashDetails(
+ const cashDetails = await this.accountService.getCashDetails({
userId,
- userCurrency
- );
+ currency: userCurrency,
+ filters: aFilters
+ });
const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus(
- cashDetails.balance
+ cashDetails.balanceInBaseCurrency
+ );
+ const totalValue = currentPositions.currentValue.plus(
+ cashDetails.balanceInBaseCurrency
);
- const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const dataGatheringItems = currentPositions.positions.map((position) => {
return {
@@ -299,7 +369,7 @@ export class PortfolioService {
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
- this.dataProviderService.get(dataGatheringItems),
+ this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
@@ -322,14 +392,38 @@ export class PortfolioService {
const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol];
+
+ const markets: { [key in Market]: number } = {
+ developedMarkets: 0,
+ emergingMarkets: 0,
+ otherMarkets: 0
+ };
+
+ for (const country of symbolProfile.countries) {
+ if (developedMarkets.includes(country.code)) {
+ markets.developedMarkets = new Big(markets.developedMarkets)
+ .plus(country.weight)
+ .toNumber();
+ } else if (emergingMarkets.includes(country.code)) {
+ markets.emergingMarkets = new Big(markets.emergingMarkets)
+ .plus(country.weight)
+ .toNumber();
+ } else {
+ markets.otherMarkets = new Big(markets.otherMarkets)
+ .plus(country.weight)
+ .toNumber();
+ }
+ }
+
holdings[item.symbol] = {
+ markets,
allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries,
currency: item.currency,
- exchange: dataProviderResponse.exchange,
+ dataSource: symbolProfile.dataSource,
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0,
@@ -347,41 +441,58 @@ export class PortfolioService {
};
}
- const cashPositions = await this.getCashPositions({
- cashDetails,
- userCurrency,
- investment: totalInvestment,
- value: totalValue
- });
+ if (
+ aFilters?.length === 0 ||
+ (aFilters?.length === 1 &&
+ aFilters[0].type === 'ASSET_CLASS' &&
+ aFilters[0].id === 'CASH')
+ ) {
+ const cashPositions = await this.getCashPositions({
+ cashDetails,
+ emergencyFund,
+ userCurrency,
+ investment: totalInvestment,
+ value: totalValue
+ });
- for (const symbol of Object.keys(cashPositions)) {
- holdings[symbol] = cashPositions[symbol];
+ for (const symbol of Object.keys(cashPositions)) {
+ holdings[symbol] = cashPositions[symbol];
+ }
}
- const accounts = await this.getValueOfAccounts(
+ const accounts = await this.getValueOfAccounts({
orders,
+ userId,
portfolioItemsNow,
- userCurrency,
- userId
- );
+ filters: aFilters
+ });
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
}
public async getPosition(
+ aDataSource: DataSource,
aImpersonationId: string,
aSymbol: string
): Promise {
+ const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
- const orders = (await this.orderService.getOrders({ userId })).filter(
- (order) => order.symbol === aSymbol
- );
+ const orders = (
+ await this.orderService.getOrders({ userCurrency, userId })
+ ).filter(({ SymbolProfile }) => {
+ return (
+ SymbolProfile.dataSource === aDataSource &&
+ SymbolProfile.symbol === aSymbol
+ );
+ });
+
+ let tags: Tag[] = [];
if (orders.length <= 0) {
return {
+ tags,
averagePrice: undefined,
- currency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
@@ -390,38 +501,48 @@ export class PortfolioService {
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
- name: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
+ orders: [],
quantity: undefined,
- symbol: aSymbol,
+ SymbolProfile: undefined,
transactionCount: undefined,
value: undefined
};
}
- const assetClass = orders[0].SymbolProfile?.assetClass;
- const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
- const positionCurrency = orders[0].currency;
- const name = orders[0].SymbolProfile?.name ?? '';
+ const positionCurrency = orders[0].SymbolProfile.currency;
+ const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
+ aSymbol
+ ]);
- const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
- currency: order.currency,
- dataSource: order.dataSource,
- date: format(order.date, DATE_FORMAT),
- fee: new Big(order.fee),
- name: order.SymbolProfile?.name,
- quantity: new Big(order.quantity),
- symbol: order.symbol,
- type: order.type,
- unitPrice: new Big(order.unitPrice)
- }));
+ const portfolioOrders: PortfolioOrder[] = orders
+ .filter((order) => {
+ tags = tags.concat(order.tags);
- const portfolioCalculator = new PortfolioCalculator(
- this.currentRateService,
- positionCurrency
- );
- portfolioCalculator.computeTransactionPoints(portfolioOrders);
+ return order.type === 'BUY' || order.type === 'SELL';
+ })
+ .map((order) => ({
+ currency: order.SymbolProfile.currency,
+ dataSource: order.SymbolProfile.dataSource,
+ date: format(order.date, DATE_FORMAT),
+ fee: new Big(order.fee),
+ name: order.SymbolProfile?.name,
+ quantity: new Big(order.quantity),
+ symbol: order.SymbolProfile.symbol,
+ type: order.type,
+ unitPrice: new Big(order.unitPrice)
+ }));
+
+ tags = uniqBy(tags, 'id');
+
+ const portfolioCalculator = new PortfolioCalculator({
+ currency: positionCurrency,
+ currentRateService: this.currentRateService,
+ orders: portfolioOrders
+ });
+
+ portfolioCalculator.computeTransactionPoints();
const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date);
@@ -445,19 +566,18 @@ export class PortfolioService {
} = position;
// Convert investment, gross and net performance to currency of user
- const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency(
- position.investment.toNumber(),
+ position.investment?.toNumber(),
currency,
userCurrency
);
const grossPerformance = this.exchangeRateDataService.toCurrency(
- position.grossPerformance.toNumber(),
+ position.grossPerformance?.toNumber(),
currency,
userCurrency
);
const netPerformance = this.exchangeRateDataService.toCurrency(
- position.netPerformance.toNumber(),
+ position.netPerformance?.toNumber(),
currency,
userCurrency
);
@@ -515,24 +635,23 @@ export class PortfolioService {
}
return {
- assetClass,
- assetSubClass,
- currency,
firstBuyDate,
grossPerformance,
investment,
marketPrice,
maxPrice,
minPrice,
- name,
netPerformance,
+ orders,
+ SymbolProfile,
+ tags,
transactionCount,
averagePrice: averagePrice.toNumber(),
- grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
+ grossPerformancePercent:
+ position.grossPerformancePercentage?.toNumber(),
historicalData: historicalDataArray,
- netPerformancePercent: position.netPerformancePercentage.toNumber(),
+ netPerformancePercent: position.netPerformancePercentage?.toNumber(),
quantity: quantity.toNumber(),
- symbol: aSymbol,
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(),
currency,
@@ -540,7 +659,7 @@ export class PortfolioService {
)
};
} else {
- const currentData = await this.dataProviderService.get([
+ const currentData = await this.dataProviderService.getQuotes([
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
]);
const marketPrice = currentData[aSymbol]?.marketPrice;
@@ -577,14 +696,13 @@ export class PortfolioService {
}
return {
- assetClass,
- assetSubClass,
marketPrice,
maxPrice,
minPrice,
- name,
+ orders,
+ SymbolProfile,
+ tags,
averagePrice: 0,
- currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
@@ -593,7 +711,6 @@ export class PortfolioService {
netPerformance: undefined,
netPerformancePercent: undefined,
quantity: 0,
- symbol: aSymbol,
transactionCount: undefined,
value: 0
};
@@ -606,12 +723,16 @@ export class PortfolioService {
): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
- const portfolioCalculator = new PortfolioCalculator(
- this.currentRateService,
- this.request.user.Settings.currency
- );
+ const { portfolioOrders, transactionPoints } =
+ await this.getTransactionPoints({
+ userId
+ });
- const { transactionPoints } = await this.getTransactionPoints({ userId });
+ const portfolioCalculator = new PortfolioCalculator({
+ currency: this.request.user.Settings.currency,
+ currentRateService: this.currentRateService,
+ orders: portfolioOrders
+ });
if (transactionPoints?.length <= 0) {
return {
@@ -640,7 +761,7 @@ export class PortfolioService {
const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
- this.dataProviderService.get(dataGatheringItem),
+ this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
@@ -660,7 +781,8 @@ export class PortfolioService {
grossPerformancePercentage:
position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(),
- marketState: dataProviderResponses[position.symbol].marketState,
+ marketState:
+ dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage:
@@ -674,28 +796,29 @@ export class PortfolioService {
public async getPerformance(
aImpersonationId: string,
aDateRange: DateRange = 'max'
- ): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
+ ): Promise {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
- const portfolioCalculator = new PortfolioCalculator(
- this.currentRateService,
- this.request.user.Settings.currency
- );
+ const { portfolioOrders, transactionPoints } =
+ await this.getTransactionPoints({
+ userId
+ });
- const { transactionPoints } = await this.getTransactionPoints({ userId });
+ const portfolioCalculator = new PortfolioCalculator({
+ currency: this.request.user.Settings.currency,
+ currentRateService: this.currentRateService,
+ orders: portfolioOrders
+ });
if (transactionPoints?.length <= 0) {
return {
hasErrors: false,
performance: {
- annualizedPerformancePercent: 0,
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
- currentValue: 0,
- isAllTimeHigh: false,
- isAllTimeLow: false
+ currentValue: 0
}
};
}
@@ -709,55 +832,46 @@ export class PortfolioService {
);
const hasErrors = currentPositions.hasErrors;
- const annualizedPerformancePercent =
- currentPositions.netAnnualizedPerformance.toNumber();
const currentValue = currentPositions.currentValue.toNumber();
- const currentGrossPerformance =
- currentPositions.grossPerformance.toNumber();
- const currentGrossPerformancePercent =
- currentPositions.grossPerformancePercentage.toNumber();
- const currentNetPerformance = currentPositions.netPerformance.toNumber();
- const currentNetPerformancePercent =
- currentPositions.netPerformancePercentage.toNumber();
+ const currentGrossPerformance = currentPositions.grossPerformance;
+ let currentGrossPerformancePercent =
+ currentPositions.grossPerformancePercentage;
+ const currentNetPerformance = currentPositions.netPerformance;
+ let currentNetPerformancePercent =
+ currentPositions.netPerformancePercentage;
+
+ if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
+ // If algebraic sign is different, harmonize it
+ currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
+ }
+
+ if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
+ // If algebraic sign is different, harmonize it
+ currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
+ }
return {
+ errors: currentPositions.errors,
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
- annualizedPerformancePercent,
- currentGrossPerformance,
- currentGrossPerformancePercent,
- currentNetPerformance,
- currentNetPerformancePercent,
currentValue,
- isAllTimeHigh: true, // TODO
- isAllTimeLow: false // TODO
+ currentGrossPerformance: currentGrossPerformance.toNumber(),
+ currentGrossPerformancePercent:
+ currentGrossPerformancePercent.toNumber(),
+ currentNetPerformance: currentNetPerformance.toNumber(),
+ currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
}
};
}
- 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 {
const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(impersonationId, this.request.user.id);
- const { orders, transactionPoints } = await this.getTransactionPoints({
- userId
- });
+ const { orders, portfolioOrders, transactionPoints } =
+ await this.getTransactionPoints({
+ userId
+ });
if (isEmpty(orders)) {
return {
@@ -765,10 +879,12 @@ export class PortfolioService {
};
}
- const portfolioCalculator = new PortfolioCalculator(
- this.currentRateService,
- currency
- );
+ const portfolioCalculator = new PortfolioCalculator({
+ currency,
+ currentRateService: this.currentRateService,
+ orders: portfolioOrders
+ });
+
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
@@ -780,12 +896,11 @@ export class PortfolioService {
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
- const accounts = await this.getValueOfAccounts(
+ const accounts = await this.getValueOfAccounts({
orders,
portfolioItemsNow,
- currency,
userId
- );
+ });
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
@@ -831,7 +946,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(),
- this.getFees(orders)
+ this.getFees(orders).toNumber()
)
],
{ baseCurrency: currency }
@@ -841,53 +956,87 @@ export class PortfolioService {
}
public async getSummary(aImpersonationId: string): Promise {
- 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 user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance(aImpersonationId);
- const { balance } = await this.accountService.getCashDetails(
+ const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
userId,
- currency
+ currency: userCurrency
+ });
+ const orders = await this.orderService.getOrders({
+ userCurrency,
+ userId
+ });
+ const dividend = this.getDividend(orders).toNumber();
+ const emergencyFund = new Big(
+ (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
- const orders = await this.orderService.getOrders({ userId });
- const fees = this.getFees(orders);
+ const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date;
+ const items = this.getItems(orders).toNumber();
- const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
- const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
+ const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
+ const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
- const committedFunds = new Big(totalBuy).sub(totalSell);
+ const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
+ const committedFunds = new Big(totalBuy).minus(totalSell);
- const netWorth = new Big(balance)
+ const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue)
+ .plus(items)
.toNumber();
+ const daysInMarket = differenceInDays(new Date(), firstOrderDate);
+
+ const annualizedPerformancePercent = new PortfolioCalculator({
+ currency: userCurrency,
+ currentRateService: this.currentRateService,
+ orders: []
+ })
+ .getAnnualizedPerformancePercent({
+ daysInMarket,
+ netPerformancePercent: new Big(
+ performanceInformation.performance.currentNetPerformancePercent
+ )
+ })
+ ?.toNumber();
+
return {
...performanceInformation.performance,
+ annualizedPerformancePercent,
+ cash,
+ dividend,
fees,
firstOrderDate,
+ items,
netWorth,
- cash: balance,
+ totalBuy,
+ totalSell,
committedFunds: committedFunds.toNumber(),
- ordersCount: orders.length,
- totalBuy: totalBuy,
- totalSell: totalSell
+ emergencyFund: emergencyFund.toNumber(),
+ ordersCount: orders.filter((order) => {
+ return order.type === 'BUY' || order.type === 'SELL';
+ }).length
};
}
private async getCashPositions({
cashDetails,
+ emergencyFund,
investment,
userCurrency,
value
}: {
cashDetails: CashDetails;
+ emergencyFund: Big;
investment: Big;
value: Big;
userCurrency: string;
}) {
- const cashPositions = {};
+ const cashPositions: PortfolioDetails['holdings'] = {};
for (const account of cashDetails.accounts) {
const convertedBalance = this.exchangeRateDataService.toCurrency(
@@ -911,11 +1060,12 @@ export class PortfolioService {
assetSubClass: AssetClass.CASH,
countries: [],
currency: account.currency,
+ dataSource: undefined,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: convertedBalance,
marketPrice: 0,
- marketState: MarketState.open,
+ marketState: 'open',
name: account.currency,
netPerformance: 0,
netPerformancePercent: 0,
@@ -928,6 +1078,28 @@ export class PortfolioService {
}
}
+ if (emergencyFund.gt(0)) {
+ cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = {
+ ...cashPositions[userCurrency],
+ assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND,
+ investment: emergencyFund.toNumber(),
+ name: ASSET_SUB_CLASS_EMERGENCY_FUND,
+ symbol: ASSET_SUB_CLASS_EMERGENCY_FUND,
+ value: emergencyFund.toNumber()
+ };
+
+ cashPositions[userCurrency].investment = new Big(
+ cashPositions[userCurrency].investment
+ )
+ .minus(emergencyFund)
+ .toNumber();
+ cashPositions[userCurrency].value = new Big(
+ cashPositions[userCurrency].value
+ )
+ .minus(emergencyFund)
+ .toNumber();
+ }
+
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = new Big(
@@ -945,6 +1117,69 @@ export class PortfolioService {
return cashPositions;
}
+ private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
+ return orders
+ .filter((order) => {
+ // Filter out all orders before given date and type dividend
+ return (
+ isBefore(date, new Date(order.date)) &&
+ order.type === TypeOfOrder.DIVIDEND
+ );
+ })
+ .map((order) => {
+ return this.exchangeRateDataService.toCurrency(
+ new Big(order.quantity).mul(order.unitPrice).toNumber(),
+ order.SymbolProfile.currency,
+ this.request.user.Settings.currency
+ );
+ })
+ .reduce(
+ (previous, current) => new Big(previous).plus(current),
+ new Big(0)
+ );
+ }
+
+ private getFees(orders: OrderWithAccount[], date = new Date(0)) {
+ return orders
+ .filter((order) => {
+ // Filter out all orders before given date
+ return isBefore(date, new Date(order.date));
+ })
+ .map((order) => {
+ return this.exchangeRateDataService.toCurrency(
+ order.fee,
+ order.SymbolProfile.currency,
+ this.request.user.Settings.currency
+ );
+ })
+ .reduce(
+ (previous, current) => new Big(previous).plus(current),
+ new Big(0)
+ );
+ }
+
+ private getItems(orders: OrderWithAccount[], date = new Date(0)) {
+ return orders
+ .filter((order) => {
+ // Filter out all orders before given date and type item
+ return (
+ isBefore(date, new Date(order.date)) &&
+ order.type === TypeOfOrder.ITEM
+ );
+ })
+ .map((order) => {
+ return this.exchangeRateDataService.toCurrency(
+ new Big(order.quantity).mul(order.unitPrice).toNumber(),
+ order.SymbolProfile.currency,
+ this.request.user.Settings.currency
+ );
+ })
+ .reduce(
+ (previous, current) => new Big(previous).plus(current),
+ new Big(0)
+ );
+ }
+
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) {
case '1d':
@@ -964,87 +1199,117 @@ export class PortfolioService {
}
private async getTransactionPoints({
+ filters,
includeDrafts = false,
userId
}: {
+ filters?: Filter[];
includeDrafts?: boolean;
userId: string;
}): Promise<{
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
+ portfolioOrders: PortfolioOrder[];
}> {
- const orders = await this.orderService.getOrders({ includeDrafts, userId });
+ const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
+
+ const orders = await this.orderService.getOrders({
+ filters,
+ includeDrafts,
+ userCurrency,
+ userId,
+ types: ['BUY', 'SELL']
+ });
if (orders.length <= 0) {
- return { transactionPoints: [], orders: [] };
+ return { transactionPoints: [], orders: [], portfolioOrders: [] };
}
- const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
- currency: order.currency,
- dataSource: order.dataSource,
+ currency: order.SymbolProfile.currency,
+ dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
order.fee,
- order.currency,
+ order.SymbolProfile.currency,
userCurrency
)
),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
- symbol: order.symbol,
- type: order.type,
+ symbol: order.SymbolProfile.symbol,
+ type: order.type,
unitPrice: new Big(
this.exchangeRateDataService.toCurrency(
order.unitPrice,
- order.currency,
+ order.SymbolProfile.currency,
userCurrency
)
)
}));
- const portfolioCalculator = new PortfolioCalculator(
- this.currentRateService,
- userCurrency
- );
- portfolioCalculator.computeTransactionPoints(portfolioOrders);
+ const portfolioCalculator = new PortfolioCalculator({
+ currency: userCurrency,
+ currentRateService: this.currentRateService,
+ orders: portfolioOrders
+ });
+
+ portfolioCalculator.computeTransactionPoints();
+
return {
- transactionPoints: portfolioCalculator.getTransactionPoints(),
- orders
+ orders,
+ portfolioOrders,
+ transactionPoints: portfolioCalculator.getTransactionPoints()
};
}
- private async getValueOfAccounts(
- orders: OrderWithAccount[],
- portfolioItemsNow: { [p: string]: TimelinePosition },
- userCurrency: string,
- userId: string
- ) {
+ private async getValueOfAccounts({
+ filters = [],
+ orders,
+ portfolioItemsNow,
+ userId
+ }: {
+ filters?: Filter[];
+ orders: OrderWithAccount[];
+ portfolioItemsNow: { [p: string]: TimelinePosition };
+ userId: string;
+ }) {
const accounts: PortfolioDetails['accounts'] = {};
- const currentAccounts = await this.accountService.getAccounts(userId);
+ let currentAccounts = [];
+
+ if (filters.length === 0) {
+ currentAccounts = await this.accountService.getAccounts(userId);
+ } else {
+ const accountIds = uniq(
+ orders.map(({ accountId }) => {
+ return accountId;
+ })
+ );
+
+ currentAccounts = await this.accountService.accounts({
+ where: { id: { in: accountIds } }
+ });
+ }
for (const account of currentAccounts) {
const ordersByAccount = orders.filter(({ accountId }) => {
return accountId === account.id;
});
- const convertedBalance = this.exchangeRateDataService.toCurrency(
- account.balance,
- account.currency,
- userCurrency
- );
- accounts[account.name] = {
- balance: convertedBalance,
+ accounts[account.id] = {
+ balance: account.balance,
currency: account.currency,
- current: convertedBalance,
- original: convertedBalance
+ current: account.balance,
+ name: account.name,
+ original: account.balance
};
for (const order of ordersByAccount) {
let currentValueOfSymbol =
- order.quantity * portfolioItemsNow[order.symbol].marketPrice;
+ order.quantity *
+ portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice;
if (order.type === 'SELL') {
@@ -1052,16 +1317,17 @@ export class PortfolioService {
originalValueOfSymbol *= -1;
}
- if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
- accounts[order.Account?.name || UNKNOWN_KEY].current +=
+ if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
+ accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol;
- accounts[order.Account?.name || UNKNOWN_KEY].original +=
+ accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
- accounts[order.Account?.name || UNKNOWN_KEY] = {
+ accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
current: currentValueOfSymbol,
+ name: account.name,
original: originalValueOfSymbol
};
}
@@ -1093,7 +1359,7 @@ export class PortfolioService {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
- order.currency,
+ order.SymbolProfile.currency,
currency
);
})
diff --git a/apps/api/src/app/redis-cache/redis-cache.module.ts b/apps/api/src/app/redis-cache/redis-cache.module.ts
index e3275276b..dcda94041 100644
--- a/apps/api/src/app/redis-cache/redis-cache.module.ts
+++ b/apps/api/src/app/redis-cache/redis-cache.module.ts
@@ -1,3 +1,4 @@
+import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CacheModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
@@ -17,9 +18,10 @@ import { RedisCacheService } from './redis-cache.service';
store: redisStore,
ttl: configurationService.get('CACHE_TTL')
})
- })
+ }),
+ ConfigurationModule
],
- providers: [ConfigurationService, RedisCacheService],
+ providers: [RedisCacheService],
exports: [RedisCacheService]
})
export class RedisCacheModule {}
diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts
index 0eb345f63..aabc46d24 100644
--- a/apps/api/src/app/subscription/subscription.controller.ts
+++ b/apps/api/src/app/subscription/subscription.controller.ts
@@ -1,9 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
+import { PropertyService } from '@ghostfolio/api/services/property/property.service';
+import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
+import { Coupon } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
+ HttpCode,
HttpException,
Inject,
Logger,
@@ -22,16 +26,73 @@ import { SubscriptionService } from './subscription.service';
export class SubscriptionController {
public constructor(
private readonly configurationService: ConfigurationService,
+ private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly subscriptionService: SubscriptionService
) {}
+ @Post('redeem-coupon')
+ @HttpCode(StatusCodes.OK)
+ @UseGuards(AuthGuard('jwt'))
+ public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
+ if (!this.request.user) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.FORBIDDEN),
+ StatusCodes.FORBIDDEN
+ );
+ }
+
+ let coupons =
+ ((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
+ [];
+
+ const coupon = coupons.find((currentCoupon) => {
+ return currentCoupon.code === couponCode;
+ });
+
+ if (coupon === undefined) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.BAD_REQUEST),
+ StatusCodes.BAD_REQUEST
+ );
+ }
+
+ await this.subscriptionService.createSubscription({
+ duration: coupon.duration,
+ userId: this.request.user.id
+ });
+
+ // Destroy coupon
+ coupons = coupons.filter((currentCoupon) => {
+ return currentCoupon.code !== couponCode;
+ });
+ await this.propertyService.put({
+ key: PROPERTY_COUPONS,
+ value: JSON.stringify(coupons)
+ });
+
+ Logger.log(
+ `Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`,
+ 'SubscriptionController'
+ );
+
+ return {
+ message: getReasonPhrase(StatusCodes.OK),
+ statusCode: StatusCodes.OK
+ };
+ }
+
@Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) {
- await this.subscriptionService.createSubscription(
+ const userId = await this.subscriptionService.createSubscriptionViaStripe(
req.query.checkoutSessionId
);
+ Logger.log(
+ `Subscription for user '${userId}' has been created via Stripe`,
+ 'SubscriptionController'
+ );
+
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
}
@@ -47,7 +108,7 @@ export class SubscriptionController {
userId: this.request.user.id
});
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'SubscriptionController');
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
diff --git a/apps/api/src/app/subscription/subscription.module.ts b/apps/api/src/app/subscription/subscription.module.ts
index 48671550c..df0861657 100644
--- a/apps/api/src/app/subscription/subscription.module.ts
+++ b/apps/api/src/app/subscription/subscription.module.ts
@@ -1,14 +1,15 @@
-import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
-import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
+import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
+import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service';
@Module({
- imports: [],
controllers: [SubscriptionController],
- providers: [ConfigurationService, PrismaService, SubscriptionService],
- exports: [SubscriptionService]
+ exports: [SubscriptionService],
+ imports: [ConfigurationModule, PrismaModule, PropertyModule],
+ providers: [SubscriptionService]
})
export class SubscriptionModule {}
diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts
index 2d40cbcc2..5a4f75c20 100644
--- a/apps/api/src/app/subscription/subscription.service.ts
+++ b/apps/api/src/app/subscription/subscription.service.ts
@@ -3,7 +3,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
-import { addDays, isBefore } from 'date-fns';
+import { addMilliseconds, isBefore } from 'date-fns';
+import ms, { StringValue } from 'ms';
import Stripe from 'stripe';
@Injectable()
@@ -44,7 +45,7 @@ export class SubscriptionService {
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
- )}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
+ )}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
if (couponId) {
@@ -64,28 +65,40 @@ export class SubscriptionService {
};
}
- public async createSubscription(aCheckoutSessionId: string) {
+ public async createSubscription({
+ duration = '1 year',
+ userId
+ }: {
+ duration?: StringValue;
+ userId: string;
+ }) {
+ await this.prismaService.subscription.create({
+ data: {
+ expiresAt: addMilliseconds(new Date(), ms(duration)),
+ User: {
+ connect: {
+ id: userId
+ }
+ }
+ }
+ });
+ }
+
+ public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try {
const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId
);
- await this.prismaService.subscription.create({
- data: {
- expiresAt: addDays(new Date(), 365),
- User: {
- connect: {
- id: session.client_reference_id
- }
- }
- }
- });
+ await this.createSubscription({ userId: session.client_reference_id });
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
});
+
+ return session.client_reference_id;
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'SubscriptionService');
}
}
diff --git a/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts b/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts
index 787547901..51ed38d4d 100644
--- a/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts
+++ b/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts
@@ -1,4 +1,4 @@
-import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
+import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
export interface SymbolItem {
diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts
index a364de6bc..5b3c0f030 100644
--- a/apps/api/src/app/symbol/symbol.controller.ts
+++ b/apps/api/src/app/symbol/symbol.controller.ts
@@ -1,20 +1,19 @@
-import type { RequestWithUser } from '@ghostfolio/common/types';
+import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
+import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
+import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import {
Controller,
- DefaultValuePipe,
Get,
HttpException,
- Inject,
Param,
- ParseBoolPipe,
Query,
- UseGuards
+ UseGuards,
+ UseInterceptors
} from '@nestjs/common';
-import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
-import { isEmpty } from 'lodash';
+import { isDate, isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
@@ -22,22 +21,19 @@ import { SymbolService } from './symbol.service';
@Controller('symbol')
export class SymbolController {
- public constructor(
- private readonly symbolService: SymbolService,
- @Inject(REQUEST) private readonly request: RequestWithUser
- ) {}
+ public constructor(private readonly symbolService: SymbolService) {}
/**
* Must be before /:symbol
*/
@Get('lookup')
@UseGuards(AuthGuard('jwt'))
+ @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol(
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try {
- const encodedQuery = encodeURIComponent(query.toLowerCase());
- return this.symbolService.lookup(encodedQuery);
+ return this.symbolService.lookup(query.toLowerCase());
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
@@ -51,11 +47,12 @@ export class SymbolController {
*/
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
+ @UseInterceptors(TransformDataSourceInRequestInterceptor)
+ @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string,
- @Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
- includeHistoricalData: boolean
+ @Query('includeHistoricalData') includeHistoricalData?: number
): Promise {
if (!DataSource[dataSource]) {
throw new HttpException(
@@ -78,4 +75,27 @@ export class SymbolController {
return result;
}
+
+ @Get(':dataSource/:symbol/:dateString')
+ @UseGuards(AuthGuard('jwt'))
+ public async gatherSymbolForDate(
+ @Param('dataSource') dataSource: DataSource,
+ @Param('dateString') dateString: string,
+ @Param('symbol') symbol: string
+ ): Promise {
+ 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
+ });
+ }
}
diff --git a/apps/api/src/app/symbol/symbol.module.ts b/apps/api/src/app/symbol/symbol.module.ts
index c5143632b..2b47334a6 100644
--- a/apps/api/src/app/symbol/symbol.module.ts
+++ b/apps/api/src/app/symbol/symbol.module.ts
@@ -8,13 +8,14 @@ import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service';
@Module({
+ controllers: [SymbolController],
+ exports: [SymbolService],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
],
- controllers: [SymbolController],
providers: [SymbolService]
})
export class SymbolModule {}
diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts
index 3f377c551..6cfcbc209 100644
--- a/apps/api/src/app/symbol/symbol.service.ts
+++ b/apps/api/src/app/symbol/symbol.service.ts
@@ -1,11 +1,14 @@
-import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
-import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
+import {
+ 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 { DATE_FORMAT } from '@ghostfolio/common/helper';
+import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
-import { subDays } from 'date-fns';
+import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
@@ -14,35 +17,36 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService {
public constructor(
private readonly dataProviderService: DataProviderService,
- private readonly marketDataService: MarketDataService,
- private readonly prismaService: PrismaService
+ private readonly marketDataService: MarketDataService
) {}
public async get({
dataGatheringItem,
- includeHistoricalData = false
+ includeHistoricalData
}: {
dataGatheringItem: IDataGatheringItem;
- includeHistoricalData?: boolean;
+ includeHistoricalData?: number;
}): Promise {
- const response = await this.dataProviderService.get([dataGatheringItem]);
- const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
+ const quotes = await this.dataProviderService.getQuotes([
+ dataGatheringItem
+ ]);
+ const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) {
- let historicalData: HistoricalDataItem[];
+ let historicalData: HistoricalDataItem[] = [];
- if (includeHistoricalData) {
- const days = 7;
+ if (includeHistoricalData > 0) {
+ const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
symbols: [dataGatheringItem.symbol]
});
- historicalData = marketData.map(({ date, marketPrice }) => {
+ historicalData = marketData.map(({ date, marketPrice: value }) => {
return {
- date: date.toISOString(),
- value: marketPrice
+ value,
+ date: date.toISOString()
};
});
}
@@ -58,6 +62,27 @@ export class SymbolService {
return undefined;
}
+ public async getForDate({
+ dataSource,
+ date,
+ symbol
+ }: {
+ dataSource: DataSource;
+ date: Date;
+ symbol: string;
+ }): Promise {
+ const historicalData = await this.dataProviderService.getHistoricalRaw(
+ [{ dataSource, symbol }],
+ date,
+ date
+ );
+
+ return {
+ marketPrice:
+ historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
+ };
+ }
+
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] };
@@ -68,35 +93,9 @@ export class SymbolService {
try {
const { items } = await this.dataProviderService.search(aQuery);
results.items = items;
-
- // Add custom symbols
- const ghostfolioSymbolProfiles =
- await this.prismaService.symbolProfile.findMany({
- select: {
- currency: true,
- dataSource: true,
- name: true,
- symbol: true
- },
- where: {
- AND: [
- {
- dataSource: DataSource.GHOSTFOLIO,
- name: {
- startsWith: aQuery
- }
- }
- ]
- }
- });
-
- for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
- results.items.push(ghostfolioSymbolProfile);
- }
-
return results;
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'SymbolService');
throw error;
}
diff --git a/apps/api/src/app/user/interfaces/access.interface.ts b/apps/api/src/app/user/interfaces/access.interface.ts
deleted file mode 100644
index 33682b0cc..000000000
--- a/apps/api/src/app/user/interfaces/access.interface.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export interface Access {
- alias?: string;
- id: string;
-}
diff --git a/apps/api/src/app/user/interfaces/user-item.interface.ts b/apps/api/src/app/user/interfaces/user-item.interface.ts
index 338888c15..32230b69e 100644
--- a/apps/api/src/app/user/interfaces/user-item.interface.ts
+++ b/apps/api/src/app/user/interfaces/user-item.interface.ts
@@ -1,4 +1,7 @@
+import { Role } from '@prisma/client';
+
export interface UserItem {
accessToken?: string;
authToken: string;
+ role: Role;
}
diff --git a/apps/api/src/app/user/interfaces/user-settings.interface.ts b/apps/api/src/app/user/interfaces/user-settings.interface.ts
index 7870abee9..8f8878079 100644
--- a/apps/api/src/app/user/interfaces/user-settings.interface.ts
+++ b/apps/api/src/app/user/interfaces/user-settings.interface.ts
@@ -1,3 +1,5 @@
export interface UserSettings {
+ emergencyFund?: number;
+ locale?: string;
isRestrictedView?: boolean;
}
diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts
index a6a583138..b97dd287e 100644
--- a/apps/api/src/app/user/update-user-setting.dto.ts
+++ b/apps/api/src/app/user/update-user-setting.dto.ts
@@ -1,6 +1,19 @@
-import { IsBoolean } from 'class-validator';
+import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpdateUserSettingDto {
+ @IsNumber()
+ @IsOptional()
+ emergencyFund?: number;
+
@IsBoolean()
+ @IsOptional()
isRestrictedView?: boolean;
+
+ @IsString()
+ @IsOptional()
+ locale?: string;
+
+ @IsNumber()
+ @IsOptional()
+ savingsRate?: number;
}
diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts
index f62599dfd..e79f61dd4 100644
--- a/apps/api/src/app/user/user.controller.ts
+++ b/apps/api/src/app/user/user.controller.ts
@@ -1,15 +1,15 @@
+import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
+import { PropertyService } from '@ghostfolio/api/services/property/property.service';
+import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
-import {
- getPermissions,
- hasPermission,
- permissions
-} from '@ghostfolio/common/permissions';
+import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
+ Headers,
HttpException,
Inject,
Param,
@@ -20,7 +20,6 @@ import {
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
-import { Provider } from '@prisma/client';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@@ -34,7 +33,9 @@ import { UserService } from './user.service';
@Controller('user')
export class UserController {
public constructor(
- private jwtService: JwtService,
+ private readonly configurationService: ConfigurationService,
+ private readonly jwtService: JwtService,
+ private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@@ -43,10 +44,7 @@ export class UserController {
@UseGuards(AuthGuard('jwt'))
public async deleteUser(@Param('id') id: string): Promise {
if (
- !hasPermission(
- getPermissions(this.request.user.role),
- permissions.deleteUser
- ) ||
+ !hasPermission(this.request.user.permissions, permissions.deleteUser) ||
id === this.request.user.id
) {
throw new HttpException(
@@ -62,18 +60,39 @@ export class UserController {
@Get()
@UseGuards(AuthGuard('jwt'))
- public async getUser(@Param('id') id: string): Promise {
- return this.userService.getUser(this.request.user);
+ public async getUser(
+ @Headers('accept-language') acceptLanguage: string
+ ): Promise {
+ return this.userService.getUser(
+ this.request.user,
+ acceptLanguage?.split(',')?.[0]
+ );
}
@Post()
public async signupUser(): Promise {
- const { accessToken, id } = await this.userService.createUser({
- provider: Provider.ANONYMOUS
+ if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
+ const isReadOnlyMode = (await this.propertyService.getByKey(
+ PROPERTY_IS_READ_ONLY_MODE
+ )) as boolean;
+
+ if (isReadOnlyMode) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.FORBIDDEN),
+ StatusCodes.FORBIDDEN
+ );
+ }
+ }
+
+ const hasAdmin = await this.userService.hasAdmin();
+
+ const { accessToken, id, role } = await this.userService.createUser({
+ role: hasAdmin ? 'USER' : 'ADMIN'
});
return {
accessToken,
+ role,
authToken: this.jwtService.sign({
id
})
@@ -85,7 +104,7 @@ export class UserController {
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if (
!hasPermission(
- getPermissions(this.request.user.role),
+ this.request.user.permissions,
permissions.updateUserSettings
)
) {
@@ -100,6 +119,12 @@ export class UserController {
...data
};
+ for (const key in userSettings) {
+ if (userSettings[key] === false || userSettings[key] === null) {
+ delete userSettings[key];
+ }
+ }
+
return await this.userService.updateUserSetting({
userSettings,
userId: this.request.user.id
@@ -111,7 +136,7 @@ export class UserController {
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
if (
!hasPermission(
- getPermissions(this.request.user.role),
+ this.request.user.permissions,
permissions.updateUserSettings
)
) {
@@ -127,10 +152,7 @@ export class UserController {
};
if (
- hasPermission(
- getPermissions(this.request.user.role),
- permissions.updateViewMode
- )
+ hasPermission(this.request.user.permissions, permissions.updateViewMode)
) {
userSettings.viewMode = data.viewMode;
}
diff --git a/apps/api/src/app/user/user.module.ts b/apps/api/src/app/user/user.module.ts
index 7d2fc3d8e..6a705524f 100644
--- a/apps/api/src/app/user/user.module.ts
+++ b/apps/api/src/app/user/user.module.ts
@@ -1,6 +1,8 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
-import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
-import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
+import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
+import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
+import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@@ -8,15 +10,19 @@ import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
+ controllers: [UserController],
+ exports: [UserService],
imports: [
+ ConfigurationModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
}),
- SubscriptionModule
+ PrismaModule,
+ PropertyModule,
+ SubscriptionModule,
+ TagModule
],
- controllers: [UserController],
- providers: [ConfigurationService, PrismaService, UserService],
- exports: [UserService]
+ providers: [UserService]
})
export class UserModule {}
diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts
index a513cbba6..3fd6f8e1d 100644
--- a/apps/api/src/app/user/user.service.ts
+++ b/apps/api/src/app/user/user.service.ts
@@ -1,12 +1,21 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
-import { baseCurrency, locale } from '@ghostfolio/common/config';
+import { PropertyService } from '@ghostfolio/api/services/property/property.service';
+import { TagService } from '@ghostfolio/api/services/tag/tag.service';
+import {
+ PROPERTY_IS_READ_ONLY_MODE,
+ baseCurrency,
+ locale
+} from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
-import { getPermissions, permissions } from '@ghostfolio/common/permissions';
-import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
+import {
+ getPermissions,
+ hasRole,
+ permissions
+} from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
-import { Prisma, Provider, User, ViewMode } from '@prisma/client';
+import { Prisma, Role, User, ViewMode } from '@prisma/client';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
@@ -20,17 +29,22 @@ export class UserService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
- private readonly subscriptionService: SubscriptionService
+ private readonly propertyService: PropertyService,
+ private readonly subscriptionService: SubscriptionService,
+ private readonly tagService: TagService
) {}
- public async getUser({
- Account,
- alias,
- id,
- permissions,
- Settings,
- subscription
- }: UserWithSettings): Promise {
+ public async getUser(
+ {
+ Account,
+ alias,
+ id,
+ permissions,
+ Settings,
+ subscription
+ }: UserWithSettings,
+ aLocale = locale
+ ): Promise {
const access = await this.prismaService.access.findMany({
include: {
User: true
@@ -38,12 +52,21 @@ export class UserService {
orderBy: { User: { alias: 'asc' } },
where: { GranteeUser: { id } }
});
+ let tags = await this.tagService.getByUser(id);
+
+ if (
+ this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
+ subscription.type === 'Basic'
+ ) {
+ tags = [];
+ }
return {
alias,
id,
permissions,
subscription,
+ tags,
access: access.map((accessItem) => {
return {
alias: accessItem.User.alias,
@@ -53,13 +76,25 @@ export class UserService {
accounts: Account,
settings: {
...(Settings.settings),
- locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
+ locale: (Settings.settings)?.locale ?? aLocale,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
}
};
}
+ public async hasAdmin() {
+ const usersWithAdminRole = await this.users({
+ where: {
+ role: {
+ equals: 'ADMIN'
+ }
+ }
+ });
+
+ return usersWithAdminRole.length > 0;
+ }
+
public isRestrictedView(aUser: UserWithSettings) {
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
}
@@ -67,50 +102,91 @@ export class UserService {
public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise {
- const userFromDatabase = await this.prismaService.user.findUnique({
+ const {
+ accessToken,
+ Account,
+ alias,
+ authChallenge,
+ createdAt,
+ id,
+ provider,
+ role,
+ Settings,
+ Subscription,
+ thirdPartyId,
+ updatedAt
+ } = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput
});
- const user: UserWithSettings = userFromDatabase;
-
- const currentPermissions = getPermissions(userFromDatabase.role);
-
- if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
- currentPermissions.push(permissions.accessFearAndGreedIndex);
- }
-
- user.permissions = currentPermissions;
+ const user: UserWithSettings = {
+ accessToken,
+ Account,
+ alias,
+ authChallenge,
+ createdAt,
+ id,
+ provider,
+ role,
+ Settings,
+ thirdPartyId,
+ updatedAt
+ };
- if (userFromDatabase?.Settings) {
- if (!userFromDatabase.Settings.currency) {
+ if (user?.Settings) {
+ if (!user.Settings.currency) {
// Set default currency if needed
- userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
+ user.Settings.currency = UserService.DEFAULT_CURRENCY;
}
- } else if (userFromDatabase) {
+ } else if (user) {
// Set default settings if needed
- userFromDatabase.Settings = {
+ user.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
- userId: userFromDatabase?.id,
+ userId: user?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
- user.subscription = this.subscriptionService.getSubscription(
- userFromDatabase?.Subscription
- );
+ user.subscription =
+ this.subscriptionService.getSubscription(Subscription);
+ }
+
+ let currentPermissions = getPermissions(user.role);
+
+ if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
+ currentPermissions.push(permissions.accessFearAndGreedIndex);
+ }
- if (user.subscription.type === SubscriptionType.Basic) {
- user.permissions = user.permissions.filter((permission) => {
- return permission !== permissions.updateViewMode;
+ if (user.subscription?.type === 'Premium') {
+ currentPermissions.push(permissions.reportDataGlitch);
+ }
+
+ 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.Settings.viewMode = ViewMode.ZEN;
}
}
+ user.permissions = currentPermissions.sort();
+
return user;
}
@@ -138,7 +214,11 @@ export class UserService {
return hash.digest('hex');
}
- public async createUser(data?: Prisma.UserCreateInput): Promise {
+ public async createUser(data: Prisma.UserCreateInput): Promise {
+ if (!data?.provider) {
+ data.provider = 'ANONYMOUS';
+ }
+
let user = await this.prismaService.user.create({
data: {
...data,
@@ -157,7 +237,7 @@ export class UserService {
}
});
- if (data.provider === Provider.ANONYMOUS) {
+ if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(
user.id,
this.getRandomString(10)
diff --git a/apps/api/src/assets/countries/developed-markets.json b/apps/api/src/assets/countries/developed-markets.json
new file mode 100644
index 000000000..5e281d475
--- /dev/null
+++ b/apps/api/src/assets/countries/developed-markets.json
@@ -0,0 +1,26 @@
+[
+ "AT",
+ "AU",
+ "BE",
+ "CA",
+ "CH",
+ "DE",
+ "DK",
+ "ES",
+ "FI",
+ "FR",
+ "GB",
+ "HK",
+ "IE",
+ "IL",
+ "IT",
+ "JP",
+ "LU",
+ "NL",
+ "NO",
+ "NZ",
+ "PT",
+ "SE",
+ "SG",
+ "US"
+]
diff --git a/apps/api/src/assets/countries/emerging-markets.json b/apps/api/src/assets/countries/emerging-markets.json
new file mode 100644
index 000000000..328187964
--- /dev/null
+++ b/apps/api/src/assets/countries/emerging-markets.json
@@ -0,0 +1,28 @@
+[
+ "AE",
+ "BR",
+ "CL",
+ "CN",
+ "CO",
+ "CY",
+ "CZ",
+ "EG",
+ "GR",
+ "HK",
+ "HU",
+ "ID",
+ "IN",
+ "KR",
+ "KW",
+ "MX",
+ "MY",
+ "PE",
+ "PH",
+ "PL",
+ "QA",
+ "SA",
+ "TH",
+ "TR",
+ "TW",
+ "ZA"
+]
diff --git a/apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts
new file mode 100644
index 000000000..aa9952473
--- /dev/null
+++ b/apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts
@@ -0,0 +1,39 @@
+import { decodeDataSource } from '@ghostfolio/common/helper';
+import {
+ CallHandler,
+ ExecutionContext,
+ Injectable,
+ NestInterceptor
+} from '@nestjs/common';
+import { Observable } from 'rxjs';
+
+import { ConfigurationService } from '../services/configuration.service';
+
+@Injectable()
+export class TransformDataSourceInRequestInterceptor
+ implements NestInterceptor
+{
+ public constructor(
+ private readonly configurationService: ConfigurationService
+ ) {}
+
+ public intercept(
+ context: ExecutionContext,
+ next: CallHandler
+ ): Observable {
+ const http = context.switchToHttp();
+ const request = http.getRequest();
+
+ if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
+ if (request.body.dataSource) {
+ request.body.dataSource = decodeDataSource(request.body.dataSource);
+ }
+
+ if (request.params.dataSource) {
+ request.params.dataSource = decodeDataSource(request.params.dataSource);
+ }
+ }
+
+ return next.handle();
+ }
+}
diff --git a/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts
new file mode 100644
index 000000000..6c96b3965
--- /dev/null
+++ b/apps/api/src/interceptors/transform-data-source-in-response.interceptor.ts
@@ -0,0 +1,86 @@
+import { encodeDataSource } from '@ghostfolio/common/helper';
+import {
+ CallHandler,
+ ExecutionContext,
+ Injectable,
+ NestInterceptor
+} from '@nestjs/common';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { ConfigurationService } from '../services/configuration.service';
+
+@Injectable()
+export class TransformDataSourceInResponseInterceptor
+ implements NestInterceptor
+{
+ public constructor(
+ private readonly configurationService: ConfigurationService
+ ) {}
+
+ public intercept(
+ context: ExecutionContext,
+ next: CallHandler
+ ): Observable {
+ return next.handle().pipe(
+ map((data: any) => {
+ if (
+ this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
+ ) {
+ if (data.activities) {
+ data.activities.map((activity) => {
+ activity.SymbolProfile.dataSource = encodeDataSource(
+ activity.SymbolProfile.dataSource
+ );
+ return activity;
+ });
+ }
+
+ if (data.dataSource) {
+ data.dataSource = encodeDataSource(data.dataSource);
+ }
+
+ if (data.errors) {
+ for (const error of data.errors) {
+ if (error.dataSource) {
+ error.dataSource = encodeDataSource(error.dataSource);
+ }
+ }
+ }
+
+ if (data.holdings) {
+ for (const symbol of Object.keys(data.holdings)) {
+ if (data.holdings[symbol].dataSource) {
+ data.holdings[symbol].dataSource = encodeDataSource(
+ data.holdings[symbol].dataSource
+ );
+ }
+ }
+ }
+
+ if (data.items) {
+ data.items.map((item) => {
+ item.dataSource = encodeDataSource(item.dataSource);
+ return item;
+ });
+ }
+
+ if (data.positions) {
+ data.positions.map((position) => {
+ position.dataSource = encodeDataSource(position.dataSource);
+ return position;
+ });
+ }
+
+ if (data.SymbolProfile) {
+ data.SymbolProfile.dataSource = encodeDataSource(
+ data.SymbolProfile.dataSource
+ );
+ }
+ }
+
+ return data;
+ })
+ );
+ }
+}
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
index 5a077910e..cf565a36d 100644
--- a/apps/api/src/main.ts
+++ b/apps/api/src/main.ts
@@ -1,4 +1,4 @@
-import { Logger, ValidationPipe } from '@nestjs/common';
+import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
@@ -7,8 +7,11 @@ import { environment } from './environments/environment';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
- const globalPrefix = 'api';
- app.setGlobalPrefix(globalPrefix);
+ app.enableVersioning({
+ defaultVersion: '1',
+ type: VersioningType.URI
+ });
+ app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
forbidNonWhitelisted: true,
diff --git a/apps/api/src/models/order-type.ts b/apps/api/src/models/order-type.ts
deleted file mode 100644
index 4d7a425c3..000000000
--- a/apps/api/src/models/order-type.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export enum OrderType {
- CorporateAction = 'CORPORATE_ACTION',
- Bonus = 'BONUS',
- Buy = 'BUY',
- Dividend = 'DIVIDEND',
- Sell = 'SELL',
- Split = 'SPLIT'
-}
diff --git a/apps/api/src/models/order.ts b/apps/api/src/models/order.ts
index 48928bc66..8882ebe37 100644
--- a/apps/api/src/models/order.ts
+++ b/apps/api/src/models/order.ts
@@ -1,8 +1,7 @@
-import { Account, SymbolProfile } from '@prisma/client';
+import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
-import { OrderType } from './order-type';
export class Order {
private account: Account;
@@ -15,7 +14,7 @@ export class Order {
private symbol: string;
private symbolProfile: SymbolProfile;
private total: number;
- private type: OrderType;
+ private type: TypeOfOrder;
private unitPrice: number;
public constructor(data: IOrder) {
diff --git a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts
index bff51aabe..3893efd44 100644
--- a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts
+++ b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts
@@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule {
};
} = {};
- for (const account of Object.keys(this.accounts)) {
- accounts[account] = {
- name: account,
- investment: this.accounts[account].current
+ for (const [accountId, account] of Object.entries(this.accounts)) {
+ accounts[accountId] = {
+ name: account.name,
+ investment: account.current
};
}
let maxItem;
let totalInvestment = 0;
- Object.values(accounts).forEach((account) => {
+ for (const account of Object.values(accounts)) {
if (!maxItem) {
maxItem = account;
}
@@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule {
if (account.investment > maxItem?.investment) {
maxItem = account;
}
- });
+ }
const maxInvestmentRatio = maxItem.investment / totalInvestment;
diff --git a/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts b/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts
index 13da575dd..7aa363c73 100644
--- a/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts
+++ b/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts
@@ -1,10 +1,10 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
+import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import {
PortfolioDetails,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
-import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
@@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule {
}
public evaluate(ruleSettings?: Settings) {
- const platforms: {
+ const accounts: {
[symbol: string]: Pick & {
investment: number;
};
} = {};
- for (const account of Object.keys(this.accounts)) {
- platforms[account] = {
- name: account,
- investment: this.accounts[account].original
+ for (const [accountId, account] of Object.entries(this.accounts)) {
+ accounts[accountId] = {
+ name: account.name,
+ investment: account.original
};
}
let maxItem;
let totalInvestment = 0;
- Object.values(platforms).forEach((platform) => {
+ for (const account of Object.values(accounts)) {
if (!maxItem) {
- maxItem = platform;
+ maxItem = account;
}
// Calculate total investment
- totalInvestment += platform.investment;
+ totalInvestment += account.investment;
// Find maximum
- if (platform.investment > maxItem?.investment) {
- maxItem = platform;
+ if (account.investment > maxItem?.investment) {
+ maxItem = account;
}
- });
+ }
const maxInvestmentRatio = maxItem.investment / totalInvestment;
diff --git a/apps/api/src/models/rules/account-cluster-risk/single-account.ts b/apps/api/src/models/rules/account-cluster-risk/single-account.ts
index 90d93c1e3..41988ee68 100644
--- a/apps/api/src/models/rules/account-cluster-risk/single-account.ts
+++ b/apps/api/src/models/rules/account-cluster-risk/single-account.ts
@@ -1,7 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
+import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
-import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-initial-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-initial-investment.ts
index e98f4f652..ed7242d09 100644
--- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-initial-investment.ts
+++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-initial-investment.ts
@@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
-import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
+import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule';
diff --git a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
index 6d001ec09..c8e3c30eb 100644
--- a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
+++ b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
@@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
-import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
+import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule';
diff --git a/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts
index 541728d03..95e3b4b76 100644
--- a/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts
+++ b/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts
@@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
-import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
+import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule';
diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
index 105a9f199..f0ba72932 100644
--- a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
+++ b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
@@ -1,6 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
-import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
+import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule';
diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts
index d671f6f40..abbfa6641 100644
--- a/apps/api/src/services/configuration.service.ts
+++ b/apps/api/src/services/configuration.service.ts
@@ -13,18 +13,25 @@ export class ConfigurationService {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }),
+ DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: true }),
+ ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
+ ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }),
+ GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
+ GOOGLE_SHEETS_ID: str({ default: '' }),
+ GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
JWT_SECRET_KEY: str({}),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
+ MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }),
@@ -32,6 +39,10 @@ export class ConfigurationService {
ROOT_URL: str({ default: 'http://localhost:4200' }),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
+ TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
+ TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
+ TWITTER_API_KEY: str({ default: 'dummyApiKey' }),
+ TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
});
}
diff --git a/apps/api/src/services/cron.service.ts b/apps/api/src/services/cron.service.ts
index be29d5a7b..40051b9ce 100644
--- a/apps/api/src/services/cron.service.ts
+++ b/apps/api/src/services/cron.service.ts
@@ -1,14 +1,24 @@
+import {
+ DATA_GATHERING_QUEUE,
+ GATHER_ASSET_PROFILE_PROCESS
+} from '@ghostfolio/common/config';
+import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
+import { Queue } from 'bull';
import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service';
+import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable()
export class CronService {
public constructor(
+ @InjectQueue(DATA_GATHERING_QUEUE)
+ private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService,
- private readonly exchangeRateDataService: ExchangeRateDataService
+ private readonly exchangeRateDataService: ExchangeRateDataService,
+ private readonly twitterBotService: TwitterBotService
) {}
@Cron(CronExpression.EVERY_MINUTE)
@@ -21,8 +31,20 @@ export class CronService {
await this.exchangeRateDataService.loadCurrencies();
}
+ @Cron(CronExpression.EVERY_DAY_AT_5PM)
+ public async runEveryDayAtFivePM() {
+ this.twitterBotService.tweetFearAndGreedIndex();
+ }
+
@Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() {
- await this.dataGatheringService.gatherProfileData();
+ const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
+
+ for (const { dataSource, symbol } of uniqueAssets) {
+ await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
+ dataSource,
+ symbol
+ });
+ }
}
}
diff --git a/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts b/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts
index 1ae645208..d5b9fec5b 100644
--- a/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts
+++ b/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts
@@ -10,7 +10,7 @@ export class CryptocurrencyService {
public constructor() {}
- public isCrypto(aSymbol = '') {
+ public isCryptocurrency(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
}
diff --git a/apps/api/src/services/cryptocurrency/custom-cryptocurrencies.json b/apps/api/src/services/cryptocurrency/custom-cryptocurrencies.json
index 949b455db..71eab866d 100644
--- a/apps/api/src/services/cryptocurrency/custom-cryptocurrencies.json
+++ b/apps/api/src/services/cryptocurrency/custom-cryptocurrencies.json
@@ -1,7 +1,14 @@
{
"1INCH": "1inch",
"ALGO": "Algorand",
+ "ATOM": "Cosmos",
"AVAX": "Avalanche",
+ "DOT": "Polkadot",
+ "LUNA1": "Terra",
"MATIC": "Polygon",
- "SHIB": "Shiba Inu"
+ "MINA": "Mina Protocol",
+ "RUNE": "THORChain",
+ "SHIB": "Shiba Inu",
+ "SOL": "Solana",
+ "UNI3": "Uniswap"
}
diff --git a/apps/api/src/services/data-gathering.module.ts b/apps/api/src/services/data-gathering.module.ts
index f458e3bda..e8e98058c 100644
--- a/apps/api/src/services/data-gathering.module.ts
+++ b/apps/api/src/services/data-gathering.module.ts
@@ -3,13 +3,19 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
+import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
+import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
+import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { SymbolProfileModule } from './symbol-profile.module';
@Module({
imports: [
+ BullModule.registerQueue({
+ name: DATA_GATHERING_QUEUE
+ }),
ConfigurationModule,
DataEnhancerModule,
DataProviderModule,
@@ -17,7 +23,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
PrismaModule,
SymbolProfileModule
],
- providers: [DataGatheringService],
- exports: [DataEnhancerModule, DataGatheringService]
+ providers: [DataGatheringProcessor, DataGatheringService],
+ exports: [BullModule, DataEnhancerModule, DataGatheringService]
})
export class DataGatheringModule {}
diff --git a/apps/api/src/services/data-gathering.processor.ts b/apps/api/src/services/data-gathering.processor.ts
new file mode 100644
index 000000000..de8d8eb4e
--- /dev/null
+++ b/apps/api/src/services/data-gathering.processor.ts
@@ -0,0 +1,27 @@
+import {
+ DATA_GATHERING_QUEUE,
+ GATHER_ASSET_PROFILE_PROCESS
+} from '@ghostfolio/common/config';
+import { UniqueAsset } from '@ghostfolio/common/interfaces';
+import { Process, Processor } from '@nestjs/bull';
+import { Injectable, Logger } from '@nestjs/common';
+import { Job } from 'bull';
+
+import { DataGatheringService } from './data-gathering.service';
+
+@Injectable()
+@Processor(DATA_GATHERING_QUEUE)
+export class DataGatheringProcessor {
+ public constructor(
+ private readonly dataGatheringService: DataGatheringService
+ ) {}
+
+ @Process(GATHER_ASSET_PROFILE_PROCESS)
+ public async gatherAssetProfile(job: Job) {
+ try {
+ await this.dataGatheringService.gatherAssetProfiles([job.data]);
+ } catch (error) {
+ Logger.error(error, 'DataGatheringProcessor');
+ }
+ }
+}
diff --git a/apps/api/src/services/data-gathering.service.ts b/apps/api/src/services/data-gathering.service.ts
index 48ce87e02..61adaa19e 100644
--- a/apps/api/src/services/data-gathering.service.ts
+++ b/apps/api/src/services/data-gathering.service.ts
@@ -1,10 +1,10 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
PROPERTY_LAST_DATA_GATHERING,
- PROPERTY_LOCKED_DATA_GATHERING,
- ghostfolioFearAndGreedIndexSymbol
+ PROPERTY_LOCKED_DATA_GATHERING
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
+import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import {
@@ -17,7 +17,6 @@ import {
subDays
} from 'date-fns';
-import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service';
@@ -29,7 +28,6 @@ export class DataGatheringService {
private dataGatheringProgress: number;
public constructor(
- private readonly configurationService: ConfigurationService,
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[],
private readonly dataProviderService: DataProviderService,
@@ -42,7 +40,7 @@ export class DataGatheringService {
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
if (isDataGatheringNeeded) {
- Logger.log('7d data gathering has been started.');
+ Logger.log('7d data gathering has been started.', 'DataGatheringService');
console.time('data-gathering-7d');
await this.prismaService.property.create({
@@ -66,7 +64,7 @@ export class DataGatheringService {
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'DataGatheringService');
}
await this.prismaService.property.delete({
@@ -75,7 +73,10 @@ export class DataGatheringService {
}
});
- Logger.log('7d data gathering has been completed.');
+ Logger.log(
+ '7d data gathering has been completed.',
+ 'DataGatheringService'
+ );
console.timeEnd('data-gathering-7d');
}
}
@@ -86,7 +87,10 @@ export class DataGatheringService {
});
if (!isDataGatheringLocked) {
- Logger.log('Max data gathering has been started.');
+ Logger.log(
+ 'Max data gathering has been started.',
+ 'DataGatheringService'
+ );
console.time('data-gathering-max');
await this.prismaService.property.create({
@@ -110,7 +114,7 @@ export class DataGatheringService {
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'DataGatheringService');
}
await this.prismaService.property.delete({
@@ -119,24 +123,24 @@ export class DataGatheringService {
}
});
- Logger.log('Max data gathering has been completed.');
+ Logger.log(
+ 'Max data gathering has been completed.',
+ 'DataGatheringService'
+ );
console.timeEnd('data-gathering-max');
}
}
- public async gatherSymbol({
- dataSource,
- symbol
- }: {
- dataSource: DataSource;
- symbol: string;
- }) {
+ public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
if (!isDataGatheringLocked) {
- Logger.log(`Symbol data gathering for ${symbol} has been started.`);
+ Logger.log(
+ `Symbol data gathering for ${symbol} has been started.`,
+ 'DataGatheringService'
+ );
console.time('data-gathering-symbol');
await this.prismaService.property.create({
@@ -167,7 +171,7 @@ export class DataGatheringService {
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'DataGatheringService');
}
await this.prismaService.property.delete({
@@ -176,41 +180,96 @@ export class DataGatheringService {
}
});
- Logger.log(`Symbol data gathering for ${symbol} has been completed.`);
+ Logger.log(
+ `Symbol data gathering for ${symbol} has been completed.`,
+ 'DataGatheringService'
+ );
console.timeEnd('data-gathering-symbol');
}
}
- public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
- Logger.log('Profile data gathering has been started.');
- console.time('data-gathering-profile');
+ public async gatherSymbolForDate({
+ dataSource,
+ date,
+ symbol
+ }: {
+ dataSource: DataSource;
+ date: Date;
+ symbol: string;
+ }) {
+ try {
+ const historicalData = await this.dataProviderService.getHistoricalRaw(
+ [{ dataSource, symbol }],
+ date,
+ date
+ );
+
+ const marketPrice =
+ historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
+
+ if (marketPrice) {
+ return await this.prismaService.marketData.upsert({
+ create: {
+ dataSource,
+ date,
+ marketPrice,
+ symbol
+ },
+ update: { marketPrice },
+ where: { date_symbol: { date, symbol } }
+ });
+ }
+ } catch (error) {
+ Logger.error(error, 'DataGatheringService');
+ } finally {
+ return undefined;
+ }
+ }
- let dataGatheringItems = aDataGatheringItems;
+ public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
+ let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
+ return dataGatheringItem.dataSource !== 'MANUAL';
+ });
- if (!dataGatheringItems) {
- dataGatheringItems = await this.getSymbolsProfileData();
+ if (!uniqueAssets) {
+ uniqueAssets = await this.getUniqueAssets();
}
- const currentData = await this.dataProviderService.get(dataGatheringItems);
+ Logger.log(
+ `Asset profile data gathering has been started for ${uniqueAssets
+ .map(({ dataSource, symbol }) => {
+ return `${symbol} (${dataSource})`;
+ })
+ .join(',')}.`,
+ 'DataGatheringService'
+ );
+
+ const assetProfiles = await this.dataProviderService.getAssetProfiles(
+ uniqueAssets
+ );
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
- dataGatheringItems.map(({ symbol }) => {
+ uniqueAssets.map(({ symbol }) => {
return symbol;
})
);
- for (const [symbol, response] of Object.entries(currentData)) {
+ for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.symbolMapping;
for (const dataEnhancer of this.dataEnhancers) {
try {
- currentData[symbol] = await dataEnhancer.enhance({
- response,
- symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
+ assetProfiles[symbol] = await dataEnhancer.enhance({
+ response: assetProfile,
+ symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
});
} catch (error) {
- Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
+ Logger.error(
+ `Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`,
+ error,
+ 'DataGatheringService'
+ );
}
}
@@ -221,8 +280,9 @@ export class DataGatheringService {
currency,
dataSource,
name,
- sectors
- } = currentData[symbol];
+ sectors,
+ url
+ } = assetProfiles[symbol];
try {
await this.prismaService.symbolProfile.upsert({
@@ -234,7 +294,8 @@ export class DataGatheringService {
dataSource,
name,
sectors,
- symbol
+ symbol,
+ url
},
update: {
assetClass,
@@ -242,7 +303,8 @@ export class DataGatheringService {
countries,
currency,
name,
- sectors
+ sectors,
+ url
},
where: {
dataSource_symbol: {
@@ -252,12 +314,22 @@ export class DataGatheringService {
}
});
} catch (error) {
- Logger.error(`${symbol}: ${error?.meta?.cause}`);
+ Logger.error(
+ `${symbol}: ${error?.meta?.cause}`,
+ error,
+ 'DataGatheringService'
+ );
}
}
- Logger.log('Profile data gathering has been completed.');
- console.timeEnd('data-gathering-profile');
+ Logger.log(
+ `Asset profile data gathering has been completed for ${uniqueAssets
+ .map(({ dataSource, symbol }) => {
+ return `${symbol} (${dataSource})`;
+ })
+ .join(',')}.`,
+ 'DataGatheringService'
+ );
}
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
@@ -265,6 +337,10 @@ export class DataGatheringService {
let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
+ if (dataSource === 'MANUAL') {
+ continue;
+ }
+
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
try {
@@ -299,16 +375,33 @@ export class DataGatheringService {
?.marketPrice;
}
- try {
- await this.prismaService.marketData.create({
- data: {
- dataSource,
- symbol,
- date: currentDate,
- marketPrice: lastMarketPrice
- }
- });
- } catch {}
+ if (lastMarketPrice) {
+ try {
+ await this.prismaService.marketData.create({
+ data: {
+ dataSource,
+ symbol,
+ date: new Date(
+ Date.UTC(
+ getYear(currentDate),
+ getMonth(currentDate),
+ getDate(currentDate),
+ 0
+ )
+ ),
+ marketPrice: lastMarketPrice
+ }
+ });
+ } catch {}
+ } else {
+ Logger.warn(
+ `Failed to gather data for symbol ${symbol} from ${dataSource} at ${format(
+ currentDate,
+ DATE_FORMAT
+ )}.`,
+ 'DataGatheringService'
+ );
+ }
// Count month one up for iteration
currentDate = new Date(
@@ -322,14 +415,15 @@ export class DataGatheringService {
}
} catch (error) {
hasError = true;
- Logger.error(error);
+ Logger.error(error, 'DataGatheringService');
}
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
Logger.log(
`Data gathering progress: ${(
this.dataGatheringProgress * 100
- ).toFixed(2)}%`
+ ).toFixed(2)}%`,
+ 'DataGatheringService'
);
}
@@ -401,6 +495,11 @@ export class DataGatheringService {
},
scraperConfiguration: true,
symbol: true
+ },
+ where: {
+ dataSource: {
+ not: 'MANUAL'
+ }
}
})
).map((symbolProfile) => {
@@ -410,15 +509,32 @@ export class DataGatheringService {
};
});
- return [
- ...this.getBenchmarksToGather(startDate),
- ...currencyPairsToGather,
- ...symbolProfilesToGather
- ];
+ return [...currencyPairsToGather, ...symbolProfilesToGather];
+ }
+
+ public async getUniqueAssets(): Promise {
+ const symbolProfiles = await this.prismaService.symbolProfile.findMany({
+ orderBy: [{ symbol: 'asc' }]
+ });
+
+ return symbolProfiles
+ .filter(({ dataSource }) => {
+ return (
+ dataSource !== DataSource.GHOSTFOLIO &&
+ dataSource !== DataSource.MANUAL &&
+ dataSource !== DataSource.RAKUTEN
+ );
+ })
+ .map(({ dataSource, symbol }) => {
+ return {
+ dataSource,
+ symbol
+ };
+ });
}
public async reset() {
- Logger.log('Data gathering has been reset.');
+ Logger.log('Data gathering has been reset.', 'DataGatheringService');
await this.prismaService.property.deleteMany({
where: {
@@ -430,41 +546,58 @@ export class DataGatheringService {
});
}
- private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
- const benchmarksToGather: IDataGatheringItem[] = [];
-
- if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
- benchmarksToGather.push({
- dataSource: DataSource.RAKUTEN,
- date: startDate,
- symbol: ghostfolioFearAndGreedIndexSymbol
- });
- }
-
- return benchmarksToGather;
- }
-
private async getSymbols7D(): Promise {
const startDate = subDays(resetHours(new Date()), 7);
- const symbolProfilesToGather = (
- await this.prismaService.symbolProfile.findMany({
+ const symbolProfiles = await this.prismaService.symbolProfile.findMany({
+ orderBy: [{ symbol: 'asc' }],
+ select: {
+ dataSource: true,
+ scraperConfiguration: true,
+ symbol: true
+ },
+ where: {
+ dataSource: {
+ not: 'MANUAL'
+ }
+ }
+ });
+
+ // Only consider symbols with incomplete market data for the last
+ // 7 days
+ const symbolsNotToGather = (
+ await this.prismaService.marketData.groupBy({
+ _count: true,
+ by: ['symbol'],
orderBy: [{ symbol: 'asc' }],
- select: {
- dataSource: true,
- scraperConfiguration: true,
- symbol: true
+ where: {
+ date: { gt: startDate }
}
})
- ).map((symbolProfile) => {
- return {
- ...symbolProfile,
- date: startDate
- };
- });
+ )
+ .filter((group) => {
+ return group._count >= 6;
+ })
+ .map((group) => {
+ return group.symbol;
+ });
+
+ const symbolProfilesToGather = symbolProfiles
+ .filter(({ symbol }) => {
+ return !symbolsNotToGather.includes(symbol);
+ })
+ .map((symbolProfile) => {
+ return {
+ ...symbolProfile,
+ date: startDate
+ };
+ });
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
+ .filter(({ symbol }) => {
+ return !symbolsNotToGather.includes(symbol);
+ })
.map(({ dataSource, symbol }) => {
return {
dataSource,
@@ -473,30 +606,7 @@ export class DataGatheringService {
};
});
- return [
- ...this.getBenchmarksToGather(startDate),
- ...currencyPairsToGather,
- ...symbolProfilesToGather
- ];
- }
-
- private async getSymbolsProfileData(): Promise {
- const startDate = subDays(resetHours(new Date()), 7);
-
- const distinctOrders = await this.prismaService.order.findMany({
- distinct: ['symbol'],
- orderBy: [{ symbol: 'asc' }],
- select: { dataSource: true, symbol: true }
- });
-
- return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
- (distinctOrder) => {
- return (
- distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
- distinctOrder.dataSource !== DataSource.RAKUTEN
- );
- }
- );
+ return [...currencyPairsToGather, ...symbolProfilesToGather];
}
private async isDataGatheringNeeded() {
diff --git a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
index 625bbc627..bff966fe3 100644
--- a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
+++ b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
@@ -1,16 +1,16 @@
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
+} from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
-import { DataSource } from '@prisma/client';
+import { DataSource, SymbolProfile } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns';
-import {
- IDataProviderHistoricalResponse,
- IDataProviderResponse
-} from '../../interfaces/interfaces';
-import { DataProviderInterface } from '../interfaces/data-provider.interface';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@Injectable()
@@ -29,25 +29,23 @@ export class AlphaVantageService implements DataProviderInterface {
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
}
- public async get(
- aSymbols: string[]
- ): Promise<{ [symbol: string]: IDataProviderResponse }> {
- return {};
+ public async getAssetProfile(
+ aSymbol: string
+ ): Promise> {
+ return {
+ dataSource: this.getName()
+ };
}
public async getHistorical(
- aSymbols: string[],
+ aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
- if (aSymbols.length <= 0) {
- return {};
- }
-
- const symbol = aSymbols[0];
+ const symbol = aSymbol;
try {
const historicalData: {
@@ -78,7 +76,7 @@ export class AlphaVantageService implements DataProviderInterface {
return response;
} catch (error) {
- Logger.error(error, symbol);
+ Logger.error(error, 'AlphaVantageService');
return {};
}
@@ -88,13 +86,19 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE;
}
- public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
- const result = await this.alphaVantage.data.search(aSymbol);
+ public async getQuotes(
+ aSymbols: string[]
+ ): Promise<{ [symbol: string]: IDataProviderResponse }> {
+ return {};
+ }
+
+ public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
+ const result = await this.alphaVantage.data.search(aQuery);
return {
items: result?.bestMatches?.map((bestMatch) => {
return {
- dataSource: DataSource.ALPHA_VANTAGE,
+ dataSource: this.getName(),
name: bestMatch['2. name'],
symbol: bestMatch['1. symbol']
};
diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
index d7dfb3b42..8ebdb1dba 100644
--- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
+++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
@@ -1,5 +1,7 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
-import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
+import { Country } from '@ghostfolio/common/interfaces/country.interface';
+import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
+import { SymbolProfile } from '@prisma/client';
import bent from 'bent';
const getJSON = bent('json');
@@ -7,6 +9,9 @@ const getJSON = bent('json');
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com/holdings';
private static countries = require('countries-list/dist/countries.json');
+ private static countriesMapping = {
+ 'Russian Federation': 'Russia'
+ };
private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples',
@@ -18,16 +23,16 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response,
symbol
}: {
- response: IDataProviderResponse;
+ response: Partial;
symbol: string;
- }): Promise {
+ }): Promise> {
if (
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
) {
return response;
}
- const holdings = await getJSON(
+ const result = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
).catch(() => {
return getJSON(
@@ -37,15 +42,27 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
);
});
- if (!response.countries || response.countries.length === 0) {
+ if (result.weight < 0.95) {
+ // Skip if data is inaccurate
+ return response;
+ }
+
+ if (
+ !response.countries ||
+ (response.countries as unknown as Country[]).length === 0
+ ) {
response.countries = [];
- for (const [name, value] of Object.entries(holdings.countries)) {
+ for (const [name, value] of Object.entries(result.countries)) {
let countryCode: string;
for (const [key, country] of Object.entries(
TrackinsightDataEnhancerService.countries
)) {
- if (country.name === name) {
+ if (
+ country.name === name ||
+ country.name ===
+ TrackinsightDataEnhancerService.countriesMapping[name]
+ ) {
countryCode = key;
break;
}
@@ -58,9 +75,12 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}
}
- if (!response.sectors || response.sectors.length === 0) {
+ if (
+ !response.sectors ||
+ (response.sectors as unknown as Sector[]).length === 0
+ ) {
response.sectors = [];
- for (const [name, value] of Object.entries(holdings.sectors)) {
+ for (const [name, value] of Object.entries(result.sectors)) {
response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight
diff --git a/apps/api/src/services/data-provider/data-provider.module.ts b/apps/api/src/services/data-provider/data-provider.module.ts
index 37afb3abf..e2a77af4a 100644
--- a/apps/api/src/services/data-provider/data-provider.module.ts
+++ b/apps/api/src/services/data-provider/data-provider.module.ts
@@ -1,6 +1,8 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
+import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
+import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
@@ -21,12 +23,16 @@ import { DataProviderService } from './data-provider.service';
AlphaVantageService,
DataProviderService,
GhostfolioScraperApiService,
+ GoogleSheetsService,
+ ManualService,
RakutenRapidApiService,
YahooFinanceService,
{
inject: [
AlphaVantageService,
GhostfolioScraperApiService,
+ GoogleSheetsService,
+ ManualService,
RakutenRapidApiService,
YahooFinanceService
],
@@ -34,11 +40,15 @@ import { DataProviderService } from './data-provider.service';
useFactory: (
alphaVantageService,
ghostfolioScraperApiService,
+ googleSheetsService,
+ manualService,
rakutenRapidApiService,
yahooFinanceService
) => [
alphaVantageService,
ghostfolioScraperApiService,
+ googleSheetsService,
+ manualService,
rakutenRapidApiService,
yahooFinanceService
]
diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts
index 15a8a6efb..fd44f2426 100644
--- a/apps/api/src/services/data-provider/data-provider.service.ts
+++ b/apps/api/src/services/data-provider/data-provider.service.ts
@@ -10,9 +10,9 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
-import { DataSource, MarketData } from '@prisma/client';
+import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns';
-import { isEmpty } from 'lodash';
+import { groupBy, isEmpty } from 'lodash';
@Injectable()
export class DataProviderService {
@@ -23,33 +23,6 @@ export class DataProviderService {
private readonly prismaService: PrismaService
) {}
- public async get(items: IDataGatheringItem[]): Promise<{
- [symbol: string]: IDataProviderResponse;
- }> {
- const response: {
- [symbol: string]: IDataProviderResponse;
- } = {};
-
- for (const item of items) {
- const dataProvider = this.getDataProvider(item.dataSource);
- response[item.symbol] = (await dataProvider.get([item.symbol]))[
- item.symbol
- ];
- }
-
- const promises = [];
- for (const symbol of Object.keys(response)) {
- const promise = Promise.resolve(response[symbol]);
- promises.push(
- promise.then((currentResponse) => (response[symbol] = currentResponse))
- );
- }
-
- await Promise.all(promises);
-
- return response;
- }
-
public async getHistorical(
aItems: IDataGatheringItem[],
aGranularity: Granularity = 'month',
@@ -109,7 +82,7 @@ export class DataProviderService {
return r;
}, {});
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'DataProviderService');
} finally {
return response;
}
@@ -135,7 +108,7 @@ export class DataProviderService {
if (dataProvider.canHandle(symbol)) {
promises.push(
dataProvider
- .getHistorical([symbol], undefined, from, to)
+ .getHistorical(symbol, undefined, from, to)
.then((data) => ({ data: data?.[symbol], symbol }))
);
}
@@ -149,13 +122,89 @@ export class DataProviderService {
return result;
}
- public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
+ public getPrimaryDataSource(): DataSource {
+ return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
+ }
+
+ public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
+ [symbol: string]: Partial;
+ }> {
+ const response: {
+ [symbol: string]: Partial;
+ } = {};
+
+ const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
+
+ const promises = [];
+
+ for (const [dataSource, dataGatheringItems] of Object.entries(
+ itemsGroupedByDataSource
+ )) {
+ const symbols = dataGatheringItems.map((dataGatheringItem) => {
+ return dataGatheringItem.symbol;
+ });
+
+ for (const symbol of symbols) {
+ const promise = Promise.resolve(
+ this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol)
+ );
+
+ promises.push(
+ promise.then((symbolProfile) => {
+ response[symbol] = symbolProfile;
+ })
+ );
+ }
+ }
+
+ await Promise.all(promises);
+
+ return response;
+ }
+
+ public async getQuotes(items: IDataGatheringItem[]): Promise<{
+ [symbol: string]: IDataProviderResponse;
+ }> {
+ const response: {
+ [symbol: string]: IDataProviderResponse;
+ } = {};
+
+ const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
+
+ const promises = [];
+
+ for (const [dataSource, dataGatheringItems] of Object.entries(
+ itemsGroupedByDataSource
+ )) {
+ const symbols = dataGatheringItems.map((dataGatheringItem) => {
+ return dataGatheringItem.symbol;
+ });
+
+ const promise = Promise.resolve(
+ this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
+ );
+
+ promises.push(
+ promise.then((result) => {
+ for (const [symbol, dataProviderResponse] of Object.entries(result)) {
+ response[symbol] = dataProviderResponse;
+ }
+ })
+ );
+ }
+
+ await Promise.all(promises);
+
+ return response;
+ }
+
+ public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = [];
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push(
- this.getDataProvider(DataSource[dataSource]).search(aSymbol)
+ this.getDataProvider(DataSource[dataSource]).search(aQuery)
);
}
@@ -175,16 +224,13 @@ export class DataProviderService {
};
}
- public getPrimaryDataSource(): DataSource {
- return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
- }
-
private getDataProvider(providerName: DataSource) {
for (const dataProviderInterface of this.dataProviderInterfaces) {
if (dataProviderInterface.getName() === providerName) {
return dataProviderInterface;
}
}
+
throw new Error('No data provider has been found.');
}
}
diff --git a/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts b/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts
index 77d406fdb..f0ee84237 100644
--- a/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts
+++ b/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts
@@ -1,24 +1,18 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
+import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
+import {
+ IDataProviderHistoricalResponse,
+ IDataProviderResponse
+} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
-import {
- DATE_FORMAT,
- getYesterday,
- isGhostfolioScraperApiSymbol
-} from '@ghostfolio/common/helper';
+import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
-import { DataSource } from '@prisma/client';
+import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent';
import * as cheerio from 'cheerio';
-import { format } from 'date-fns';
-
-import {
- IDataProviderHistoricalResponse,
- IDataProviderResponse,
- MarketState
-} from '../../interfaces/interfaces';
-import { DataProviderInterface } from '../interfaces/data-provider.interface';
+import { addDays, format, isBefore } from 'date-fns';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
@@ -30,73 +24,61 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
) {}
public canHandle(symbol: string) {
- return isGhostfolioScraperApiSymbol(symbol);
+ return true;
}
- public async get(
- aSymbols: string[]
- ): Promise<{ [symbol: string]: IDataProviderResponse }> {
- if (aSymbols.length <= 0) {
- return {};
- }
-
- try {
- const [symbol] = aSymbols;
- const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
- [symbol]
- );
-
- const { marketPrice } = await this.prismaService.marketData.findFirst({
- orderBy: {
- date: 'desc'
- },
- where: {
- symbol
- }
- });
-
- return {
- [symbol]: {
- marketPrice,
- currency: symbolProfile?.currency,
- dataSource: DataSource.GHOSTFOLIO,
- marketState: MarketState.delayed
- }
- };
- } catch (error) {
- Logger.error(error);
- }
-
- return {};
+ public async getAssetProfile(
+ aSymbol: string
+ ): Promise> {
+ return {
+ dataSource: this.getName()
+ };
}
public async getHistorical(
- aSymbols: string[],
+ aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
- if (aSymbols.length <= 0) {
- return {};
- }
-
try {
- const [symbol] = aSymbols;
+ const symbol = aSymbol;
+
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
- const scraperConfiguration = symbolProfile?.scraperConfiguration;
+ const { defaultMarketPrice, selector, url } =
+ symbolProfile.scraperConfiguration;
+
+ if (defaultMarketPrice) {
+ const historical: {
+ [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
+ } = {
+ [symbol]: {}
+ };
+ let date = from;
+
+ while (isBefore(date, to)) {
+ historical[symbol][format(date, DATE_FORMAT)] = {
+ marketPrice: defaultMarketPrice
+ };
+
+ date = addDays(date, 1);
+ }
- const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
+ return historical;
+ } else if (selector === undefined || url === undefined) {
+ return {};
+ }
+
+ const get = bent(url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
- const value = this.extractNumberFromString(
- $(scraperConfiguration?.selector).text()
- );
+ const value = this.extractNumberFromString($(selector).text());
return {
[symbol]: {
@@ -106,7 +88,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
}
};
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'GhostfolioScraperApiService');
}
return {};
@@ -116,8 +98,81 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return DataSource.GHOSTFOLIO;
}
- public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
- return { items: [] };
+ public async getQuotes(
+ aSymbols: string[]
+ ): Promise<{ [symbol: string]: IDataProviderResponse }> {
+ const response: { [symbol: string]: IDataProviderResponse } = {};
+
+ if (aSymbols.length <= 0) {
+ return response;
+ }
+
+ try {
+ const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
+ aSymbols
+ );
+
+ const marketData = await this.prismaService.marketData.findMany({
+ distinct: ['symbol'],
+ orderBy: {
+ date: 'desc'
+ },
+ take: aSymbols.length,
+ where: {
+ symbol: {
+ in: aSymbols
+ }
+ }
+ });
+
+ for (const symbolProfile of symbolProfiles) {
+ response[symbolProfile.symbol] = {
+ currency: symbolProfile.currency,
+ dataSource: this.getName(),
+ marketPrice: marketData.find((marketDataItem) => {
+ return marketDataItem.symbol === symbolProfile.symbol;
+ }).marketPrice,
+ marketState: 'delayed'
+ };
+ }
+
+ return response;
+ } catch (error) {
+ Logger.error(error, 'GhostfolioScraperApiService');
+ }
+
+ return {};
+ }
+
+ public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
+ const items = await this.prismaService.symbolProfile.findMany({
+ select: {
+ currency: true,
+ dataSource: true,
+ name: true,
+ symbol: true
+ },
+ where: {
+ OR: [
+ {
+ dataSource: this.getName(),
+ name: {
+ mode: 'insensitive',
+ startsWith: aQuery
+ }
+ },
+ {
+ dataSource: this.getName(),
+ symbol: {
+ mode: 'insensitive',
+ startsWith: aQuery
+ }
+ }
+ ]
+ }
+ });
+
+ return { items };
}
private extractNumberFromString(aString: string): number {
diff --git a/apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface.ts b/apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface.ts
index 21380253e..9bae19418 100644
--- a/apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface.ts
+++ b/apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface.ts
@@ -1,4 +1,5 @@
export interface ScraperConfiguration {
+ defaultMarketPrice?: number;
selector: string;
url: string;
}
diff --git a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
new file mode 100644
index 000000000..97022706f
--- /dev/null
+++ b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
@@ -0,0 +1,184 @@
+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
+} from '@ghostfolio/api/services/interfaces/interfaces';
+import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
+import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
+import { Granularity } from '@ghostfolio/common/types';
+import { Injectable, Logger } from '@nestjs/common';
+import { DataSource, SymbolProfile } from '@prisma/client';
+import { format } from 'date-fns';
+import { GoogleSpreadsheet } from 'google-spreadsheet';
+
+@Injectable()
+export class GoogleSheetsService implements DataProviderInterface {
+ public constructor(
+ private readonly configurationService: ConfigurationService,
+ private readonly prismaService: PrismaService,
+ private readonly symbolProfileService: SymbolProfileService
+ ) {}
+
+ public canHandle(symbol: string) {
+ return true;
+ }
+
+ public async getAssetProfile(
+ aSymbol: string
+ ): Promise> {
+ return {
+ dataSource: this.getName()
+ };
+ }
+
+ public async getHistorical(
+ aSymbol: string,
+ aGranularity: Granularity = 'day',
+ from: Date,
+ to: Date
+ ): Promise<{
+ [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
+ }> {
+ try {
+ const symbol = aSymbol;
+
+ const sheet = await this.getSheet({
+ symbol,
+ sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
+ });
+
+ const rows = await sheet.getRows();
+
+ const historicalData: {
+ [date: string]: IDataProviderHistoricalResponse;
+ } = {};
+
+ rows
+ .filter((row, index) => {
+ return index >= 1;
+ })
+ .forEach((row) => {
+ const date = parseDate(row._rawData[0]);
+ const close = parseFloat(row._rawData[1]);
+
+ historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
+ });
+
+ return {
+ [symbol]: historicalData
+ };
+ } catch (error) {
+ Logger.error(error, 'GoogleSheetsService');
+ }
+
+ return {};
+ }
+
+ public getName(): DataSource {
+ return DataSource.GOOGLE_SHEETS;
+ }
+
+ public async getQuotes(
+ aSymbols: string[]
+ ): Promise<{ [symbol: string]: IDataProviderResponse }> {
+ if (aSymbols.length <= 0) {
+ return {};
+ }
+
+ try {
+ const response: { [symbol: string]: IDataProviderResponse } = {};
+
+ const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
+ aSymbols
+ );
+
+ const sheet = await this.getSheet({
+ sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
+ symbol: 'Overview'
+ });
+
+ const rows = await sheet.getRows();
+
+ for (const row of rows) {
+ const marketPrice = parseFloat(row['marketPrice']);
+ const symbol = row['symbol'];
+
+ if (aSymbols.includes(symbol)) {
+ response[symbol] = {
+ marketPrice,
+ currency: symbolProfiles.find((symbolProfile) => {
+ return symbolProfile.symbol === symbol;
+ })?.currency,
+ dataSource: this.getName(),
+ marketState: 'delayed'
+ };
+ }
+ }
+
+ return response;
+ } catch (error) {
+ Logger.error(error, 'GoogleSheetsService');
+ }
+
+ return {};
+ }
+
+ public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
+ const items = await this.prismaService.symbolProfile.findMany({
+ select: {
+ currency: true,
+ dataSource: true,
+ name: true,
+ symbol: true
+ },
+ where: {
+ OR: [
+ {
+ dataSource: this.getName(),
+ name: {
+ mode: 'insensitive',
+ startsWith: aQuery
+ }
+ },
+ {
+ dataSource: this.getName(),
+ symbol: {
+ mode: 'insensitive',
+ startsWith: aQuery
+ }
+ }
+ ]
+ }
+ });
+
+ return { items };
+ }
+
+ private async getSheet({
+ sheetId,
+ symbol
+ }: {
+ sheetId: string;
+ symbol: string;
+ }) {
+ const doc = new GoogleSpreadsheet(sheetId);
+
+ await doc.useServiceAccountAuth({
+ client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
+ private_key: this.configurationService
+ .get('GOOGLE_SHEETS_PRIVATE_KEY')
+ .replace(/\\n/g, '\n')
+ });
+
+ await doc.loadInfo();
+
+ const sheet = doc.sheetsByTitle[symbol];
+
+ await sheet.loadCells();
+
+ return sheet;
+ }
+}
diff --git a/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts b/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts
index 26585b320..4e5ce8cba 100644
--- a/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts
+++ b/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts
@@ -1,13 +1,13 @@
-import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
+import { SymbolProfile } from '@prisma/client';
export interface DataEnhancerInterface {
enhance({
response,
symbol
}: {
- response: IDataProviderResponse;
+ response: Partial;
symbol: string;
- }): Promise;
+ }): Promise>;
getName(): string;
}
diff --git a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
index 5f99c8614..16cf44603 100644
--- a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
+++ b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
@@ -1,27 +1,30 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
-import { Granularity } from '@ghostfolio/common/types';
-import { DataSource } from '@prisma/client';
-
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
-} from '../../interfaces/interfaces';
+} from '@ghostfolio/api/services/interfaces/interfaces';
+import { Granularity } from '@ghostfolio/common/types';
+import { DataSource, SymbolProfile } from '@prisma/client';
export interface DataProviderInterface {
canHandle(symbol: string): boolean;
- get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
+ getAssetProfile(aSymbol: string): Promise>;
getHistorical(
- aSymbols: string[],
+ aSymbol: string,
aGranularity: Granularity,
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
- }>;
+ }>; // TODO: Return only one symbol
getName(): DataSource;
- search(aSymbol: string): Promise<{ items: LookupItem[] }>;
+ getQuotes(
+ aSymbols: string[]
+ ): Promise<{ [symbol: string]: IDataProviderResponse }>;
+
+ search(aQuery: string): Promise<{ items: LookupItem[] }>;
}
diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts
new file mode 100644
index 000000000..edcdd2cde
--- /dev/null
+++ b/apps/api/src/services/data-provider/manual/manual.service.ts
@@ -0,0 +1,51 @@
+import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
+import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
+import {
+ IDataProviderHistoricalResponse,
+ IDataProviderResponse
+} from '@ghostfolio/api/services/interfaces/interfaces';
+import { Granularity } from '@ghostfolio/common/types';
+import { Injectable } from '@nestjs/common';
+import { DataSource, SymbolProfile } from '@prisma/client';
+
+@Injectable()
+export class ManualService implements DataProviderInterface {
+ public constructor() {}
+
+ public canHandle(symbol: string) {
+ return false;
+ }
+
+ public async getAssetProfile(
+ aSymbol: string
+ ): Promise> {
+ return {
+ dataSource: this.getName()
+ };
+ }
+
+ public async getHistorical(
+ aSymbol: string,
+ aGranularity: Granularity = 'day',
+ from: Date,
+ to: Date
+ ): Promise<{
+ [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
+ }> {
+ return {};
+ }
+
+ public getName(): DataSource {
+ return DataSource.MANUAL;
+ }
+
+ public async getQuotes(
+ aSymbols: string[]
+ ): Promise<{ [symbol: string]: IDataProviderResponse }> {
+ return {};
+ }
+
+ public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
+ return { items: [] };
+ }
+}
diff --git a/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts b/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts
index dea52e74d..baa6591f4 100644
--- a/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts
+++ b/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts
@@ -1,25 +1,21 @@
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
+} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
-import { DataSource } from '@prisma/client';
+import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns';
-import {
- IDataProviderHistoricalResponse,
- IDataProviderResponse,
- MarketState
-} from '../../interfaces/interfaces';
-import { DataProviderInterface } from '../interfaces/data-provider.interface';
-
@Injectable()
export class RakutenRapidApiService implements DataProviderInterface {
- public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
-
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
@@ -29,50 +25,24 @@ export class RakutenRapidApiService implements DataProviderInterface {
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
}
- public async get(
- aSymbols: string[]
- ): Promise<{ [symbol: string]: IDataProviderResponse }> {
- if (aSymbols.length <= 0) {
- return {};
- }
-
- try {
- const symbol = aSymbols[0];
-
- if (symbol === ghostfolioFearAndGreedIndexSymbol) {
- const fgi = await this.getFearAndGreedIndex();
-
- return {
- [ghostfolioFearAndGreedIndexSymbol]: {
- currency: undefined,
- dataSource: DataSource.RAKUTEN,
- marketPrice: fgi.now.value,
- marketState: MarketState.open,
- name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
- }
- };
- }
- } catch (error) {
- Logger.error(error);
- }
-
- return {};
+ public async getAssetProfile(
+ aSymbol: string
+ ): Promise> {
+ return {
+ dataSource: this.getName()
+ };
}
public async getHistorical(
- aSymbols: string[],
+ aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
- if (aSymbols.length <= 0) {
- return {};
- }
-
try {
- const symbol = aSymbols[0];
+ const symbol = aSymbol;
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
@@ -85,7 +55,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({
data: {
symbol,
- dataSource: DataSource.RAKUTEN,
+ dataSource: this.getName(),
date: subWeeks(getToday(), 1),
marketPrice: fgi.oneWeekAgo.value
}
@@ -94,7 +64,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({
data: {
symbol,
- dataSource: DataSource.RAKUTEN,
+ dataSource: this.getName(),
date: subMonths(getToday(), 1),
marketPrice: fgi.oneMonthAgo.value
}
@@ -103,7 +73,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({
data: {
symbol,
- dataSource: DataSource.RAKUTEN,
+ dataSource: this.getName(),
date: subYears(getToday(), 1),
marketPrice: fgi.oneYearAgo.value
}
@@ -129,7 +99,36 @@ export class RakutenRapidApiService implements DataProviderInterface {
return DataSource.RAKUTEN;
}
- public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
+ public async getQuotes(
+ aSymbols: string[]
+ ): Promise<{ [symbol: string]: IDataProviderResponse }> {
+ if (aSymbols.length <= 0) {
+ return {};
+ }
+
+ try {
+ const symbol = aSymbols[0];
+
+ if (symbol === ghostfolioFearAndGreedIndexSymbol) {
+ const fgi = await this.getFearAndGreedIndex();
+
+ return {
+ [ghostfolioFearAndGreedIndexSymbol]: {
+ currency: undefined,
+ dataSource: this.getName(),
+ marketPrice: fgi.now.value,
+ marketState: 'open'
+ }
+ };
+ }
+ } catch (error) {
+ Logger.error(error, 'RakutenRapidApiService');
+ }
+
+ return {};
+ }
+
+ public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
}
@@ -158,7 +157,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
const { fgi } = await get();
return fgi;
} catch (error) {
- Logger.error(error);
+ Logger.error(error, 'RakutenRapidApiService');
return undefined;
}
diff --git a/apps/api/src/services/data-provider/yahoo-finance/interfaces/interfaces.ts b/apps/api/src/services/data-provider/yahoo-finance/interfaces/interfaces.ts
deleted file mode 100644
index d41a43d39..000000000
--- a/apps/api/src/services/data-provider/yahoo-finance/interfaces/interfaces.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-export interface IYahooFinanceHistoricalResponse {
- adjClose: number;
- close: number;
- date: Date;
- high: number;
- low: number;
- open: number;
- symbol: string;
- volume: number;
-}
-
-export interface IYahooFinanceQuoteResponse {
- price: IYahooFinancePrice;
- summaryProfile: IYahooFinanceSummaryProfile;
-}
-
-export interface IYahooFinancePrice {
- currency: string;
- exchangeName: string;
- longName: string;
- marketState: string;
- quoteType: string;
- regularMarketPrice: number;
- shortName: string;
-}
-
-export interface IYahooFinanceSummaryProfile {
- country?: string;
- industry?: string;
- sector?: string;
- website?: string;
-}
diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts
new file mode 100644
index 000000000..648eb6037
--- /dev/null
+++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts
@@ -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');
+ });
+});
diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
index 4079b437b..28c9e8549 100644
--- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
+++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
@@ -1,32 +1,28 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
-import { UNKNOWN_KEY } from '@ghostfolio/common/config';
+import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
+import {
+ IDataProviderHistoricalResponse,
+ IDataProviderResponse
+} from '@ghostfolio/api/services/interfaces/interfaces';
+import { baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
-import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
-import * as bent from 'bent';
+import {
+ AssetClass,
+ AssetSubClass,
+ DataSource,
+ SymbolProfile
+} from '@prisma/client';
import Big from 'big.js';
import { countries } from 'countries-list';
-import { format } from 'date-fns';
-import * as yahooFinance from 'yahoo-finance';
-
-import {
- IDataProviderHistoricalResponse,
- IDataProviderResponse,
- MarketState
-} from '../../interfaces/interfaces';
-import { DataProviderInterface } from '../interfaces/data-provider.interface';
-import {
- IYahooFinanceHistoricalResponse,
- IYahooFinancePrice,
- IYahooFinanceQuoteResponse
-} from './interfaces/interfaces';
+import { addDays, format, isSameDay } from 'date-fns';
+import yahooFinance from 'yahoo-finance2';
+import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
- private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
-
public constructor(
private readonly cryptocurrencyService: CryptocurrencyService
) {}
@@ -35,141 +31,156 @@ export class YahooFinanceService implements DataProviderInterface {
return true;
}
- public async get(
- aSymbols: string[]
- ): Promise<{ [symbol: string]: IDataProviderResponse }> {
- if (aSymbols.length <= 0) {
- return {};
- }
- const yahooFinanceSymbols = aSymbols.map((symbol) =>
- this.convertToYahooFinanceSymbol(symbol)
+ public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
+ const symbol = aYahooFinanceSymbol.replace(
+ new RegExp(`-${baseCurrency}$`),
+ baseCurrency
);
+ return symbol.replace('=X', '');
+ }
- try {
- const response: { [symbol: string]: IDataProviderResponse } = {};
+ /**
+ * 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}`
+ );
+ }
+ }
- const data: {
- [symbol: string]: IYahooFinanceQuoteResponse;
- } = await yahooFinance.quote({
- modules: ['price', 'summaryProfile'],
- symbols: yahooFinanceSymbols
+ return aSymbol;
+ }
+
+ public async getAssetProfile(
+ aSymbol: string
+ ): Promise> {
+ const response: Partial = {};
+
+ try {
+ const symbol = this.convertToYahooFinanceSymbol(aSymbol);
+ const assetProfile = await yahooFinance.quoteSummary(symbol, {
+ modules: ['price', 'summaryProfile']
});
- for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
- // Convert symbols back
- const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
+ const { assetClass, assetSubClass } = this.parseAssetClass(
+ assetProfile.price
+ );
- const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
+ response.assetClass = assetClass;
+ response.assetSubClass = assetSubClass;
+ response.currency = assetProfile.price.currency;
+ response.dataSource = this.getName();
+ response.name = this.formatName({
+ longName: assetProfile.price.longName,
+ quoteType: assetProfile.price.quoteType,
+ shortName: assetProfile.price.shortName,
+ symbol: assetProfile.price.symbol
+ });
+ response.symbol = aSymbol;
- response[symbol] = {
- assetClass,
- assetSubClass,
- currency: value.price?.currency,
- dataSource: DataSource.YAHOO,
- exchange: this.parseExchange(value.price?.exchangeName),
- marketState:
- value.price?.marketState === 'REGULAR' ||
- this.cryptocurrencyService.isCrypto(symbol)
- ? MarketState.open
- : MarketState.closed,
- marketPrice: value.price?.regularMarketPrice || 0,
- name: value.price?.longName || value.price?.shortName || symbol
- };
+ if (
+ assetSubClass === AssetSubClass.STOCK &&
+ assetProfile.summaryProfile?.country
+ ) {
+ // Add country if asset is stock and country available
- if (value.price?.currency === 'GBp') {
- // Convert GBp (pence) to GBP
- response[symbol].currency = 'GBP';
- response[symbol].marketPrice = new Big(
- value.price?.regularMarketPrice ?? 0
- )
- .div(100)
- .toNumber();
- }
+ try {
+ const [code] = Object.entries(countries).find(([, country]) => {
+ return country.name === assetProfile.summaryProfile?.country;
+ });
- // Add country if stock and available
- if (
- assetSubClass === AssetSubClass.STOCK &&
- value.summaryProfile?.country
- ) {
- try {
- const [code] = Object.entries(countries).find(([, country]) => {
- return country.name === value.summaryProfile?.country;
- });
-
- if (code) {
- response[symbol].countries = [{ code, weight: 1 }];
- }
- } catch {}
-
- if (value.summaryProfile?.sector) {
- response[symbol].sectors = [
- { name: value.summaryProfile?.sector, weight: 1 }
- ];
+ if (code) {
+ response.countries = [{ code, weight: 1 }];
}
- }
+ } catch {}
- // Add url if available
- const url = value.summaryProfile?.website;
- if (url) {
- response[symbol].url = url;
+ if (assetProfile.summaryProfile?.sector) {
+ response.sectors = [
+ { name: assetProfile.summaryProfile?.sector, weight: 1 }
+ ];
}
}
- return response;
- } catch (error) {
- Logger.error(error);
+ const url = assetProfile.summaryProfile?.website;
+ if (url) {
+ response.url = url;
+ }
+ } catch {}
- return {};
- }
+ return response;
}
public async getHistorical(
- aSymbols: string[],
+ aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
- if (aSymbols.length <= 0) {
- return {};
+ if (isSameDay(from, to)) {
+ to = addDays(to, 1);
}
- const yahooFinanceSymbols = aSymbols.map((symbol) => {
- return this.convertToYahooFinanceSymbol(symbol);
- });
+ const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
try {
- const historicalData: {
- [symbol: string]: IYahooFinanceHistoricalResponse[];
- } = await yahooFinance.historical({
- symbols: yahooFinanceSymbols,
- from: format(from, DATE_FORMAT),
- to: format(to, DATE_FORMAT)
- });
+ const historicalResult = await yahooFinance.historical(
+ yahooFinanceSymbol,
+ {
+ interval: '1d',
+ period1: format(from, DATE_FORMAT),
+ period2: format(to, DATE_FORMAT)
+ }
+ );
const response: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {};
- for (const [yahooFinanceSymbol, timeSeries] of Object.entries(
- historicalData
- )) {
- // Convert symbols back
- const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
- response[symbol] = {};
+ // Convert symbol back
+ const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
- timeSeries.forEach((timeSerie) => {
- response[symbol][format(timeSerie.date, DATE_FORMAT)] = {
- marketPrice: timeSerie.close,
- performance: timeSerie.open - timeSerie.close
- };
- });
+ response[symbol] = {};
+
+ for (const historicalItem of historicalResult) {
+ let marketPrice = historicalItem.close;
+
+ if (symbol === 'USDGBp') {
+ // Convert GPB to GBp (pence)
+ marketPrice = new Big(marketPrice).mul(100).toNumber();
+ }
+
+ response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
+ marketPrice,
+ performance: historicalItem.open - historicalItem.close
+ };
}
return response;
} catch (error) {
- Logger.error(error);
+ Logger.warn(
+ `Skipping yahooFinance2.getHistorical("${aSymbol}"): [${error.name}] ${error.message}`,
+ 'YahooFinanceService'
+ );
return {};
}
@@ -179,101 +190,158 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO;
}
- public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
- const items: LookupItem[] = [];
+ public async getQuotes(
+ aSymbols: string[]
+ ): Promise<{ [symbol: string]: IDataProviderResponse }> {
+ if (aSymbols.length <= 0) {
+ return {};
+ }
+ const yahooFinanceSymbols = aSymbols.map((symbol) =>
+ this.convertToYahooFinanceSymbol(symbol)
+ );
try {
- const get = bent(
- `${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
- 'GET',
- 'json',
- 200
- );
+ const response: { [symbol: string]: IDataProviderResponse } = {};
+
+ const quotes = await yahooFinance.quote(yahooFinanceSymbols);
+
+ for (const quote of quotes) {
+ // Convert symbols back
+ const symbol = this.convertFromYahooFinanceSymbol(quote.symbol);
+
+ response[symbol] = {
+ currency: quote.currency,
+ dataSource: this.getName(),
+ marketState:
+ quote.marketState === 'REGULAR' ||
+ this.cryptocurrencyService.isCryptocurrency(symbol)
+ ? 'open'
+ : 'closed',
+ marketPrice: quote.regularMarketPrice || 0
+ };
- const searchResult = await get();
+ if (symbol === 'USDGBP' && yahooFinanceSymbols.includes('USDGBp=X')) {
+ // Convert GPB to GBp (pence)
+ response['USDGBp'] = {
+ ...response[symbol],
+ currency: 'GBp',
+ marketPrice: new Big(response[symbol].marketPrice)
+ .mul(100)
+ .toNumber()
+ };
+ }
+ }
+
+ return response;
+ } catch (error) {
+ Logger.error(error, 'YahooFinanceService');
+
+ return {};
+ }
+ }
+
+ public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
+ const items: LookupItem[] = [];
+
+ try {
+ const searchResult = await yahooFinance.search(aQuery);
- const symbols: string[] = searchResult.quotes
+ const quotes = searchResult.quotes
.filter((quote) => {
- // filter out undefined symbols
+ // Filter out undefined symbols
return quote.symbol;
})
.filter(({ quoteType, symbol }) => {
return (
(quoteType === 'CRYPTOCURRENCY' &&
- this.cryptocurrencyService.isCrypto(
- symbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
+ this.cryptocurrencyService.isCryptocurrency(
+ symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
)) ||
- quoteType === 'EQUITY' ||
- quoteType === 'ETF'
+ ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
);
})
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
- // Only allow cryptocurrencies in USD to avoid having redundancy in the database.
- // Trades need to be converted manually before to USD (or a UI converter needs to be developed)
- return symbol.includes('USD');
+ // Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
+ // Transactions need to be converted manually to the base currency before
+ return symbol.includes(baseCurrency);
+ } else if (quoteType === 'FUTURE') {
+ // Allow GC=F, but not MGC=F
+ return symbol.length === 4;
}
return true;
- })
- .map(({ symbol }) => {
+ });
+
+ const marketData = await yahooFinance.quote(
+ quotes.map(({ symbol }) => {
return symbol;
+ })
+ );
+
+ for (const marketDataItem of marketData) {
+ const quote = quotes.find((currentQuote) => {
+ return currentQuote.symbol === marketDataItem.symbol;
});
- const marketData = await this.get(symbols);
+ const symbol = this.convertFromYahooFinanceSymbol(
+ marketDataItem.symbol
+ );
- for (const [symbol, value] of Object.entries(marketData)) {
items.push({
symbol,
- currency: value.currency,
- dataSource: DataSource.YAHOO,
- name: value.name
+ currency: marketDataItem.currency,
+ dataSource: this.getName(),
+ name: this.formatName({
+ longName: quote.longname,
+ quoteType: quote.quoteType,
+ shortName: quote.shortname,
+ symbol: quote.symbol
+ })
});
}
- } catch {}
+ } catch (error) {
+ Logger.error(error, 'YahooFinanceService');
+ }
return { items };
}
- private convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
- const symbol = aYahooFinanceSymbol.replace('-', '');
- return symbol.replace('=X', '');
- }
+ private formatName({
+ longName,
+ quoteType,
+ shortName,
+ symbol
+ }: {
+ longName: Price['longName'];
+ quoteType: Price['quoteType'];
+ shortName: Price['shortName'];
+ symbol: Price['symbol'];
+ }) {
+ let name = longName;
+
+ if (name) {
+ name = name.replace('iShares ETF (CH) - ', '');
+ name = name.replace('iShares III Public Limited Company - ', '');
+ name = name.replace('iShares VI Public Limited Company - ', '');
+ name = name.replace('iShares VII PLC - ', '');
+ name = name.replace('Multi Units Luxembourg - ', '');
+ name = name.replace('VanEck ETFs N.V. - ', '');
+ name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
+ name = name.replace('Vanguard Funds Public Limited Company - ', '');
+ name = name.replace('Vanguard Index Funds - ', '');
+ name = name.replace('Xtrackers (IE) Plc - ', '');
+ }
- /**
- * 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');
- }
+ if (quoteType === 'FUTURE') {
+ // "Gold Jun 22" -> "Gold"
+ name = shortName?.slice(0, -6);
}
- return aSymbol;
+ return name || shortName || symbol;
}
- private parseAssetClass(aPrice: IYahooFinancePrice): {
+ private parseAssetClass(aPrice: Price): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
@@ -293,16 +361,26 @@ export class YahooFinanceService implements DataProviderInterface {
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
- }
+ case 'future':
+ assetClass = AssetClass.COMMODITY;
+ assetSubClass = AssetSubClass.COMMODITY;
- return { assetClass, assetSubClass };
- }
+ if (
+ aPrice?.shortName?.toLowerCase()?.startsWith('gold') ||
+ aPrice?.shortName?.toLowerCase()?.startsWith('palladium') ||
+ aPrice?.shortName?.toLowerCase()?.startsWith('platinum') ||
+ aPrice?.shortName?.toLowerCase()?.startsWith('silver')
+ ) {
+ assetSubClass = AssetSubClass.PRECIOUS_METAL;
+ }
- private parseExchange(aString: string): string {
- if (aString?.toLowerCase() === 'ccc') {
- return UNKNOWN_KEY;
+ break;
+ case 'mutualfund':
+ assetClass = AssetClass.EQUITY;
+ assetSubClass = AssetSubClass.MUTUALFUND;
+ break;
}
- return aString;
+ return { assetClass, assetSubClass };
}
}
diff --git a/apps/api/src/services/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data.service.ts
index e0e0e614f..8092f1804 100644
--- a/apps/api/src/services/exchange-rate-data.service.ts
+++ b/apps/api/src/services/exchange-rate-data.service.ts
@@ -2,7 +2,7 @@ import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns';
-import { isEmpty, isNumber, uniq } from 'lodash';
+import { isNumber, uniq } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces';
@@ -58,10 +58,10 @@ export class ExchangeRateDataService {
getYesterday()
);
- if (isEmpty(result)) {
+ if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback
- // if historical data is not yet available
- const historicalData = await this.dataProviderService.get(
+ // if historical data is not fully available
+ const historicalData = await this.dataProviderService.getQuotes(
this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
@@ -114,6 +114,10 @@ export class ExchangeRateDataService {
aFromCurrency: string,
aToCurrency: string
) {
+ if (aValue === 0) {
+ return 0;
+ }
+
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
return isNaN(exchangeRate);
});
@@ -145,7 +149,8 @@ export class ExchangeRateDataService {
// Fallback with error, if currencies are not available
Logger.error(
- `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
+ `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`,
+ 'ExchangeRateDataService'
);
return aValue;
}
@@ -157,7 +162,12 @@ export class ExchangeRateDataService {
await this.prismaService.account.findMany({
distinct: ['currency'],
orderBy: [{ currency: 'asc' }],
- select: { currency: true }
+ select: { currency: true },
+ where: {
+ currency: {
+ not: null
+ }
+ }
})
).forEach((account) => {
currencies.push(account.currency);
@@ -167,7 +177,12 @@ export class ExchangeRateDataService {
await this.prismaService.settings.findMany({
distinct: ['currency'],
orderBy: [{ currency: 'asc' }],
- select: { currency: true }
+ select: { currency: true },
+ where: {
+ currency: {
+ not: null
+ }
+ }
})
).forEach((userSettings) => {
currencies.push(userSettings.currency);
@@ -191,7 +206,7 @@ export class ExchangeRateDataService {
currencies = currencies.concat(customCurrencies);
}
- return uniq(currencies).sort();
+ return uniq(currencies).filter(Boolean).sort();
}
private prepareCurrencyPairs(aCurrencies: string[]) {
diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts
index 5439093a6..82cf08cbe 100644
--- a/apps/api/src/services/interfaces/environment.interface.ts
+++ b/apps/api/src/services/interfaces/environment.interface.ts
@@ -4,18 +4,25 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number;
+ DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean;
+ ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean;
+ ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string;
+ GOOGLE_SHEETS_ACCOUNT: string;
+ GOOGLE_SHEETS_ID: string;
+ GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string;
MAX_ITEM_IN_CACHE: number;
+ MAX_ORDERS_TO_IMPORT: number;
PORT: number;
RAKUTEN_RAPID_API_KEY: string;
REDIS_HOST: string;
@@ -23,5 +30,9 @@ export interface Environment extends CleanedEnvAccessors {
ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;
+ TWITTER_ACCESS_TOKEN: string;
+ TWITTER_ACCESS_TOKEN_SECRET: string;
+ TWITTER_API_KEY: string;
+ TWITTER_API_SECRET: string;
WEB_AUTH_RP_ID: string;
}
diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts
index d4913a83b..dbe3dfa4f 100644
--- a/apps/api/src/services/interfaces/interfaces.ts
+++ b/apps/api/src/services/interfaces/interfaces.ts
@@ -1,19 +1,11 @@
+import { MarketState } from '@ghostfolio/common/types';
import {
Account,
- AssetClass,
- AssetSubClass,
DataSource,
- SymbolProfile
+ SymbolProfile,
+ Type as TypeOfOrder
} from '@prisma/client';
-import { OrderType } from '../../models/order-type';
-
-export const MarketState = {
- closed: 'closed',
- delayed: 'delayed',
- open: 'open'
-};
-
export interface IOrder {
account: Account;
currency: string;
@@ -24,7 +16,7 @@ export interface IOrder {
quantity: number;
symbol: string;
symbolProfile: SymbolProfile;
- type: OrderType;
+ type: TypeOfOrder;
unitPrice: number;
}
@@ -34,19 +26,10 @@ export interface IDataProviderHistoricalResponse {
}
export interface IDataProviderResponse {
- assetClass?: AssetClass;
- assetSubClass?: AssetSubClass;
- countries?: { code: string; weight: number }[];
currency: string;
dataSource: DataSource;
- exchange?: string;
- marketChange?: number;
- marketChangePercent?: number;
marketPrice: number;
marketState: MarketState;
- name?: string;
- sectors?: { name: string; weight: number }[];
- url?: string;
}
export interface IDataGatheringItem {
@@ -54,5 +37,3 @@ export interface IDataGatheringItem {
date?: Date;
symbol: string;
}
-
-export type MarketState = typeof MarketState[keyof typeof MarketState];
diff --git a/apps/api/src/services/market-data.service.ts b/apps/api/src/services/market-data.service.ts
index f9c7fc003..0afb5a811 100644
--- a/apps/api/src/services/market-data.service.ts
+++ b/apps/api/src/services/market-data.service.ts
@@ -1,13 +1,24 @@
+import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
+import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
-import { MarketData, Prisma } from '@prisma/client';
+import { DataSource, MarketData, Prisma } from '@prisma/client';
@Injectable()
export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {}
+ public async deleteMany({ dataSource, symbol }: UniqueAsset) {
+ return this.prismaService.marketData.deleteMany({
+ where: {
+ dataSource,
+ symbol
+ }
+ });
+ }
+
public async get({
date,
symbol
@@ -65,4 +76,22 @@ export class MarketDataService {
where
});
}
+
+ public async updateMarketData(params: {
+ data: { dataSource: DataSource } & UpdateMarketDataDto;
+ where: Prisma.MarketDataWhereUniqueInput;
+ }): Promise {
+ 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 }
+ });
+ }
}
diff --git a/apps/api/src/services/property/property.service.ts b/apps/api/src/services/property/property.service.ts
index 44c4f7e27..4760c3a94 100644
--- a/apps/api/src/services/property/property.service.ts
+++ b/apps/api/src/services/property/property.service.ts
@@ -6,9 +6,15 @@ import { Injectable } from '@nestjs/common';
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]: object | string | string[];
+ [key: string]: boolean | object | string | string[];
} = {
[PROPERTY_CURRENCIES]: []
};
diff --git a/apps/api/src/services/symbol-profile.service.ts b/apps/api/src/services/symbol-profile.service.ts
index 0a95fd6da..5c8839ebf 100644
--- a/apps/api/src/services/symbol-profile.service.ts
+++ b/apps/api/src/services/symbol-profile.service.ts
@@ -4,20 +4,44 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
-import { Prisma, SymbolProfile } from '@prisma/client';
+import {
+ DataSource,
+ Prisma,
+ SymbolProfile,
+ SymbolProfileOverrides
+} from '@prisma/client';
import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@Injectable()
export class SymbolProfileService {
- constructor(private readonly prismaService: PrismaService) {}
+ public constructor(private readonly prismaService: PrismaService) {}
+
+ public async delete({
+ dataSource,
+ symbol
+ }: {
+ dataSource: DataSource;
+ symbol: string;
+ }) {
+ return this.prismaService.symbolProfile.delete({
+ where: { dataSource_symbol: { dataSource, symbol } }
+ });
+ }
+
+ public async deleteById(id: string) {
+ return this.prismaService.symbolProfile.delete({
+ where: { id }
+ });
+ }
public async getSymbolProfiles(
symbols: string[]
): Promise {
return this.prismaService.symbolProfile
.findMany({
+ include: { SymbolProfileOverrides: true },
where: {
symbol: {
in: symbols
@@ -27,14 +51,38 @@ export class SymbolProfileService {
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
}
- private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] {
- return symbolProfiles.map((symbolProfile) => ({
- ...symbolProfile,
- countries: this.getCountries(symbolProfile),
- scraperConfiguration: this.getScraperConfiguration(symbolProfile),
- sectors: this.getSectors(symbolProfile),
- symbolMapping: this.getSymbolMapping(symbolProfile)
- }));
+ private getSymbols(
+ symbolProfiles: (SymbolProfile & {
+ SymbolProfileOverrides: SymbolProfileOverrides;
+ })[]
+ ): EnhancedSymbolProfile[] {
+ return symbolProfiles.map((symbolProfile) => {
+ const item = {
+ ...symbolProfile,
+ countries: this.getCountries(symbolProfile),
+ scraperConfiguration: this.getScraperConfiguration(symbolProfile),
+ sectors: this.getSectors(symbolProfile),
+ symbolMapping: this.getSymbolMapping(symbolProfile)
+ };
+
+ if (item.SymbolProfileOverrides) {
+ item.assetClass =
+ item.SymbolProfileOverrides.assetClass ?? item.assetClass;
+ item.assetSubClass =
+ item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
+ item.countries =
+ (item.SymbolProfileOverrides.countries as unknown as Country[]) ??
+ item.countries;
+ item.name = item.SymbolProfileOverrides?.name ?? item.name;
+ item.sectors =
+ (item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
+ item.sectors;
+
+ delete item.SymbolProfileOverrides;
+ }
+
+ return item;
+ });
}
private getCountries(symbolProfile: SymbolProfile): Country[] {
@@ -61,6 +109,7 @@ export class SymbolProfileService {
if (scraperConfiguration) {
return {
+ defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string
};
diff --git a/apps/api/src/services/tag/tag.module.ts b/apps/api/src/services/tag/tag.module.ts
new file mode 100644
index 000000000..32d905884
--- /dev/null
+++ b/apps/api/src/services/tag/tag.module.ts
@@ -0,0 +1,11 @@
+import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
+import { Module } from '@nestjs/common';
+
+import { TagService } from './tag.service';
+
+@Module({
+ exports: [TagService],
+ imports: [PrismaModule],
+ providers: [TagService]
+})
+export class TagModule {}
diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts
new file mode 100644
index 000000000..534a6e73d
--- /dev/null
+++ b/apps/api/src/services/tag/tag.service.ts
@@ -0,0 +1,30 @@
+import { PrismaService } from '@ghostfolio/api/services/prisma.service';
+import { Injectable } from '@nestjs/common';
+
+@Injectable()
+export class TagService {
+ public constructor(private readonly prismaService: PrismaService) {}
+
+ public async get() {
+ return this.prismaService.tag.findMany({
+ orderBy: {
+ name: 'asc'
+ }
+ });
+ }
+
+ public async getByUser(userId: string) {
+ return this.prismaService.tag.findMany({
+ orderBy: {
+ name: 'asc'
+ },
+ where: {
+ orders: {
+ some: {
+ userId
+ }
+ }
+ }
+ });
+ }
+}
diff --git a/apps/api/src/services/twitter-bot/twitter-bot.module.ts b/apps/api/src/services/twitter-bot/twitter-bot.module.ts
new file mode 100644
index 000000000..d74d6f10f
--- /dev/null
+++ b/apps/api/src/services/twitter-bot/twitter-bot.module.ts
@@ -0,0 +1,11 @@
+import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
+import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
+import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
+import { Module } from '@nestjs/common';
+
+@Module({
+ exports: [TwitterBotService],
+ imports: [ConfigurationModule, SymbolModule],
+ providers: [TwitterBotService]
+})
+export class TwitterBotModule {}
diff --git a/apps/api/src/services/twitter-bot/twitter-bot.service.ts b/apps/api/src/services/twitter-bot/twitter-bot.service.ts
new file mode 100644
index 000000000..58052872b
--- /dev/null
+++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts
@@ -0,0 +1,65 @@
+import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
+import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
+import {
+ ghostfolioFearAndGreedIndexDataSource,
+ ghostfolioFearAndGreedIndexSymbol
+} from '@ghostfolio/common/config';
+import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
+import { Injectable, Logger } from '@nestjs/common';
+import { isSunday } from 'date-fns';
+import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
+
+@Injectable()
+export class TwitterBotService {
+ private twitterClient: TwitterApiReadWrite;
+
+ public constructor(
+ private readonly configurationService: ConfigurationService,
+ private readonly symbolService: SymbolService
+ ) {
+ this.twitterClient = new TwitterApi({
+ accessSecret: this.configurationService.get(
+ 'TWITTER_ACCESS_TOKEN_SECRET'
+ ),
+ accessToken: this.configurationService.get('TWITTER_ACCESS_TOKEN'),
+ appKey: this.configurationService.get('TWITTER_API_KEY'),
+ appSecret: this.configurationService.get('TWITTER_API_SECRET')
+ }).readWrite;
+ }
+
+ public async tweetFearAndGreedIndex() {
+ if (
+ !this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
+ isSunday(new Date())
+ ) {
+ return;
+ }
+
+ try {
+ const symbolItem = await this.symbolService.get({
+ dataGatheringItem: {
+ dataSource: ghostfolioFearAndGreedIndexDataSource,
+ symbol: ghostfolioFearAndGreedIndexSymbol
+ }
+ });
+
+ if (symbolItem?.marketPrice) {
+ const { emoji, text } = resolveFearAndGreedIndex(
+ symbolItem.marketPrice
+ );
+
+ const status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)\n\n#FearAndGreed #Markets #ServiceTweet`;
+ const { data: createdTweet } = await this.twitterClient.v2.tweet(
+ status
+ );
+
+ Logger.log(
+ `Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`,
+ 'TwitterBotService'
+ );
+ }
+ } catch (error) {
+ Logger.error(error, 'TwitterBotService');
+ }
+ }
+}
diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json
index 62d262ff8..44e62fa9f 100644
--- a/apps/api/tsconfig.app.json
+++ b/apps/api/tsconfig.app.json
@@ -6,6 +6,6 @@
"emitDecoratorMetadata": true,
"target": "es2015"
},
- "exclude": ["**/*.spec.ts", "**/*.test.ts"],
+ "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
"include": ["**/*.ts"]
}
diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json
index 07c165e42..148da8555 100644
--- a/apps/api/tsconfig.spec.json
+++ b/apps/api/tsconfig.spec.json
@@ -5,5 +5,5 @@
"module": "commonjs",
"types": ["jest", "node"]
},
- "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"]
+ "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
}
diff --git a/apps/client/jest.config.js b/apps/client/jest.config.ts
similarity index 86%
rename from apps/client/jest.config.js
rename to apps/client/jest.config.ts
index 15d6e93be..f8f7806f2 100644
--- a/apps/client/jest.config.js
+++ b/apps/client/jest.config.ts
@@ -1,6 +1,6 @@
module.exports = {
displayName: 'client',
- preset: '../../jest.preset.js',
+
setupFilesAfterEnv: ['/src/test-setup.ts'],
globals: {
'ts-jest': {
@@ -17,5 +17,6 @@ module.exports = {
transform: {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
},
- transformIgnorePatterns: ['node_modules/(?!.*.mjs$)']
+ transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
+ preset: '../../jest.preset.ts'
};
diff --git a/apps/client/src/app/adapter/custom-date-adapter.ts b/apps/client/src/app/adapter/custom-date-adapter.ts
index 5c5be28f9..74bf7dd7a 100644
--- a/apps/client/src/app/adapter/custom-date-adapter.ts
+++ b/apps/client/src/app/adapter/custom-date-adapter.ts
@@ -1,14 +1,15 @@
import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
-import { format, isValid } from 'date-fns';
-import * as deDateFnsLocale from 'date-fns/locale/de/index';
+import { getDateFormatString } from '@ghostfolio/common/helper';
+import { format, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter {
/**
* @constructor
*/
public constructor(
+ @Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
platform: Platform
) {
@@ -16,50 +17,23 @@ export class CustomDateAdapter extends NativeDateAdapter {
}
/**
- * Sets the first day of the week to Monday
+ * Formats a date as a string
*/
- public getFirstDayOfWeek(): number {
- return 1;
+ public format(aDate: Date, aParseFormat: string): string {
+ return format(aDate, getDateFormatString(this.locale));
}
/**
- * Formats a date as a string according to the given format
+ * Sets the first day of the week to Monday
*/
- public format(aDate: Date, aParseFormat: string): string {
- return format(aDate, aParseFormat, {
- locale: deDateFnsLocale
- });
+ public getFirstDayOfWeek(): number {
+ return 1;
}
/**
* Parses a date from a provided value
*/
- public parse(aValue: any): Date {
- let date: Date;
-
- try {
- // TODO
- // Native date parser from the following formats:
- // - 'd.M.yyyy'
- // - 'dd.MM.yyyy'
- // https://github.com/you-dont-need/You-Dont-Need-Momentjs#string--date-format
- const datePattern = /^(\d{1,2}).(\d{1,2}).(\d{4})$/;
- const [, day, month, year] = datePattern.exec(aValue);
-
- date = new Date(
- parseInt(year, 10),
- parseInt(month, 10) - 1, // monthIndex
- parseInt(day, 10)
- );
- } catch (error) {
- } finally {
- const isDateValid = date && isValid(date);
-
- if (isDateValid) {
- return date;
- }
-
- return null;
- }
+ public parse(aValue: string): Date {
+ return parse(aValue, getDateFormatString(this.locale), new Date());
}
}
diff --git a/apps/client/src/app/adapter/date-formats.ts b/apps/client/src/app/adapter/date-formats.ts
index 554f7c76e..fdf32bef8 100644
--- a/apps/client/src/app/adapter/date-formats.ts
+++ b/apps/client/src/app/adapter/date-formats.ts
@@ -1,16 +1,14 @@
-import {
- DEFAULT_DATE_FORMAT,
- DEFAULT_DATE_FORMAT_MONTH_YEAR
-} from '@ghostfolio/common/config';
+import { DEFAULT_DATE_FORMAT_MONTH_YEAR } from '@ghostfolio/common/config';
+import { getDateFormatString } from '@ghostfolio/common/helper';
export const DateFormats = {
display: {
- dateInput: DEFAULT_DATE_FORMAT,
+ dateInput: getDateFormatString(),
monthYearLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR,
- dateA11yLabel: DEFAULT_DATE_FORMAT,
+ dateA11yLabel: getDateFormatString(),
monthYearA11yLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR
},
parse: {
- dateInput: DEFAULT_DATE_FORMAT
+ dateInput: getDateFormatString()
}
};
diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts
index 31521e686..28a9d1b49 100644
--- a/apps/client/src/app/app-routing.module.ts
+++ b/apps/client/src/app/app-routing.module.ts
@@ -9,6 +9,13 @@ const routes: Routes = [
loadChildren: () =>
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',
loadChildren: () =>
@@ -33,6 +40,11 @@ const routes: Routes = [
loadChildren: () =>
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',
loadChildren: () =>
@@ -47,6 +59,20 @@ const routes: Routes = [
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).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: 'features',
+ loadChildren: () =>
+ import('./pages/features/features-page.module').then(
+ (m) => m.FeaturesPageModule
+ )
+ },
{
path: 'home',
loadChildren: () =>
@@ -66,6 +92,13 @@ const routes: Routes = [
(m) => m.PortfolioPageModule
)
},
+ {
+ path: 'portfolio/activities',
+ loadChildren: () =>
+ import('./pages/portfolio/transactions/transactions-page.module').then(
+ (m) => m.TransactionsPageModule
+ )
+ },
{
path: 'portfolio/allocations',
loadChildren: () =>
@@ -81,17 +114,17 @@ const routes: Routes = [
)
},
{
- path: 'portfolio/report',
+ path: 'portfolio/fire',
loadChildren: () =>
- import('./pages/portfolio/report/report-page.module').then(
- (m) => m.ReportPageModule
+ import('./pages/portfolio/fire/fire-page.module').then(
+ (m) => m.FirePageModule
)
},
{
- path: 'portfolio/transactions',
+ path: 'portfolio/report',
loadChildren: () =>
- import('./pages/portfolio/transactions/transactions-page.module').then(
- (m) => m.TransactionsPageModule
+ import('./pages/portfolio/report/report-page.module').then(
+ (m) => m.ReportPageModule
)
},
{
diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html
index c878550a4..e9c8d0293 100644
--- a/apps/client/src/app/app.component.html
+++ b/apps/client/src/app/app.component.html
@@ -9,18 +9,27 @@
-
+
-
+
+
+ {{ info.systemMessage }}
+
diff --git a/apps/client/src/app/app.component.scss b/apps/client/src/app/app.component.scss
index bcefe5d71..9afa6eaab 100644
--- a/apps/client/src/app/app.component.scss
+++ b/apps/client/src/app/app.component.scss
@@ -8,14 +8,13 @@
min-height: 100vh;
padding-top: 5rem;
- .create-account-container {
+ .info-message-container {
height: 3.5rem;
margin-top: -0.5rem;
- .create-account-box {
+ .info-message {
background-color: rgba(0, 0, 0, $alpha-hover);
border-radius: 2rem;
- cursor: pointer;
font-size: 80%;
a {
diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts
index aeec70e6b..7f6774f52 100644
--- a/apps/client/src/app/app.component.ts
+++ b/apps/client/src/app/app.component.ts
@@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.tokenStorageService.signOut();
this.userService.remove();
- window.location.reload();
+ document.location.href = '/';
}
public ngOnDestroy() {
diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.html b/apps/client/src/app/components/accounts-table/accounts-table.component.html
index 1871e7f3c..08c6c3de9 100644
--- a/apps/client/src/app/components/accounts-table/accounts-table.component.html
+++ b/apps/client/src/app/components/accounts-table/accounts-table.component.html
@@ -15,7 +15,7 @@
>(Default)
-
Total
+
Total
@@ -78,32 +78,54 @@
-
+
Cash Balance
-
+
-
+
-
+
Value
-
+
-
+
+
+
+
+
+
+
+ Value
+
+
+
+
+
@@ -133,16 +194,17 @@
-
- Edit
+
+
+ Edit
0"
+ [disabled]="element.isDefault || element.transactionCount > 0"
(click)="onDeleteAccount(element.id)"
>
- Delete
+
+ Delete
diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.ts b/apps/client/src/app/components/accounts-table/accounts-table.component.ts
index ed0b9b298..4d82b5ed6 100644
--- a/apps/client/src/app/components/accounts-table/accounts-table.component.ts
+++ b/apps/client/src/app/components/accounts-table/accounts-table.component.ts
@@ -24,8 +24,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() deviceType: string;
@Input() locale: string;
@Input() showActions: boolean;
- @Input() totalBalance: number;
- @Input() totalValue: number;
+ @Input() totalBalanceInBaseCurrency: number;
+ @Input() totalValueInBaseCurrency: number;
@Input() transactionCount: number;
@Output() accountDeleted = new EventEmitter
();
@@ -46,11 +46,12 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnChanges() {
this.displayedColumns = [
'account',
- 'currency',
'platform',
'transactions',
'balance',
- 'value'
+ 'value',
+ 'currency',
+ 'valueInBaseCurrency'
];
if (this.showActions) {
diff --git a/apps/client/src/app/components/accounts-table/accounts-table.module.ts b/apps/client/src/app/components/accounts-table/accounts-table.module.ts
index 215bfd405..edbdee3ea 100644
--- a/apps/client/src/app/components/accounts-table/accounts-table.module.ts
+++ b/apps/client/src/app/components/accounts-table/accounts-table.module.ts
@@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
+import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
-import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { AccountsTableComponent } from './accounts-table.component';
@NgModule({
diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html
index 2c67da932..7264be84d 100644
--- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html
+++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html
@@ -1,24 +1,37 @@
-
+
+
{{ itemByMonth.key }}
diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss
index b5dabd463..13db0835b 100644
--- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss
+++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss
@@ -14,6 +14,10 @@
margin-right: 0.25rem;
width: 0.5rem;
+ &:hover {
+ opacity: 0.8;
+ }
+
&.valid {
background-color: var(--danger);
}
@@ -21,5 +25,10 @@
&.available {
background-color: var(--success);
}
+
+ &.today {
+ background-color: rgba(var(--palette-accent-500), 1);
+ cursor: default;
+ }
}
}
diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
index 83f4d65a3..b4a715a95 100644
--- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
+++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
@@ -1,15 +1,31 @@
import {
ChangeDetectionStrategy,
Component,
+ EventEmitter,
Input,
OnChanges,
- OnInit
+ OnInit,
+ Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
-import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
-import { DATE_FORMAT } from '@ghostfolio/common/helper';
-import { MarketData } from '@prisma/client';
-import { format, isBefore, isValid, parse } from 'date-fns';
+import { UserService } from '@ghostfolio/client/services/user/user.service';
+import {
+ DATE_FORMAT,
+ getDateFormatString,
+ getLocale
+} from '@ghostfolio/common/helper';
+import { User } from '@ghostfolio/common/interfaces';
+import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
+import { DataSource, MarketData } from '@prisma/client';
+import {
+ addDays,
+ format,
+ isBefore,
+ isSameDay,
+ isValid,
+ parse,
+ parseISO
+} from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
@@ -22,30 +38,78 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
templateUrl: './admin-market-data-detail.component.html'
})
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
+ @Input() dataSource: DataSource;
+ @Input() dateOfFirstActivity: string;
+ @Input() locale = getLocale();
@Input() marketData: MarketData[];
+ @Input() symbol: string;
+
+ @Output() marketDataChanged = new EventEmitter
();
public days = Array(31);
- public defaultDateFormat = DEFAULT_DATE_FORMAT;
+ public defaultDateFormat: string;
public deviceType: string;
+ public historicalDataItems: LineChartItem[];
public marketDataByMonth: {
- [yearMonth: string]: { [day: string]: MarketData & { day: number } };
+ [yearMonth: string]: {
+ [day: string]: Pick & { day: number };
+ };
} = {};
+ public user: User;
private unsubscribeSubject = new Subject();
public constructor(
private deviceService: DeviceDetectorService,
- private dialog: MatDialog
+ private dialog: MatDialog,
+ private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
+
+ this.userService.stateChanged
+ .pipe(takeUntil(this.unsubscribeSubject))
+ .subscribe((state) => {
+ if (state?.user) {
+ this.user = state.user;
+ }
+ });
}
public ngOnInit() {}
public ngOnChanges() {
+ this.defaultDateFormat = getDateFormatString(this.locale);
+
+ this.historicalDataItems = this.marketData.map((marketDataItem) => {
+ return {
+ date: format(marketDataItem.date, DATE_FORMAT),
+ value: marketDataItem.marketPrice
+ };
+ });
+
+ let date = parseISO(this.dateOfFirstActivity);
+
+ const missingMarketData: Partial[] = [];
+
+ if (this.historicalDataItems?.[0]?.date) {
+ while (
+ isBefore(
+ date,
+ parse(this.historicalDataItems[0].date, DATE_FORMAT, new Date())
+ )
+ ) {
+ missingMarketData.push({
+ date,
+ marketPrice: undefined
+ });
+
+ date = addDays(date, 1);
+ }
+ }
+
this.marketDataByMonth = {};
- for (const marketDataItem of this.marketData) {
+ for (const marketDataItem of [...missingMarketData, ...this.marketData]) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM');
@@ -53,9 +117,12 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
this.marketDataByMonth[key] = {};
}
- this.marketDataByMonth[key][currentDay] = {
- ...marketDataItem,
- day: currentDay
+ this.marketDataByMonth[key][
+ currentDay < 10 ? `0${currentDay}` : currentDay
+ ] = {
+ date: marketDataItem.date,
+ day: currentDay,
+ marketPrice: marketDataItem.marketPrice
};
}
}
@@ -66,12 +133,32 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
return isValid(date) && isBefore(date, new Date());
}
- public onOpenMarketDataDetail({ date, marketPrice, symbol }: MarketData) {
+ public isToday(aDateString: string) {
+ const date = parse(aDateString, DATE_FORMAT, new Date());
+ return isValid(date) && isSameDay(date, new Date());
+ }
+
+ public onOpenMarketDataDetail({
+ day,
+ yearMonth
+ }: {
+ day: string;
+ yearMonth: string;
+ }) {
+ const date = new Date(`${yearMonth}-${day}`);
+ const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
+
+ if (isSameDay(date, new Date())) {
+ return;
+ }
+
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: {
+ date,
marketPrice,
- symbol,
- date: format(date, DEFAULT_DATE_FORMAT)
+ dataSource: this.dataSource,
+ symbol: this.symbol,
+ user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
@@ -80,7 +167,9 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
- .subscribe(() => {});
+ .subscribe(({ withRefresh }) => {
+ this.marketDataChanged.next(withRefresh);
+ });
}
public ngOnDestroy() {
diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts
index 9ea09ab51..b51d497bf 100644
--- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts
+++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts
@@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common';
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 { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
@@ -7,7 +8,7 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark
@NgModule({
declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent],
- imports: [CommonModule, GfMarketDataDetailDialogModule],
+ imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts
index a7defb817..81360878b 100644
--- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts
+++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts
@@ -1,5 +1,10 @@
+import { User } from '@ghostfolio/common/interfaces';
+import { DataSource } from '@prisma/client';
+
export interface MarketDataDetailDialogParams {
- date: string;
+ dataSource: DataSource;
+ date: Date;
marketPrice: number;
symbol: string;
+ user: User;
}
diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts
index 4ad1ebaa3..8c761ccdb 100644
--- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts
+++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts
@@ -1,11 +1,14 @@
import {
ChangeDetectionStrategy,
+ ChangeDetectorRef,
Component,
Inject,
OnDestroy
} from '@angular/core';
+import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
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';
@@ -20,14 +23,50 @@ export class MarketDataDetailDialog implements OnDestroy {
private unsubscribeSubject = new Subject();
public constructor(
+ private adminService: AdminService,
+ private changeDetectorRef: ChangeDetectorRef,
+ @Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams,
+ private dateAdapter: DateAdapter,
public dialogRef: MatDialogRef,
- @Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams
+ @Inject(MAT_DATE_LOCALE) private locale: string
) {}
- public ngOnInit() {}
+ public ngOnInit() {
+ this.locale = this.data.user?.settings?.locale;
+ this.dateAdapter.setLocale(this.locale);
+ }
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() {
diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html
index 65b3578fe..3642a9e1d 100644
--- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html
+++ b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html
@@ -1,25 +1,50 @@