Compare commits
157 Commits
Author | SHA1 | Date | |
---|---|---|---|
26b9660e11 | |||
ca7717f9c5 | |||
6f3cce1c5f | |||
efdc9b387f | |||
d7b579e3e8 | |||
b8533050b0 | |||
1b81409b35 | |||
8cd6c34ed8 | |||
0c68474802 | |||
34997f91db | |||
084467ee9a | |||
af47889d65 | |||
51203ec96e | |||
a2277dea2c | |||
debd233c32 | |||
f1eeee0525 | |||
5ffc39c32f | |||
a668a66e84 | |||
0581b8b9ec | |||
63a61fb492 | |||
5788c6474e | |||
5529fdc0ee | |||
88a9b518f6 | |||
98de2355c4 | |||
b41eb60348 | |||
0edebe30e1 | |||
e3abe4feee | |||
50391e199a | |||
a33f8d5bed | |||
636be8441e | |||
654dc2ba32 | |||
458ef169f4 | |||
5bb01bb03c | |||
43e9528d8c | |||
522c54c9b4 | |||
0004ced4e1 | |||
274c60e961 | |||
754e98099c | |||
87bf8df1c3 | |||
3f7d6b25c7 | |||
8a062e03ab | |||
a70f45cbf3 | |||
f268264c46 | |||
bbe5d70720 | |||
f1d2a52cba | |||
87cc887865 | |||
61ecd15f1d | |||
eb853f05ae | |||
6285417903 | |||
ca674a654e | |||
2729c5651f | |||
7e28e42995 | |||
e21563d903 | |||
3ede69650c | |||
c289793c6d | |||
a90c067da0 | |||
38c2baf943 | |||
82c78cad6b | |||
bffe6060bd | |||
841bd5c33f | |||
3b895afc9e | |||
00c2ede85e | |||
8420cb830c | |||
a0ddd1f9b9 | |||
40d93066ff | |||
671e4e316b | |||
473136e9aa | |||
9a3db91982 | |||
d23cb5f190 | |||
7a364472c8 | |||
59c064e3c8 | |||
e792924606 | |||
d32dd5e860 | |||
bb86f85203 | |||
0bca8897d6 | |||
ba73f6de2e | |||
eb75be8535 | |||
6d2a897366 | |||
d8bfb23f20 | |||
d9d71e7827 | |||
b642ce08e5 | |||
bc8d8309d4 | |||
1f2f9f22f2 | |||
7a3237f1ff | |||
07661d9262 | |||
77358eed65 | |||
c641c28b12 | |||
c54392b7bb | |||
f3a8822a77 | |||
f1dc075c36 | |||
144d831954 | |||
c37ad9bad4 | |||
4ab3f81384 | |||
b932bac9aa | |||
bcdd873222 | |||
25b3de5828 | |||
40b454d2f3 | |||
5596e5f03b | |||
66992ef915 | |||
7f67430685 | |||
8a49a04324 | |||
5d7c19b0ed | |||
cde74b6c62 | |||
633c65e33c | |||
d1617f2d87 | |||
68e558f198 | |||
12ca01c862 | |||
2115745471 | |||
2cabd21315 | |||
3615e2f057 | |||
d3679d41b3 | |||
f2d431a6b8 | |||
2bc8bebfb8 | |||
5b20ba3382 | |||
15cc294581 | |||
b060b81204 | |||
a8d557eb1b | |||
6ae3a47b54 | |||
88c19eb45e | |||
7728706bc8 | |||
2e9d40c201 | |||
c002e37285 | |||
6be38a1c19 | |||
a3178fb213 | |||
e7158f6e16 | |||
dbea0456bc | |||
fefee11301 | |||
40836b745b | |||
07eabac059 | |||
48b412cfb8 | |||
b62488628c | |||
982c71c728 | |||
5aa16a3779 | |||
93de25e5b6 | |||
9acdb41aa2 | |||
ffbdfb86ec | |||
be7f6bb657 | |||
6f7cbc93b9 | |||
0b5c71130d | |||
0578c645d1 | |||
67ae86763e | |||
266c0a9a2c | |||
a3cdb23776 | |||
e1371a8d2b | |||
448cea0b69 | |||
ad42c0bf28 | |||
f50670c7fe | |||
c0029d3b1d | |||
2518a8fd9d | |||
572dcf075a | |||
29cb83d469 | |||
cac73ac111 | |||
02cf4295a9 | |||
78b3328bf7 | |||
e0d6d9e8ca | |||
54310f2214 | |||
1fec49fbc2 |
25
.env.dev
Normal file
25
.env.dev
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||||
|
|
||||||
|
# CACHE
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||||
|
|
||||||
|
# POSTGRES
|
||||||
|
POSTGRES_DB=ghostfolio-db
|
||||||
|
POSTGRES_USER=user
|
||||||
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
|
# VARIOUS
|
||||||
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
|
|
||||||
|
# DEVELOPMENT
|
||||||
|
|
||||||
|
# Nx 18 enables using plugins to infer targets by default
|
||||||
|
# This is disabled for existing workspaces to maintain compatibility
|
||||||
|
# For more info, see: https://nx.dev/concepts/inferred-tasks
|
||||||
|
NX_ADD_PLUGINS=false
|
||||||
|
|
||||||
|
NX_NATIVE_COMMAND_RUNNER=false
|
@ -1,4 +1,4 @@
|
|||||||
COMPOSE_PROJECT_NAME=ghostfolio-development
|
COMPOSE_PROJECT_NAME=ghostfolio
|
||||||
|
|
||||||
# CACHE
|
# CACHE
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
@ -10,6 +10,7 @@ POSTGRES_DB=ghostfolio-db
|
|||||||
POSTGRES_USER=user
|
POSTGRES_USER=user
|
||||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
|
# VARIOUS
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
|
@ -12,6 +12,12 @@
|
|||||||
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
|
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
|
||||||
"importOrderSeparation": true,
|
"importOrderSeparation": true,
|
||||||
"overrides": [
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"files": "*.ts",
|
"files": "*.ts",
|
||||||
"options": {
|
"options": {
|
||||||
|
296
CHANGELOG.md
296
CHANGELOG.md
@ -5,6 +5,302 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 2.70.0 - 2024-04-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set up the language localization for Chinese (`zh`)
|
||||||
|
- Added `init: true` to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`) to avoid zombie processes
|
||||||
|
- Set up _Webpack Bundle Analyzer_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Disabled the option to update the cash balance of an account if date is not today
|
||||||
|
- Improved the usability of the date range support by specific years (`2023`, `2022`, `2021`, etc.) in the assistant (experimental)
|
||||||
|
- Introduced a factory for the portfolio calculations to support different algorithms in future
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the duplicated tags in the position detail dialog
|
||||||
|
- Removed `Tini` from the docker image
|
||||||
|
|
||||||
|
## 2.69.0 - 2024-03-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the date range support in the activities table on the portfolio activities page (experimental)
|
||||||
|
- Extended the date range support by specific years (`2021`, `2022`, `2023`, etc.) in the assistant (experimental)
|
||||||
|
- Set up `Tini` to avoid zombie processes and perform signal forwarding in docker image
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability to delete an asset profile in the historical market data table and the asset profile details dialog of the admin control
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added missing dates to edit historical market data in the asset profile details dialog of the admin control panel
|
||||||
|
|
||||||
|
## 2.68.0 - 2024-03-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the export functionality by the user account’s currency
|
||||||
|
- Added support to override the name of an asset profile in the asset profile details dialog of the admin control
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the portfolio calculations
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the chart tooltip of the benchmark comparator
|
||||||
|
- Fixed an issue with names in the activities table on the portfolio activities page while using symbol profile overrides
|
||||||
|
|
||||||
|
## 2.67.0 - 2024-03-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the cryptocurrency _Toncoin_ (`TON11419-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Replaced `Math.random()` with `crypto.randomBytes()` for generating cryptographically secure random strings
|
||||||
|
- Upgraded `ionicons` from version `7.1.0` to `7.3.0`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.10.0` to `2.11.0`
|
||||||
|
- Upgraded `zone.js` from version `0.14.3` to `0.14.4`
|
||||||
|
|
||||||
|
## 2.66.3 - 2024-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the content of the _SaaS_ and _Self-Hosting_ sections by the backup strategy on the Frequently Asked Questions (FAQ) page
|
||||||
|
- Added an index for `dataSource` / `symbol` to the market data database table
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the chart tooltip of the benchmark comparator by adding the benchmark name
|
||||||
|
- Upgraded `angular` from version `17.1.3` to `17.2.4`
|
||||||
|
- Upgraded `Nx` from version `18.0.4` to `18.1.2`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the missing portfolio performance chart in the _Presenter View_ / _Zen Mode_
|
||||||
|
|
||||||
|
## 2.65.0 - 2024-03-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the symbol and ISIN number to the position detail dialog
|
||||||
|
- Added support to delete an asset profile in the asset profile details dialog of the admin control
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the support to grant private access with permissions from experimental to general availability
|
||||||
|
- Set the meta theme color dynamically to respect the appearance (dark mode)
|
||||||
|
- Improved the usability to edit market data in the admin control panel
|
||||||
|
|
||||||
|
## 2.64.0 - 2024-03-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a toggle to switch between active and closed holdings on the portfolio holdings page
|
||||||
|
- Added support to update the cash balance of an account when adding a fee activity
|
||||||
|
- Added support to update the cash balance of an account when adding an interest activity
|
||||||
|
- Extended the content of the _General_ section by the product roadmap on the Frequently Asked Questions (FAQ) page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the platform management in the admin control panel
|
||||||
|
- Improved the usability of the tag management in the admin control panel
|
||||||
|
- Improved the exception handling of various rules in the _X-ray_ section
|
||||||
|
- Increased the timeout to load benchmarks
|
||||||
|
- Upgraded `prisma` from version `5.10.2` to `5.11.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the dividend calculation of the portfolio holdings
|
||||||
|
- Fixed the date conversion of the import of historical market data in the admin control panel
|
||||||
|
|
||||||
|
## 2.63.2 - 2024-03-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the content of the _Self-Hosting_ section by available home server systems on the Frequently Asked Questions (FAQ) page
|
||||||
|
- Added support for the cryptocurrency _Real Smurf Cat_ (`SMURFCAT-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `8.3` to `9.0`
|
||||||
|
- Upgraded `countries-list` from version `2.6.1` to `3.1.0`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.9.1` to `2.10.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the performance calculation caused by multiple `SELL` activities on the same day
|
||||||
|
- Fixed an issue in the calculation on the allocations page caused by liabilities
|
||||||
|
- Fixed an issue with the currency in the request to get quotes from _EOD Historical Data_
|
||||||
|
|
||||||
|
## 2.62.0 - 2024-03-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the calculation of the accounts table
|
||||||
|
- Optimized the calculation of the portfolio holdings
|
||||||
|
- Integrated dividend into the transaction point concept in the portfolio service
|
||||||
|
- Removed the environment variable `WEB_AUTH_RP_ID`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
|
||||||
|
- Fixed an issue with removing a linked account from a (wealth) item activity
|
||||||
|
|
||||||
|
## 2.61.1 - 2024-03-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the account value calculation caused by liabilities
|
||||||
|
|
||||||
|
## 2.61.0 - 2024-03-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the calculation of the portfolio summary
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the activities import (query parameter handling)
|
||||||
|
|
||||||
|
## 2.60.0 - 2024-03-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the cryptocurrency _Uniswap_ (`UNI7083-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the benchmarks in the markets overview
|
||||||
|
- Integrated (wealth) items into the transaction point concept in the portfolio service
|
||||||
|
- Refreshed the cryptocurrencies list
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a missing value in the activities table on mobile
|
||||||
|
- Fixed a missing value on the public page
|
||||||
|
- Displayed the button to fetch the current market price only if the activity is from today
|
||||||
|
|
||||||
|
## 2.59.0 - 2024-02-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an index for `isExcluded` to the account database table
|
||||||
|
- Extended the content of the _Self-Hosting_ section on the Frequently Asked Questions (FAQ) page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the activities import by `isin` in the _Yahoo Finance_ service
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the exchange rate calculation of (wealth) items in accounts
|
||||||
|
|
||||||
|
## 2.58.0 - 2024-02-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the handling of activities without account
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the query to filter activities of excluded accounts
|
||||||
|
- Improved the asset profile validation in the activities import
|
||||||
|
|
||||||
|
## 2.57.0 - 2024-02-25
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the break down of the performance into asset and currency on the analysis page from experimental to general availability
|
||||||
|
- Restructured the `copy-assets` `Nx` target
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Changed the performances of the _Top 3_ and _Bottom 3_ performers on the analysis page to take the currency effects into account
|
||||||
|
|
||||||
|
## 2.56.0 - 2024-02-24
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Switched the performance calculations to take the currency effects into account
|
||||||
|
- Removed the `isDefault` flag from the `Account` database schema
|
||||||
|
- Exposed the database index of _Redis_ as an environment variable (`REDIS_DB`)
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `prisma` from version `5.9.1` to `5.10.2`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added the missing default currency to the prepare currencies function in the exchange rate data service
|
||||||
|
|
||||||
|
## 2.55.0 - 2024-02-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added indexes for `alias`, `granteeUserId` and `userId` to the access database table
|
||||||
|
- Added indexes for `currency`, `name` and `userId` to the account database table
|
||||||
|
- Added indexes for `accountId`, `date` and `updatedAt` to the account balance database table
|
||||||
|
- Added an index for `userId` to the auth device database table
|
||||||
|
- Added indexes for `marketPrice` and `state` to the market data database table
|
||||||
|
- Added indexes for `date`, `isDraft` and `userId` to the order database table
|
||||||
|
- Added an index for `name` to the platform database table
|
||||||
|
- Added indexes for `assetClass`, `currency`, `dataSource`, `isin`, `name` and `symbol` to the symbol profile database table
|
||||||
|
- Added an index for `userId` to the subscription database table
|
||||||
|
- Added an index for `name` to the tag database table
|
||||||
|
- Added indexes for `accessToken`, `createdAt`, `provider`, `role` and `thirdPartyId` to the user database table
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the validation for `currency` in various endpoints
|
||||||
|
- Harmonized the setting of a default locale in various components
|
||||||
|
- Set the parser to `angular` in the `prettier` options
|
||||||
|
|
||||||
|
## 2.54.0 - 2024-02-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an index for `id` to the account database table
|
||||||
|
- Added indexes for `dataSource` and `date` to the market data database table
|
||||||
|
- Added an index for `accountId` to the order database table
|
||||||
|
|
||||||
|
## 2.53.1 - 2024-02-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an accounts tab to the position detail dialog
|
||||||
|
- Added `INACTIVE` as a new user role
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the holdings table
|
||||||
|
- Refactored the query to filter activities of excluded accounts
|
||||||
|
- Eliminated the search request to get quotes in the _EOD Historical Data_ service
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `ng-extract-i18n-merge` from version `2.9.1` to `2.10.0`
|
||||||
|
|
||||||
|
## 2.52.0 - 2024-02-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a loading indicator to the dividend timeline on the analysis page
|
||||||
|
- Added a loading indicator to the investment timeline on the analysis page
|
||||||
|
- Added support for the cryptocurrency _Jupiter_ (`JUP29210-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Divided the content of the Frequently Asked Questions (FAQ) page into three sections: _General_, _Cloud (SaaS)_ and _Self-Hosting_
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page
|
||||||
|
- Fixed an issue with the X-axis scale of the investment timeline on the analysis page
|
||||||
|
|
||||||
## 2.51.0 - 2024-02-12
|
## 2.51.0 - 2024-02-12
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
@ -57,6 +57,7 @@ RUN apt update && apt install -y \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
|
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE ${PORT:-3333}
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "yarn", "start:production" ]
|
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
**Open Source Wealth Management Software**
|
**Open Source Wealth Management Software**
|
||||||
|
|
||||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
|
||||||
|
|
||||||
[](https://www.buymeacoffee.com/ghostfolio)
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
[](#contributing)
|
[](#contributing)
|
||||||
@ -99,6 +99,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
|
| `REDIS_DB` | `0` | The database index of _Redis_ |
|
||||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
@ -143,7 +144,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
|||||||
|
|
||||||
### Home Server Systems (Community)
|
### Home Server Systems (Community)
|
||||||
|
|
||||||
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@ -153,7 +154,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
|||||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- Create a local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
|
@ -13,7 +13,6 @@ export default {
|
|||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
testTimeout: 10000,
|
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
preset: '../../jest.preset.js'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -9,12 +9,13 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"executor": "@nx/webpack:webpack",
|
"executor": "@nx/webpack:webpack",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/apps/api",
|
|
||||||
"main": "apps/api/src/main.ts",
|
|
||||||
"tsConfig": "apps/api/tsconfig.app.json",
|
|
||||||
"assets": ["apps/api/src/assets"],
|
|
||||||
"target": "node",
|
|
||||||
"compiler": "tsc",
|
"compiler": "tsc",
|
||||||
|
"deleteOutputPath": false,
|
||||||
|
"main": "apps/api/src/main.ts",
|
||||||
|
"outputPath": "dist/apps/api",
|
||||||
|
"sourceMap": true,
|
||||||
|
"target": "node",
|
||||||
|
"tsConfig": "apps/api/tsconfig.app.json",
|
||||||
"webpackConfig": "apps/api/webpack.config.js"
|
"webpackConfig": "apps/api/webpack.config.js"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
@ -33,6 +34,26 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["{options.outputPath}"]
|
"outputs": ["{options.outputPath}"]
|
||||||
},
|
},
|
||||||
|
"copy-assets": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "shx rm -rf dist/apps/api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "shx mkdir -p dist/apps/api/assets/locales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parallel": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nx/js:node",
|
"executor": "@nx/js:node",
|
||||||
"options": {
|
"options": {
|
||||||
|
@ -83,7 +83,7 @@ export class AccessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.accessService.createAccess({
|
return this.accessService.createAccess({
|
||||||
alias: data.alias || undefined,
|
alias: data.alias || undefined,
|
||||||
GranteeUser: data.granteeUserId
|
GranteeUser: data.granteeUserId
|
||||||
? { connect: { id: data.granteeUserId } }
|
? { connect: { id: data.granteeUserId } }
|
||||||
|
@ -63,7 +63,7 @@ export class AccountController {
|
|||||||
{ Order: true }
|
{ Order: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (account?.isDefault || account?.Order.length > 0) {
|
if (!account || account?.Order.length > 0) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
|
@ -5,7 +5,7 @@ import { Filter } from '@ghostfolio/common/interfaces';
|
|||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
@ -21,10 +21,8 @@ export class AccountService {
|
|||||||
public async account({
|
public async account({
|
||||||
id_userId
|
id_userId
|
||||||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||||
const { id, userId } = id_userId;
|
|
||||||
|
|
||||||
const [account] = await this.accounts({
|
const [account] = await this.accounts({
|
||||||
where: { id, userId }
|
where: id_userId
|
||||||
});
|
});
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -19,7 +20,7 @@ export class CreateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsISO4217CurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -19,7 +20,7 @@ export class UpdateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsISO4217CurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ -339,6 +339,6 @@ export class AdminController {
|
|||||||
@Param('key') key: string,
|
@Param('key') key: string,
|
||||||
@Body() data: PropertyDto
|
@Body() data: PropertyDto
|
||||||
) {
|
) {
|
||||||
return await this.adminService.putSetting(key, data.value);
|
return this.adminService.putSetting(key, data.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -71,7 +71,7 @@ export class AdminService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.symbolProfileService.add(
|
return this.symbolProfileService.add(
|
||||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -212,6 +212,7 @@ export class AdminService {
|
|||||||
countries: true,
|
countries: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
@ -226,7 +227,7 @@ export class AdminService {
|
|||||||
this.prismaService.symbolProfile.count({ where })
|
this.prismaService.symbolProfile.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let marketData = assetProfiles.map(
|
let marketData: AdminMarketDataItem[] = assetProfiles.map(
|
||||||
({
|
({
|
||||||
_count,
|
_count,
|
||||||
assetClass,
|
assetClass,
|
||||||
@ -235,6 +236,7 @@ export class AdminService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
id,
|
||||||
name,
|
name,
|
||||||
Order,
|
Order,
|
||||||
sectors,
|
sectors,
|
||||||
@ -257,6 +259,7 @@ export class AdminService {
|
|||||||
currency,
|
currency,
|
||||||
countriesCount,
|
countriesCount,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
id,
|
||||||
name,
|
name,
|
||||||
symbol,
|
symbol,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
@ -331,19 +334,35 @@ export class AdminService {
|
|||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
await this.symbolProfileService.updateSymbolProfile({
|
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
|
||||||
assetClass,
|
{
|
||||||
assetSubClass,
|
assetClass,
|
||||||
comment,
|
assetSubClass,
|
||||||
countries,
|
comment,
|
||||||
currency,
|
countries,
|
||||||
dataSource,
|
currency,
|
||||||
name,
|
dataSource,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
sectors,
|
sectors,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping,
|
||||||
});
|
...(dataSource === 'MANUAL'
|
||||||
|
? { name }
|
||||||
|
: {
|
||||||
|
SymbolProfileOverrides: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
name: name as string
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: name as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
||||||
|
|
||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
{
|
{
|
||||||
@ -397,6 +416,7 @@ export class AdminService {
|
|||||||
assetClass: 'CASH',
|
assetClass: 'CASH',
|
||||||
countriesCount: 0,
|
countriesCount: 0,
|
||||||
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||||
|
id: undefined,
|
||||||
name: symbol,
|
name: symbol,
|
||||||
sectorsCount: 0
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
@ -440,13 +460,14 @@ export class AdminService {
|
|||||||
},
|
},
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
id: true,
|
id: true,
|
||||||
|
role: true,
|
||||||
Subscription: true
|
Subscription: true
|
||||||
},
|
},
|
||||||
take: 30
|
take: 30
|
||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
({ _count, Analytics, createdAt, id, role, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics
|
const engagement = Analytics
|
||||||
@ -466,6 +487,7 @@ export class AdminService {
|
|||||||
createdAt,
|
createdAt,
|
||||||
engagement,
|
engagement,
|
||||||
id,
|
id,
|
||||||
|
role,
|
||||||
subscription,
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
country: Analytics?.country,
|
country: Analytics?.country,
|
||||||
|
@ -2,6 +2,7 @@ import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
@ -24,7 +25,7 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
countries?: Prisma.InputJsonArray;
|
countries?: Prisma.InputJsonArray;
|
||||||
|
|
||||||
@IsString()
|
@IsISO4217CurrencyCode()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
|
||||||
|
@ -53,6 +53,7 @@ import { UserModule } from './user/user.module';
|
|||||||
BenchmarkModule,
|
BenchmarkModule,
|
||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
redis: {
|
redis: {
|
||||||
|
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
||||||
host: process.env.REDIS_HOST,
|
host: process.env.REDIS_HOST,
|
||||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||||
password: process.env.REDIS_PASSWORD
|
password: process.env.REDIS_PASSWORD
|
||||||
|
@ -41,7 +41,7 @@ export class WebAuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
get rpID() {
|
get rpID() {
|
||||||
return this.configurationService.get('WEB_AUTH_RP_ID');
|
return new URL(this.configurationService.get('ROOT_URL')).hostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
get expectedOrigin() {
|
get expectedOrigin() {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import type {
|
import type {
|
||||||
@ -8,7 +9,7 @@ import type {
|
|||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -19,6 +20,7 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -106,13 +108,18 @@ export class BenchmarkController {
|
|||||||
public async getBenchmarkMarketDataBySymbol(
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('startDateString') startDateString: string,
|
@Param('startDateString') startDateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max'
|
||||||
): Promise<BenchmarkMarketDataDetails> {
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
const startDate = new Date(startDateString);
|
const { endDate, startDate } = getInterval(
|
||||||
|
dateRange,
|
||||||
|
new Date(startDateString)
|
||||||
|
);
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
return this.benchmarkService.getMarketDataBySymbol({
|
return this.benchmarkService.getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
|
endDate,
|
||||||
startDate,
|
startDate,
|
||||||
symbol,
|
symbol,
|
||||||
userCurrency
|
userCurrency
|
||||||
|
@ -13,7 +13,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
calculateBenchmarkTrend,
|
calculateBenchmarkTrend,
|
||||||
parseDate
|
parseDate,
|
||||||
|
resetHours
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
Benchmark,
|
Benchmark,
|
||||||
@ -26,8 +27,14 @@ import { BenchmarkTrend } from '@ghostfolio/common/types';
|
|||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { format, isSameDay, subDays } from 'date-fns';
|
import {
|
||||||
|
differenceInDays,
|
||||||
|
eachDayOfInterval,
|
||||||
|
format,
|
||||||
|
isSameDay,
|
||||||
|
subDays
|
||||||
|
} from 'date-fns';
|
||||||
import { isNumber, last, uniqBy } from 'lodash';
|
import { isNumber, last, uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
@ -110,7 +117,9 @@ export class BenchmarkService {
|
|||||||
const quotes = await this.dataProviderService.getQuotes({
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
})
|
}),
|
||||||
|
requestTimeout: ms('30 seconds'),
|
||||||
|
useCache: false
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
@ -163,7 +172,7 @@ export class BenchmarkService {
|
|||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
this.CACHE_KEY_BENCHMARKS,
|
this.CACHE_KEY_BENCHMARKS,
|
||||||
JSON.stringify(benchmarks),
|
JSON.stringify(benchmarks),
|
||||||
ms('4 hours') / 1000
|
ms('2 hours') / 1000
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,15 +215,28 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
public async getMarketDataBySymbol({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
|
endDate = new Date(),
|
||||||
startDate,
|
startDate,
|
||||||
symbol,
|
symbol,
|
||||||
userCurrency
|
userCurrency
|
||||||
}: {
|
}: {
|
||||||
|
endDate?: Date;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||||
const marketData: { date: string; value: number }[] = [];
|
const marketData: { date: string; value: number }[] = [];
|
||||||
|
|
||||||
|
const days = differenceInDays(endDate, startDate) + 1;
|
||||||
|
const dates = eachDayOfInterval(
|
||||||
|
{
|
||||||
|
start: startDate,
|
||||||
|
end: endDate
|
||||||
|
},
|
||||||
|
{ step: Math.round(days / Math.min(days, MAX_CHART_ITEMS)) }
|
||||||
|
).map((date) => {
|
||||||
|
return resetHours(date);
|
||||||
|
});
|
||||||
|
|
||||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
this.symbolService.get({
|
this.symbolService.get({
|
||||||
dataGatheringItem: {
|
dataGatheringItem: {
|
||||||
@ -230,7 +252,7 @@ export class BenchmarkService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: {
|
date: {
|
||||||
gte: startDate
|
in: dates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -264,17 +286,7 @@ export class BenchmarkService {
|
|||||||
return { marketData };
|
return { marketData };
|
||||||
}
|
}
|
||||||
|
|
||||||
const step = Math.round(
|
|
||||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
|
||||||
);
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
for (let marketDataItem of marketDataItems) {
|
for (let marketDataItem of marketDataItems) {
|
||||||
if (i % step !== 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exchangeRate =
|
const exchangeRate =
|
||||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
format(marketDataItem.date, DATE_FORMAT)
|
format(marketDataItem.date, DATE_FORMAT)
|
||||||
@ -297,15 +309,15 @@ export class BenchmarkService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const includesToday = isSameDay(
|
const includesEndDate = isSameDay(
|
||||||
parseDate(last(marketData).date),
|
parseDate(last(marketData).date),
|
||||||
new Date()
|
endDate
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentSymbolItem?.marketPrice && !includesToday) {
|
if (currentSymbolItem?.marketPrice && !includesEndDate) {
|
||||||
const exchangeRate =
|
const exchangeRate =
|
||||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
format(new Date(), DATE_FORMAT)
|
format(endDate, DATE_FORMAT)
|
||||||
];
|
];
|
||||||
|
|
||||||
const exchangeRateFactor =
|
const exchangeRateFactor =
|
||||||
@ -314,7 +326,7 @@ export class BenchmarkService {
|
|||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
marketData.push({
|
marketData.push({
|
||||||
date: format(new Date(), DATE_FORMAT),
|
date: format(endDate, DATE_FORMAT),
|
||||||
value:
|
value:
|
||||||
this.calculateChangeInPercentage(
|
this.calculateChangeInPercentage(
|
||||||
marketPriceAtStartDate,
|
marketPriceAtStartDate,
|
||||||
|
@ -95,7 +95,10 @@ export class ExportService {
|
|||||||
: SymbolProfile.symbol
|
: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
|
user: {
|
||||||
|
settings: { currency: userCurrency }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,8 +43,10 @@ export class ImportController {
|
|||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async import(
|
public async import(
|
||||||
@Body() importData: ImportDataDto,
|
@Body() importData: ImportDataDto,
|
||||||
@Query('dryRun') isDryRun?: boolean
|
@Query('dryRun') isDryRunParam = 'false'
|
||||||
): Promise<ImportResponse> {
|
): Promise<ImportResponse> {
|
||||||
|
const isDryRun = isDryRunParam === 'true';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||||
) {
|
) {
|
||||||
|
@ -27,7 +27,7 @@ import {
|
|||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@ -117,7 +117,7 @@ export class ImportService {
|
|||||||
feeInBaseCurrency: 0,
|
feeInBaseCurrency: 0,
|
||||||
id: assetProfile.id,
|
id: assetProfile.id,
|
||||||
isDraft: false,
|
isDraft: false,
|
||||||
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
SymbolProfile: assetProfile,
|
||||||
symbolProfileId: assetProfile.id,
|
symbolProfileId: assetProfile.id,
|
||||||
type: 'DIVIDEND',
|
type: 'DIVIDEND',
|
||||||
unitPrice: marketPrice,
|
unitPrice: marketPrice,
|
||||||
@ -521,22 +521,14 @@ export class ImportService {
|
|||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
assetClass: null,
|
activitiesCount: undefined,
|
||||||
assetSubClass: null,
|
assetClass: undefined,
|
||||||
comment: null,
|
assetSubClass: undefined,
|
||||||
countries: null,
|
countries: undefined,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
figi: null,
|
|
||||||
figiComposite: null,
|
|
||||||
figiShareClass: null,
|
|
||||||
id: undefined,
|
id: undefined,
|
||||||
isin: null,
|
sectors: undefined,
|
||||||
name: null,
|
updatedAt: undefined
|
||||||
scraperConfiguration: null,
|
|
||||||
sectors: null,
|
|
||||||
symbolMapping: null,
|
|
||||||
updatedAt: undefined,
|
|
||||||
url: null
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -570,17 +562,10 @@ export class ImportService {
|
|||||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const uniqueActivitiesDto = uniqBy(
|
|
||||||
activitiesDto,
|
|
||||||
({ dataSource, symbol }) => {
|
|
||||||
return getAssetProfileIdentifier({ dataSource, symbol });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, symbol, type }
|
{ currency, dataSource, symbol, type }
|
||||||
] of uniqueActivitiesDto.entries()) {
|
] of activitiesDto.entries()) {
|
||||||
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||||
@ -602,37 +587,33 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetProfile = {
|
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
|
||||||
currency,
|
const assetProfile = {
|
||||||
...(
|
currency,
|
||||||
await this.dataProviderService.getAssetProfiles([
|
...(
|
||||||
{ dataSource, symbol }
|
await this.dataProviderService.getAssetProfiles([
|
||||||
])
|
{ dataSource, symbol }
|
||||||
)?.[symbol]
|
])
|
||||||
};
|
)?.[symbol]
|
||||||
|
};
|
||||||
|
|
||||||
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
||||||
if (!assetProfile?.name) {
|
if (!assetProfile?.name) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetProfile.currency !== currency) {
|
||||||
|
throw new Error(
|
||||||
|
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||||
assetProfile.currency !== currency &&
|
assetProfile;
|
||||||
!this.exchangeRateDataService.hasCurrencyPair(
|
|
||||||
currency,
|
|
||||||
assetProfile.currency
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
|
||||||
assetProfile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return assetProfiles;
|
return assetProfiles;
|
||||||
|
@ -60,10 +60,6 @@ export class InfoService {
|
|||||||
|
|
||||||
const globalPermissions: string[] = [];
|
const globalPermissions: string[] = [];
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
|
|
||||||
globalPermissions.push(permissions.enableBlog);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
info.fearAndGreedDataSource = encodeDataSource(
|
info.fearAndGreedDataSource = encodeDataSource(
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@ -38,7 +39,7 @@ export class CreateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsISO4217CurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ -1,13 +1,19 @@
|
|||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
|
||||||
|
import { AccountWithPlatform } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Order, Tag } from '@prisma/client';
|
||||||
|
|
||||||
export interface Activities {
|
export interface Activities {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends Order {
|
||||||
|
Account?: AccountWithPlatform;
|
||||||
error?: ActivityError;
|
error?: ActivityError;
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
|
SymbolProfile?: EnhancedSymbolProfile;
|
||||||
|
tags?: Tag[];
|
||||||
updateAccountBalance?: boolean;
|
updateAccountBalance?: boolean;
|
||||||
value: number;
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
@ -8,7 +9,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
|
|||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -84,6 +85,7 @@ export class OrderController {
|
|||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('skip') skip?: number,
|
@Query('skip') skip?: number,
|
||||||
@Query('sortColumn') sortColumn?: string,
|
@Query('sortColumn') sortColumn?: string,
|
||||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@ -96,14 +98,18 @@ export class OrderController {
|
|||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { endDate, startDate } = getInterval(dateRange);
|
||||||
|
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
const { activities, count } = await this.orderService.getOrders({
|
const { activities, count } = await this.orderService.getOrders({
|
||||||
|
endDate,
|
||||||
filters,
|
filters,
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
startDate,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
skip: isNaN(skip) ? undefined : skip,
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
@ -19,11 +19,11 @@ import {
|
|||||||
Order,
|
Order,
|
||||||
Prisma,
|
Prisma,
|
||||||
Tag,
|
Tag,
|
||||||
Type as TypeOfOrder
|
Type as ActivityType
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy, uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { Activities } from './interfaces/activities.interface';
|
import { Activities } from './interfaces/activities.interface';
|
||||||
@ -70,12 +70,7 @@ export class OrderService {
|
|||||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||||
const userId = data.userId;
|
const userId = data.userId;
|
||||||
|
|
||||||
if (
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||||
data.type === 'FEE' ||
|
|
||||||
data.type === 'INTEREST' ||
|
|
||||||
data.type === 'ITEM' ||
|
|
||||||
data.type === 'LIABILITY'
|
|
||||||
) {
|
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||||
@ -130,13 +125,9 @@ export class OrderService {
|
|||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
const isDraft =
|
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)
|
||||||
data.type === 'FEE' ||
|
? false
|
||||||
data.type === 'INTEREST' ||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
data.type === 'ITEM' ||
|
|
||||||
data.type === 'LIABILITY'
|
|
||||||
? false
|
|
||||||
: isAfter(data.date as Date, endOfToday());
|
|
||||||
|
|
||||||
const order = await this.prismaService.order.create({
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
@ -157,7 +148,7 @@ export class OrderService {
|
|||||||
.plus(data.fee)
|
.plus(data.fee)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
if (data.type === 'BUY') {
|
if (['BUY', 'FEE'].includes(data.type)) {
|
||||||
amount = new Big(amount).mul(-1).toNumber();
|
amount = new Big(amount).mul(-1).toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,12 +171,7 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
|
||||||
order.type === 'FEE' ||
|
|
||||||
order.type === 'INTEREST' ||
|
|
||||||
order.type === 'ITEM' ||
|
|
||||||
order.type === 'LIABILITY'
|
|
||||||
) {
|
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,25 +186,40 @@ export class OrderService {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
return this.prismaService.order.findFirst({
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
SymbolProfile: { dataSource, symbol }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
|
endDate,
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
skip,
|
skip,
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
startDate,
|
||||||
take = Number.MAX_SAFE_INTEGER,
|
take = Number.MAX_SAFE_INTEGER,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
withExcludedAccounts = false
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
|
endDate?: Date;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
sortColumn?: string;
|
sortColumn?: string;
|
||||||
sortDirection?: Prisma.SortOrder;
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
startDate?: Date;
|
||||||
take?: number;
|
take?: number;
|
||||||
types?: TypeOfOrder[];
|
types?: ActivityType[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
@ -228,6 +229,18 @@ export class OrderService {
|
|||||||
];
|
];
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
|
if (endDate || startDate) {
|
||||||
|
where.AND = [];
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
where.AND.push({ date: { lte: endDate } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
where.AND.push({ date: { gt: startDate } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ACCOUNT: filtersByAccount,
|
ACCOUNT: filtersByAccount,
|
||||||
ASSET_CLASS: filtersByAssetClass,
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
@ -292,13 +305,14 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
where.OR = types.map((type) => {
|
where.type = { in: types };
|
||||||
return {
|
}
|
||||||
type: {
|
|
||||||
equals: type
|
if (withExcludedAccounts === false) {
|
||||||
}
|
where.OR = [
|
||||||
};
|
{ Account: null },
|
||||||
});
|
{ Account: { NOT: { isExcluded: true } } }
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const [orders, count] = await Promise.all([
|
const [orders, count] = await Promise.all([
|
||||||
@ -322,33 +336,53 @@ export class OrderService {
|
|||||||
this.prismaService.order.count({ where })
|
this.prismaService.order.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activities = orders
|
const uniqueAssets = uniqBy(
|
||||||
.filter((order) => {
|
orders.map(({ SymbolProfile }) => {
|
||||||
return (
|
|
||||||
withExcludedAccounts ||
|
|
||||||
!order.Account ||
|
|
||||||
order.Account?.isExcluded === false
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map((order) => {
|
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...order,
|
dataSource: SymbolProfile.dataSource,
|
||||||
value,
|
symbol: SymbolProfile.symbol
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
order.fee,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
value,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
}),
|
||||||
|
({ dataSource, symbol }) => {
|
||||||
|
return getAssetProfileIdentifier({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const assetProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
|
||||||
|
|
||||||
|
const activities = orders.map((order) => {
|
||||||
|
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
|
||||||
|
return (
|
||||||
|
dataSource === order.SymbolProfile.dataSource &&
|
||||||
|
symbol === order.SymbolProfile.symbol
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
value,
|
||||||
|
// TODO: Use exchange rate of date
|
||||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
order.fee,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
SymbolProfile: assetProfile,
|
||||||
|
// TODO: Use exchange rate of date
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return { activities, count };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -371,13 +405,10 @@ export class OrderService {
|
|||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
|
type?: ActivityType;
|
||||||
};
|
};
|
||||||
where: Prisma.OrderWhereUniqueInput;
|
where: Prisma.OrderWhereUniqueInput;
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
if (data.Account.connect.id_userId.id === null) {
|
|
||||||
delete data.Account;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.comment) {
|
if (!data.comment) {
|
||||||
data.comment = null;
|
data.comment = null;
|
||||||
}
|
}
|
||||||
@ -386,13 +417,12 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||||
data.type === 'FEE' ||
|
|
||||||
data.type === 'INTEREST' ||
|
|
||||||
data.type === 'ITEM' ||
|
|
||||||
data.type === 'LIABILITY'
|
|
||||||
) {
|
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
|
|
||||||
|
if (data.Account?.connect?.id_userId?.id === null) {
|
||||||
|
data.Account = { disconnect: true };
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ import { Transform, TransformFnParams } from 'class-transformer';
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@ -37,7 +38,7 @@ export class UpdateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsISO4217CurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||||
|
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||||
|
import {
|
||||||
|
SymbolMetrics,
|
||||||
|
TimelinePosition,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
||||||
|
protected calculateOverallPerformance(
|
||||||
|
positions: TimelinePosition[]
|
||||||
|
): CurrentPositions {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSymbolMetrics({
|
||||||
|
dataSource,
|
||||||
|
end,
|
||||||
|
exchangeRates,
|
||||||
|
isChartMode = false,
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
step = 1,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
end: Date;
|
||||||
|
exchangeRates: { [dateString: string]: number };
|
||||||
|
isChartMode?: boolean;
|
||||||
|
marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
};
|
||||||
|
start: Date;
|
||||||
|
step?: number;
|
||||||
|
} & UniqueAsset): SymbolMetrics {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
export const activityDummyData = {
|
||||||
|
accountId: undefined,
|
||||||
|
accountUserId: undefined,
|
||||||
|
comment: undefined,
|
||||||
|
createdAt: new Date(),
|
||||||
|
feeInBaseCurrency: undefined,
|
||||||
|
id: undefined,
|
||||||
|
isDraft: false,
|
||||||
|
symbolProfileId: undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
userId: undefined,
|
||||||
|
value: undefined,
|
||||||
|
valueInBaseCurrency: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
export const symbolProfileDummyData = {
|
||||||
|
activitiesCount: undefined,
|
||||||
|
assetClass: undefined,
|
||||||
|
assetSubClass: undefined,
|
||||||
|
countries: [],
|
||||||
|
createdAt: undefined,
|
||||||
|
id: undefined,
|
||||||
|
sectors: [],
|
||||||
|
updatedAt: undefined
|
||||||
|
};
|
@ -0,0 +1,51 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
|
||||||
|
|
||||||
|
export enum PerformanceCalculationType {
|
||||||
|
MWR = 'MWR', // Money-Weighted Rate of Return
|
||||||
|
TWR = 'TWR' // Time-Weighted Rate of Return
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PortfolioCalculatorFactory {
|
||||||
|
public constructor(
|
||||||
|
private readonly currentRateService: CurrentRateService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType,
|
||||||
|
currency
|
||||||
|
}: {
|
||||||
|
activities: Activity[];
|
||||||
|
calculationType: PerformanceCalculationType;
|
||||||
|
currency: string;
|
||||||
|
}): PortfolioCalculator {
|
||||||
|
switch (calculationType) {
|
||||||
|
case PerformanceCalculationType.MWR:
|
||||||
|
return new MWRPortfolioCalculator({
|
||||||
|
activities,
|
||||||
|
currency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
exchangeRateDataService: this.exchangeRateDataService
|
||||||
|
});
|
||||||
|
case PerformanceCalculationType.TWR:
|
||||||
|
return new TWRPortfolioCalculator({
|
||||||
|
activities,
|
||||||
|
currency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
exchangeRateDataService: this.exchangeRateDataService
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid calculation type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
771
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
Normal file
771
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
Normal file
@ -0,0 +1,771 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||||
|
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||||
|
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
|
||||||
|
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||||
|
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
|
HistoricalDataItem,
|
||||||
|
InvestmentItem,
|
||||||
|
ResponseError,
|
||||||
|
SymbolMetrics,
|
||||||
|
TimelinePosition,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { GroupBy } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
import {
|
||||||
|
eachDayOfInterval,
|
||||||
|
endOfDay,
|
||||||
|
format,
|
||||||
|
isBefore,
|
||||||
|
isSameDay,
|
||||||
|
max,
|
||||||
|
subDays
|
||||||
|
} from 'date-fns';
|
||||||
|
import { last, uniq } from 'lodash';
|
||||||
|
|
||||||
|
export abstract class PortfolioCalculator {
|
||||||
|
protected static readonly ENABLE_LOGGING = false;
|
||||||
|
|
||||||
|
protected orders: PortfolioOrder[];
|
||||||
|
|
||||||
|
private currency: string;
|
||||||
|
private currentRateService: CurrentRateService;
|
||||||
|
private dataProviderInfos: DataProviderInfo[];
|
||||||
|
private exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
private transactionPoints: TransactionPoint[];
|
||||||
|
|
||||||
|
public constructor({
|
||||||
|
activities,
|
||||||
|
currency,
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
}: {
|
||||||
|
activities: Activity[];
|
||||||
|
currency: string;
|
||||||
|
currentRateService: CurrentRateService;
|
||||||
|
exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
}) {
|
||||||
|
this.currency = currency;
|
||||||
|
this.currentRateService = currentRateService;
|
||||||
|
this.exchangeRateDataService = exchangeRateDataService;
|
||||||
|
this.orders = activities.map(
|
||||||
|
({ date, fee, quantity, SymbolProfile, type, unitPrice }) => {
|
||||||
|
return {
|
||||||
|
SymbolProfile,
|
||||||
|
type,
|
||||||
|
date: format(date, DATE_FORMAT),
|
||||||
|
fee: new Big(fee),
|
||||||
|
quantity: new Big(quantity),
|
||||||
|
unitPrice: new Big(unitPrice)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.orders.sort((a, b) => {
|
||||||
|
return a.date?.localeCompare(b.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.computeTransactionPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract calculateOverallPerformance(
|
||||||
|
positions: TimelinePosition[]
|
||||||
|
): CurrentPositions;
|
||||||
|
|
||||||
|
public async getChartData({
|
||||||
|
end = new Date(Date.now()),
|
||||||
|
start,
|
||||||
|
step = 1
|
||||||
|
}: {
|
||||||
|
end?: Date;
|
||||||
|
start: Date;
|
||||||
|
step?: number;
|
||||||
|
}): Promise<HistoricalDataItem[]> {
|
||||||
|
const symbols: { [symbol: string]: boolean } = {};
|
||||||
|
|
||||||
|
const transactionPointsBeforeEndDate =
|
||||||
|
this.transactionPoints?.filter((transactionPoint) => {
|
||||||
|
return isBefore(parseDate(transactionPoint.date), end);
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
|
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
|
|
||||||
|
let dates = eachDayOfInterval({ start, end }, { step }).map((date) => {
|
||||||
|
return resetHours(date);
|
||||||
|
});
|
||||||
|
|
||||||
|
const includesEndDate = isSameDay(last(dates), end);
|
||||||
|
|
||||||
|
if (!includesEndDate) {
|
||||||
|
dates.push(resetHours(end));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionPointsBeforeEndDate.length > 0) {
|
||||||
|
for (const {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
} of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||||
|
dataGatheringItems.push({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
currencies[symbol] = currency;
|
||||||
|
symbols[symbol] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dataProviderInfos, values: marketSymbols } =
|
||||||
|
await this.currentRateService.getValues({
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
|
const marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
let exchangeRatesByCurrency =
|
||||||
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||||
|
currencies: uniq(Object.values(currencies)),
|
||||||
|
endDate: endOfDay(end),
|
||||||
|
startDate: this.getStartDate(),
|
||||||
|
targetCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const marketSymbol of marketSymbols) {
|
||||||
|
const dateString = format(marketSymbol.date, DATE_FORMAT);
|
||||||
|
if (!marketSymbolMap[dateString]) {
|
||||||
|
marketSymbolMap[dateString] = {};
|
||||||
|
}
|
||||||
|
if (marketSymbol.marketPrice) {
|
||||||
|
marketSymbolMap[dateString][marketSymbol.symbol] = new Big(
|
||||||
|
marketSymbol.marketPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accumulatedValuesByDate: {
|
||||||
|
[date: string]: {
|
||||||
|
investmentValueWithCurrencyEffect: Big;
|
||||||
|
totalCurrentValue: Big;
|
||||||
|
totalCurrentValueWithCurrencyEffect: Big;
|
||||||
|
totalInvestmentValue: Big;
|
||||||
|
totalInvestmentValueWithCurrencyEffect: Big;
|
||||||
|
totalNetPerformanceValue: Big;
|
||||||
|
totalNetPerformanceValueWithCurrencyEffect: Big;
|
||||||
|
totalTimeWeightedInvestmentValue: Big;
|
||||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect: Big;
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
const valuesBySymbol: {
|
||||||
|
[symbol: string]: {
|
||||||
|
currentValues: { [date: string]: Big };
|
||||||
|
currentValuesWithCurrencyEffect: { [date: string]: Big };
|
||||||
|
investmentValuesAccumulated: { [date: string]: Big };
|
||||||
|
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big };
|
||||||
|
investmentValuesWithCurrencyEffect: { [date: string]: Big };
|
||||||
|
netPerformanceValues: { [date: string]: Big };
|
||||||
|
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big };
|
||||||
|
timeWeightedInvestmentValues: { [date: string]: Big };
|
||||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big };
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const symbol of Object.keys(symbols)) {
|
||||||
|
const {
|
||||||
|
currentValues,
|
||||||
|
currentValuesWithCurrencyEffect,
|
||||||
|
investmentValuesAccumulated,
|
||||||
|
investmentValuesAccumulatedWithCurrencyEffect,
|
||||||
|
investmentValuesWithCurrencyEffect,
|
||||||
|
netPerformanceValues,
|
||||||
|
netPerformanceValuesWithCurrencyEffect,
|
||||||
|
timeWeightedInvestmentValues,
|
||||||
|
timeWeightedInvestmentValuesWithCurrencyEffect
|
||||||
|
} = this.getSymbolMetrics({
|
||||||
|
end,
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
step,
|
||||||
|
symbol,
|
||||||
|
dataSource: null,
|
||||||
|
exchangeRates:
|
||||||
|
exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`],
|
||||||
|
isChartMode: true
|
||||||
|
});
|
||||||
|
|
||||||
|
valuesBySymbol[symbol] = {
|
||||||
|
currentValues,
|
||||||
|
currentValuesWithCurrencyEffect,
|
||||||
|
investmentValuesAccumulated,
|
||||||
|
investmentValuesAccumulatedWithCurrencyEffect,
|
||||||
|
investmentValuesWithCurrencyEffect,
|
||||||
|
netPerformanceValues,
|
||||||
|
netPerformanceValuesWithCurrencyEffect,
|
||||||
|
timeWeightedInvestmentValues,
|
||||||
|
timeWeightedInvestmentValuesWithCurrencyEffect
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const currentDate of dates) {
|
||||||
|
const dateString = format(currentDate, DATE_FORMAT);
|
||||||
|
|
||||||
|
for (const symbol of Object.keys(valuesBySymbol)) {
|
||||||
|
const symbolValues = valuesBySymbol[symbol];
|
||||||
|
|
||||||
|
const currentValue =
|
||||||
|
symbolValues.currentValues?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
|
const currentValueWithCurrencyEffect =
|
||||||
|
symbolValues.currentValuesWithCurrencyEffect?.[dateString] ??
|
||||||
|
new Big(0);
|
||||||
|
|
||||||
|
const investmentValueAccumulated =
|
||||||
|
symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
|
const investmentValueAccumulatedWithCurrencyEffect =
|
||||||
|
symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[
|
||||||
|
dateString
|
||||||
|
] ?? new Big(0);
|
||||||
|
|
||||||
|
const investmentValueWithCurrencyEffect =
|
||||||
|
symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ??
|
||||||
|
new Big(0);
|
||||||
|
|
||||||
|
const netPerformanceValue =
|
||||||
|
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
|
const netPerformanceValueWithCurrencyEffect =
|
||||||
|
symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ??
|
||||||
|
new Big(0);
|
||||||
|
|
||||||
|
const timeWeightedInvestmentValue =
|
||||||
|
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
|
const timeWeightedInvestmentValueWithCurrencyEffect =
|
||||||
|
symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[
|
||||||
|
dateString
|
||||||
|
] ?? new Big(0);
|
||||||
|
|
||||||
|
accumulatedValuesByDate[dateString] = {
|
||||||
|
investmentValueWithCurrencyEffect: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.investmentValueWithCurrencyEffect ?? new Big(0)
|
||||||
|
).add(investmentValueWithCurrencyEffect),
|
||||||
|
totalCurrentValue: (
|
||||||
|
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||||
|
).add(currentValue),
|
||||||
|
totalCurrentValueWithCurrencyEffect: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalCurrentValueWithCurrencyEffect ?? new Big(0)
|
||||||
|
).add(currentValueWithCurrencyEffect),
|
||||||
|
totalInvestmentValue: (
|
||||||
|
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
|
||||||
|
new Big(0)
|
||||||
|
).add(investmentValueAccumulated),
|
||||||
|
totalInvestmentValueWithCurrencyEffect: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
|
||||||
|
).add(investmentValueAccumulatedWithCurrencyEffect),
|
||||||
|
totalNetPerformanceValue: (
|
||||||
|
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
|
||||||
|
new Big(0)
|
||||||
|
).add(netPerformanceValue),
|
||||||
|
totalNetPerformanceValueWithCurrencyEffect: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0)
|
||||||
|
).add(netPerformanceValueWithCurrencyEffect),
|
||||||
|
totalTimeWeightedInvestmentValue: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalTimeWeightedInvestmentValue ?? new Big(0)
|
||||||
|
).add(timeWeightedInvestmentValue),
|
||||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect: (
|
||||||
|
accumulatedValuesByDate[dateString]
|
||||||
|
?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0)
|
||||||
|
).add(timeWeightedInvestmentValueWithCurrencyEffect)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
|
||||||
|
const {
|
||||||
|
investmentValueWithCurrencyEffect,
|
||||||
|
totalCurrentValue,
|
||||||
|
totalCurrentValueWithCurrencyEffect,
|
||||||
|
totalInvestmentValue,
|
||||||
|
totalInvestmentValueWithCurrencyEffect,
|
||||||
|
totalNetPerformanceValue,
|
||||||
|
totalNetPerformanceValueWithCurrencyEffect,
|
||||||
|
totalTimeWeightedInvestmentValue,
|
||||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect
|
||||||
|
} = values;
|
||||||
|
|
||||||
|
const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0)
|
||||||
|
? 0
|
||||||
|
: totalNetPerformanceValue
|
||||||
|
.div(totalTimeWeightedInvestmentValue)
|
||||||
|
.mul(100)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
const netPerformanceInPercentageWithCurrencyEffect =
|
||||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0)
|
||||||
|
? 0
|
||||||
|
: totalNetPerformanceValueWithCurrencyEffect
|
||||||
|
.div(totalTimeWeightedInvestmentValueWithCurrencyEffect)
|
||||||
|
.mul(100)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
netPerformanceInPercentage,
|
||||||
|
netPerformanceInPercentageWithCurrencyEffect,
|
||||||
|
investmentValueWithCurrencyEffect:
|
||||||
|
investmentValueWithCurrencyEffect.toNumber(),
|
||||||
|
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||||
|
netPerformanceWithCurrencyEffect:
|
||||||
|
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
|
||||||
|
totalInvestment: totalInvestmentValue.toNumber(),
|
||||||
|
totalInvestmentValueWithCurrencyEffect:
|
||||||
|
totalInvestmentValueWithCurrencyEffect.toNumber(),
|
||||||
|
value: totalCurrentValue.toNumber(),
|
||||||
|
valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCurrentPositions(
|
||||||
|
start: Date,
|
||||||
|
end?: Date
|
||||||
|
): Promise<CurrentPositions> {
|
||||||
|
const lastTransactionPoint = last(this.transactionPoints);
|
||||||
|
|
||||||
|
let endDate = end;
|
||||||
|
|
||||||
|
if (!endDate) {
|
||||||
|
endDate = new Date(Date.now());
|
||||||
|
|
||||||
|
if (lastTransactionPoint) {
|
||||||
|
endDate = max([endDate, parseDate(lastTransactionPoint.date)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionPoints = this.transactionPoints?.filter(({ date }) => {
|
||||||
|
return isBefore(parseDate(date), endDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!transactionPoints.length) {
|
||||||
|
return {
|
||||||
|
currentValueInBaseCurrency: new Big(0),
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
positions: [],
|
||||||
|
totalInvestment: new Big(0),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
|
let dates: Date[] = [];
|
||||||
|
let firstIndex = transactionPoints.length;
|
||||||
|
let firstTransactionPoint: TransactionPoint = null;
|
||||||
|
|
||||||
|
dates.push(resetHours(start));
|
||||||
|
|
||||||
|
for (const { currency, dataSource, symbol } of transactionPoints[
|
||||||
|
firstIndex - 1
|
||||||
|
].items) {
|
||||||
|
dataGatheringItems.push({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
currencies[symbol] = currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < transactionPoints.length; i++) {
|
||||||
|
if (
|
||||||
|
!isBefore(parseDate(transactionPoints[i].date), start) &&
|
||||||
|
firstTransactionPoint === null
|
||||||
|
) {
|
||||||
|
firstTransactionPoint = transactionPoints[i];
|
||||||
|
firstIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstTransactionPoint !== null) {
|
||||||
|
dates.push(resetHours(parseDate(transactionPoints[i].date)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dates.push(resetHours(endDate));
|
||||||
|
|
||||||
|
// Add dates of last week for fallback
|
||||||
|
dates.push(subDays(resetHours(new Date()), 7));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 6));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 5));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 4));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 3));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 2));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 1));
|
||||||
|
dates.push(resetHours(new Date()));
|
||||||
|
|
||||||
|
dates = uniq(
|
||||||
|
dates.map((date) => {
|
||||||
|
return date.getTime();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.map((timestamp) => {
|
||||||
|
return new Date(timestamp);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
return a.getTime() - b.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
let exchangeRatesByCurrency =
|
||||||
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||||
|
currencies: uniq(Object.values(currencies)),
|
||||||
|
endDate: endOfDay(endDate),
|
||||||
|
startDate: this.getStartDate(),
|
||||||
|
targetCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
dataProviderInfos,
|
||||||
|
errors: currentRateErrors,
|
||||||
|
values: marketSymbols
|
||||||
|
} = await this.currentRateService.getValues({
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
|
const marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const marketSymbol of marketSymbols) {
|
||||||
|
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||||
|
|
||||||
|
if (!marketSymbolMap[date]) {
|
||||||
|
marketSymbolMap[date] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marketSymbol.marketPrice) {
|
||||||
|
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||||
|
marketSymbol.marketPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDateString = format(endDate, DATE_FORMAT);
|
||||||
|
|
||||||
|
if (firstIndex > 0) {
|
||||||
|
firstIndex--;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions: TimelinePosition[] = [];
|
||||||
|
let hasAnySymbolMetricsErrors = false;
|
||||||
|
|
||||||
|
const errors: ResponseError['errors'] = [];
|
||||||
|
|
||||||
|
for (const item of lastTransactionPoint.items) {
|
||||||
|
const marketPriceInBaseCurrency = (
|
||||||
|
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
|
||||||
|
).mul(
|
||||||
|
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
|
||||||
|
endDateString
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
grossPerformance,
|
||||||
|
grossPerformancePercentage,
|
||||||
|
grossPerformancePercentageWithCurrencyEffect,
|
||||||
|
grossPerformanceWithCurrencyEffect,
|
||||||
|
hasErrors,
|
||||||
|
netPerformance,
|
||||||
|
netPerformancePercentage,
|
||||||
|
netPerformancePercentageWithCurrencyEffect,
|
||||||
|
netPerformanceWithCurrencyEffect,
|
||||||
|
timeWeightedInvestment,
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect,
|
||||||
|
totalDividend,
|
||||||
|
totalDividendInBaseCurrency,
|
||||||
|
totalInvestment,
|
||||||
|
totalInvestmentWithCurrencyEffect
|
||||||
|
} = this.getSymbolMetrics({
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
end: endDate,
|
||||||
|
exchangeRates:
|
||||||
|
exchangeRatesByCurrency[`${item.currency}${this.currency}`],
|
||||||
|
symbol: item.symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
dividend: totalDividend,
|
||||||
|
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
||||||
|
timeWeightedInvestment,
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect,
|
||||||
|
averagePrice: item.averagePrice,
|
||||||
|
currency: item.currency,
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
fee: item.fee,
|
||||||
|
firstBuyDate: item.firstBuyDate,
|
||||||
|
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||||
|
grossPerformancePercentage: !hasErrors
|
||||||
|
? grossPerformancePercentage ?? null
|
||||||
|
: null,
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: !hasErrors
|
||||||
|
? grossPerformancePercentageWithCurrencyEffect ?? null
|
||||||
|
: null,
|
||||||
|
grossPerformanceWithCurrencyEffect: !hasErrors
|
||||||
|
? grossPerformanceWithCurrencyEffect ?? null
|
||||||
|
: null,
|
||||||
|
investment: totalInvestment,
|
||||||
|
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
|
||||||
|
marketPrice:
|
||||||
|
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
|
||||||
|
marketPriceInBaseCurrency:
|
||||||
|
marketPriceInBaseCurrency?.toNumber() ?? null,
|
||||||
|
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
||||||
|
netPerformancePercentage: !hasErrors
|
||||||
|
? netPerformancePercentage ?? null
|
||||||
|
: null,
|
||||||
|
netPerformancePercentageWithCurrencyEffect: !hasErrors
|
||||||
|
? netPerformancePercentageWithCurrencyEffect ?? null
|
||||||
|
: null,
|
||||||
|
netPerformanceWithCurrencyEffect: !hasErrors
|
||||||
|
? netPerformanceWithCurrencyEffect ?? null
|
||||||
|
: null,
|
||||||
|
quantity: item.quantity,
|
||||||
|
symbol: item.symbol,
|
||||||
|
tags: item.tags,
|
||||||
|
transactionCount: item.transactionCount,
|
||||||
|
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul(
|
||||||
|
item.quantity
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
(hasErrors ||
|
||||||
|
currentRateErrors.find(({ dataSource, symbol }) => {
|
||||||
|
return dataSource === item.dataSource && symbol === item.symbol;
|
||||||
|
})) &&
|
||||||
|
item.investment.gt(0)
|
||||||
|
) {
|
||||||
|
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overall = this.calculateOverallPerformance(positions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...overall,
|
||||||
|
errors,
|
||||||
|
positions,
|
||||||
|
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDataProviderInfos() {
|
||||||
|
return this.dataProviderInfos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getInvestments(): { date: string; investment: Big }[] {
|
||||||
|
if (this.transactionPoints.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.transactionPoints.map((transactionPoint) => {
|
||||||
|
return {
|
||||||
|
date: transactionPoint.date,
|
||||||
|
investment: transactionPoint.items.reduce(
|
||||||
|
(investment, transactionPointSymbol) =>
|
||||||
|
investment.plus(transactionPointSymbol.investment),
|
||||||
|
new Big(0)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getInvestmentsByGroup({
|
||||||
|
data,
|
||||||
|
groupBy
|
||||||
|
}: {
|
||||||
|
data: HistoricalDataItem[];
|
||||||
|
groupBy: GroupBy;
|
||||||
|
}): InvestmentItem[] {
|
||||||
|
const groupedData: { [dateGroup: string]: Big } = {};
|
||||||
|
|
||||||
|
for (const { date, investmentValueWithCurrencyEffect } of data) {
|
||||||
|
const dateGroup =
|
||||||
|
groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4);
|
||||||
|
groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus(
|
||||||
|
investmentValueWithCurrencyEffect
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(groupedData).map((dateGroup) => ({
|
||||||
|
date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`,
|
||||||
|
investment: groupedData[dateGroup].toNumber()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStartDate() {
|
||||||
|
return this.transactionPoints.length > 0
|
||||||
|
? parseDate(this.transactionPoints[0].date)
|
||||||
|
: new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract getSymbolMetrics({
|
||||||
|
dataSource,
|
||||||
|
end,
|
||||||
|
exchangeRates,
|
||||||
|
isChartMode,
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
step,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
end: Date;
|
||||||
|
exchangeRates: { [dateString: string]: number };
|
||||||
|
isChartMode?: boolean;
|
||||||
|
marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
};
|
||||||
|
start: Date;
|
||||||
|
step?: number;
|
||||||
|
} & UniqueAsset): SymbolMetrics;
|
||||||
|
|
||||||
|
public getTransactionPoints() {
|
||||||
|
return this.transactionPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeTransactionPoints() {
|
||||||
|
this.transactionPoints = [];
|
||||||
|
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||||
|
|
||||||
|
let lastDate: string = null;
|
||||||
|
let lastTransactionPoint: TransactionPoint = null;
|
||||||
|
|
||||||
|
for (const {
|
||||||
|
fee,
|
||||||
|
date,
|
||||||
|
quantity,
|
||||||
|
SymbolProfile,
|
||||||
|
tags,
|
||||||
|
type,
|
||||||
|
unitPrice
|
||||||
|
} of this.orders) {
|
||||||
|
let currentTransactionPointItem: TransactionPointSymbol;
|
||||||
|
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
|
||||||
|
|
||||||
|
const factor = getFactor(type);
|
||||||
|
|
||||||
|
if (oldAccumulatedSymbol) {
|
||||||
|
let investment = oldAccumulatedSymbol.investment;
|
||||||
|
|
||||||
|
const newQuantity = quantity
|
||||||
|
.mul(factor)
|
||||||
|
.plus(oldAccumulatedSymbol.quantity);
|
||||||
|
|
||||||
|
if (type === 'BUY') {
|
||||||
|
investment = oldAccumulatedSymbol.investment.plus(
|
||||||
|
quantity.mul(unitPrice)
|
||||||
|
);
|
||||||
|
} else if (type === 'SELL') {
|
||||||
|
investment = oldAccumulatedSymbol.investment.minus(
|
||||||
|
quantity.mul(oldAccumulatedSymbol.averagePrice)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTransactionPointItem = {
|
||||||
|
investment,
|
||||||
|
tags,
|
||||||
|
averagePrice: newQuantity.gt(0)
|
||||||
|
? investment.div(newQuantity)
|
||||||
|
: new Big(0),
|
||||||
|
currency: SymbolProfile.currency,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
dividend: new Big(0),
|
||||||
|
fee: fee.plus(oldAccumulatedSymbol.fee),
|
||||||
|
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||||
|
quantity: newQuantity,
|
||||||
|
symbol: SymbolProfile.symbol,
|
||||||
|
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
currentTransactionPointItem = {
|
||||||
|
fee,
|
||||||
|
tags,
|
||||||
|
averagePrice: unitPrice,
|
||||||
|
currency: SymbolProfile.currency,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
dividend: new Big(0),
|
||||||
|
firstBuyDate: date,
|
||||||
|
investment: unitPrice.mul(quantity).mul(factor),
|
||||||
|
quantity: quantity.mul(factor),
|
||||||
|
symbol: SymbolProfile.symbol,
|
||||||
|
transactionCount: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
symbols[SymbolProfile.symbol] = currentTransactionPointItem;
|
||||||
|
|
||||||
|
const items = lastTransactionPoint?.items ?? [];
|
||||||
|
|
||||||
|
const newItems = items.filter(({ symbol }) => {
|
||||||
|
return symbol !== SymbolProfile.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
|
newItems.push(currentTransactionPointItem);
|
||||||
|
|
||||||
|
newItems.sort((a, b) => {
|
||||||
|
return a.symbol?.localeCompare(b.symbol);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lastDate !== date || lastTransactionPoint === null) {
|
||||||
|
lastTransactionPoint = {
|
||||||
|
date,
|
||||||
|
items: newItems
|
||||||
|
};
|
||||||
|
|
||||||
|
this.transactionPoints.push(lastTransactionPoint);
|
||||||
|
} else {
|
||||||
|
lastTransactionPoint.items = newItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDate = date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,190 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
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;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BALN.SW buy and sell in two activities', async () => {
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-22'),
|
||||||
|
fee: 1.55,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 142.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 1.65,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 136.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
symbol: 'BALN.SW'
|
||||||
|
},
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: 136.6
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const chartData = await portfolioCalculator.getChartData({
|
||||||
|
start: parseDate('2021-11-22')
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
parseDate('2021-11-22')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||||
|
data: chartData,
|
||||||
|
groupBy: 'month'
|
||||||
|
});
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('-12.6'),
|
||||||
|
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.04408677396780965649'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('-15.8'),
|
||||||
|
netPerformancePercentage: new Big('-0.05528341497550734703'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.05528341497550734703'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('0'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
|
fee: new Big('3.2'),
|
||||||
|
firstBuyDate: '2021-11-22',
|
||||||
|
grossPerformance: new Big('-12.6'),
|
||||||
|
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.04408677396780965649'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||||
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
|
netPerformance: new Big('-15.8'),
|
||||||
|
netPerformancePercentage: new Big('-0.05528341497550734703'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.05528341497550734703'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||||
|
marketPrice: 148.9,
|
||||||
|
marketPriceInBaseCurrency: 148.9,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
timeWeightedInvestment: new Big('285.80000000000000396627'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
|
'285.80000000000000396627'
|
||||||
|
),
|
||||||
|
transactionCount: 3,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-22', investment: new Big('285.8') },
|
||||||
|
{ date: '2021-11-30', investment: new Big('0') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: 0 },
|
||||||
|
{ date: '2021-12-01', investment: 0 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,11 +1,18 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
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', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -19,9 +26,10 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
@ -29,41 +37,53 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy and sell', async () => {
|
it.only('with BALN.SW buy and sell', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const activities: Activity[] = [
|
||||||
currentRateService,
|
{
|
||||||
exchangeRateDataService,
|
...activityDummyData,
|
||||||
currency: 'CHF',
|
date: new Date('2021-11-22'),
|
||||||
orders: [
|
fee: 1.55,
|
||||||
{
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
date: '2021-11-22',
|
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(1.55),
|
|
||||||
name: 'Bâloise Holding AG',
|
name: 'Bâloise Holding AG',
|
||||||
quantity: new Big(2),
|
symbol: 'BALN.SW'
|
||||||
symbol: 'BALN.SW',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(142.9)
|
|
||||||
},
|
},
|
||||||
{
|
type: 'BUY',
|
||||||
|
unitPrice: 142.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-30'),
|
||||||
|
fee: 1.65,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
date: '2021-11-30',
|
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(1.65),
|
|
||||||
name: 'Bâloise Holding AG',
|
name: 'Bâloise Holding AG',
|
||||||
quantity: new Big(2),
|
symbol: 'BALN.SW'
|
||||||
symbol: 'BALN.SW',
|
},
|
||||||
type: 'SELL',
|
type: 'SELL',
|
||||||
unitPrice: new Big(136.6)
|
unitPrice: 136.6
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
@ -87,7 +107,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
currentValue: new Big('0'),
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
@ -107,6 +127,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('0'),
|
averagePrice: new Big('0'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('3.2'),
|
fee: new Big('3.2'),
|
||||||
firstBuyDate: '2021-11-22',
|
firstBuyDate: '2021-11-22',
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
@ -129,7 +151,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
symbol: 'BALN.SW',
|
symbol: 'BALN.SW',
|
||||||
timeWeightedInvestment: new Big('285.8'),
|
timeWeightedInvestment: new Big('285.8'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
||||||
transactionCount: 2
|
transactionCount: 2,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('0'),
|
totalInvestment: new Big('0'),
|
@ -1,11 +1,18 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
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', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -19,9 +26,10 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
@ -29,30 +37,38 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy', async () => {
|
it.only('with BALN.SW buy', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const activities: Activity[] = [
|
||||||
currentRateService,
|
{
|
||||||
exchangeRateDataService,
|
...activityDummyData,
|
||||||
currency: 'CHF',
|
date: new Date('2021-11-30'),
|
||||||
orders: [
|
fee: 1.55,
|
||||||
{
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
date: '2021-11-30',
|
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(1.55),
|
|
||||||
name: 'Bâloise Holding AG',
|
name: 'Bâloise Holding AG',
|
||||||
quantity: new Big(2),
|
symbol: 'BALN.SW'
|
||||||
symbol: 'BALN.SW',
|
},
|
||||||
type: 'BUY',
|
type: 'BUY',
|
||||||
unitPrice: new Big(136.6)
|
unitPrice: 136.6
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
@ -76,7 +92,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
currentValue: new Big('297.8'),
|
currentValueInBaseCurrency: new Big('297.8'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('24.6'),
|
grossPerformance: new Big('24.6'),
|
||||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
@ -96,6 +112,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('136.6'),
|
averagePrice: new Big('136.6'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('1.55'),
|
fee: new Big('1.55'),
|
||||||
firstBuyDate: '2021-11-30',
|
firstBuyDate: '2021-11-30',
|
||||||
grossPerformance: new Big('24.6'),
|
grossPerformance: new Big('24.6'),
|
||||||
@ -118,7 +136,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
symbol: 'BALN.SW',
|
symbol: 'BALN.SW',
|
||||||
timeWeightedInvestment: new Big('273.2'),
|
timeWeightedInvestment: new Big('273.2'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||||
transactionCount: 1
|
transactionCount: 1,
|
||||||
|
valueInBaseCurrency: new Big('297.8')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('273.2'),
|
totalInvestment: new Big('273.2'),
|
@ -1,12 +1,19 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
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', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -32,9 +39,10 @@ jest.mock(
|
|||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
@ -42,41 +50,53 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BTCUSD buy and sell partially', async () => {
|
it.only('with BTCUSD buy and sell partially', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const activities: Activity[] = [
|
||||||
currentRateService,
|
{
|
||||||
exchangeRateDataService,
|
...activityDummyData,
|
||||||
currency: 'CHF',
|
date: new Date('2015-01-01'),
|
||||||
orders: [
|
fee: 0,
|
||||||
{
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
date: '2015-01-01',
|
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(0),
|
|
||||||
name: 'Bitcoin USD',
|
name: 'Bitcoin USD',
|
||||||
quantity: new Big(2),
|
symbol: 'BTCUSD'
|
||||||
symbol: 'BTCUSD',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(320.43)
|
|
||||||
},
|
},
|
||||||
{
|
type: 'BUY',
|
||||||
|
unitPrice: 320.43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2017-12-31'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
date: '2017-12-31',
|
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(0),
|
|
||||||
name: 'Bitcoin USD',
|
name: 'Bitcoin USD',
|
||||||
quantity: new Big(1),
|
symbol: 'BTCUSD'
|
||||||
symbol: 'BTCUSD',
|
},
|
||||||
type: 'SELL',
|
type: 'SELL',
|
||||||
unitPrice: new Big(14156.4)
|
unitPrice: 14156.4
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
@ -100,7 +120,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
currentValue: new Big('13298.425356'),
|
currentValueInBaseCurrency: new Big('13298.425356'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('27172.74'),
|
grossPerformance: new Big('27172.74'),
|
||||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
@ -120,6 +140,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('320.43'),
|
averagePrice: new Big('320.43'),
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('0'),
|
fee: new Big('0'),
|
||||||
firstBuyDate: '2015-01-01',
|
firstBuyDate: '2015-01-01',
|
||||||
grossPerformance: new Big('27172.74'),
|
grossPerformance: new Big('27172.74'),
|
||||||
@ -149,7 +171,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
'636.79469348020066587024'
|
'636.79469348020066587024'
|
||||||
),
|
),
|
||||||
transactionCount: 2
|
transactionCount: 2,
|
||||||
|
valueInBaseCurrency: new Big('13298.425356')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('320.43'),
|
totalInvestment: new Big('320.43'),
|
@ -1,12 +1,19 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PerformanceCalculationType
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
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', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -32,9 +39,10 @@ jest.mock(
|
|||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
@ -42,30 +50,38 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with GOOGL buy', async () => {
|
it.only('with GOOGL buy', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const activities: Activity[] = [
|
||||||
currentRateService,
|
{
|
||||||
exchangeRateDataService,
|
...activityDummyData,
|
||||||
currency: 'CHF',
|
date: new Date('2023-01-03'),
|
||||||
orders: [
|
fee: 1,
|
||||||
{
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
date: '2023-01-03',
|
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(1),
|
|
||||||
name: 'Alphabet Inc.',
|
name: 'Alphabet Inc.',
|
||||||
quantity: new Big(1),
|
symbol: 'GOOGL'
|
||||||
symbol: 'GOOGL',
|
},
|
||||||
type: 'BUY',
|
type: 'BUY',
|
||||||
unitPrice: new Big(89.12)
|
unitPrice: 89.12
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
@ -89,7 +105,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
currentValue: new Big('103.10483'),
|
currentValueInBaseCurrency: new Big('103.10483'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('27.33'),
|
grossPerformance: new Big('27.33'),
|
||||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||||
@ -109,6 +125,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('89.12'),
|
averagePrice: new Big('89.12'),
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('1'),
|
fee: new Big('1'),
|
||||||
firstBuyDate: '2023-01-03',
|
firstBuyDate: '2023-01-03',
|
||||||
grossPerformance: new Big('27.33'),
|
grossPerformance: new Big('27.33'),
|
||||||
@ -132,7 +150,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
tags: undefined,
|
tags: undefined,
|
||||||
timeWeightedInvestment: new Big('89.12'),
|
timeWeightedInvestment: new Big('89.12'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||||
transactionCount: 1
|
transactionCount: 1,
|
||||||
|
valueInBaseCurrency: new Big('103.10483')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('89.12'),
|
totalInvestment: new Big('89.12'),
|
@ -0,0 +1,138 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return ExchangeRateDataServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with MSFT buy', async () => {
|
||||||
|
const activities: Activity[] = [
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-09-16'),
|
||||||
|
fee: 19,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Microsoft Inc.',
|
||||||
|
symbol: 'MSFT'
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: 298.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2021-11-16'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
name: 'Microsoft Inc.',
|
||||||
|
symbol: 'MSFT'
|
||||||
|
},
|
||||||
|
type: 'DIVIDEND',
|
||||||
|
unitPrice: 0.62
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'USD'
|
||||||
|
});
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
parseDate('2023-07-10')
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toMatchObject({
|
||||||
|
errors: [],
|
||||||
|
hasErrors: false,
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('298.58'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0.62'),
|
||||||
|
dividendInBaseCurrency: new Big('0.62'),
|
||||||
|
fee: new Big('19'),
|
||||||
|
firstBuyDate: '2021-09-16',
|
||||||
|
investment: new Big('298.58'),
|
||||||
|
investmentWithCurrencyEffect: new Big('298.58'),
|
||||||
|
marketPrice: 331.83,
|
||||||
|
marketPriceInBaseCurrency: 331.83,
|
||||||
|
quantity: new Big('1'),
|
||||||
|
symbol: 'MSFT',
|
||||||
|
tags: undefined,
|
||||||
|
transactionCount: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('298.58'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('298.58')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,11 +1,14 @@
|
|||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
import { subDays } from 'date-fns';
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -19,9 +22,10 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
@ -29,30 +33,31 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it('with no orders', async () => {
|
it('with no orders', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = factory.createCalculator({
|
||||||
currentRateService,
|
activities: [],
|
||||||
exchangeRateDataService,
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
currency: 'CHF',
|
currency: 'CHF'
|
||||||
orders: []
|
|
||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
const start = subDays(new Date(Date.now()), 10);
|
||||||
start: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
const chartData = await portfolioCalculator.getChartData({ start });
|
||||||
new Date()
|
|
||||||
);
|
const currentPositions =
|
||||||
|
await portfolioCalculator.getCurrentPositions(start);
|
||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
@ -64,7 +69,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
currentValue: new Big(0),
|
currentValueInBaseCurrency: new Big(0),
|
||||||
grossPerformance: new Big(0),
|
grossPerformance: new Big(0),
|
||||||
grossPerformancePercentage: new Big(0),
|
grossPerformancePercentage: new Big(0),
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
@ -75,7 +80,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
netPerformanceWithCurrencyEffect: new Big(0),
|
netPerformanceWithCurrencyEffect: new Big(0),
|
||||||
positions: [],
|
positions: [],
|
||||||
totalInvestment: new Big(0)
|
totalInvestment: new Big(0),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big(0)
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([]);
|
expect(investments).toEqual([]);
|
@ -1,11 +1,18 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
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', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -19,9 +26,10 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
@ -29,42 +37,53 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with NOVN.SW buy and sell partially', async () => {
|
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const activities: Activity[] = [
|
||||||
currentRateService,
|
{
|
||||||
exchangeRateDataService,
|
...activityDummyData,
|
||||||
currency: 'CHF',
|
date: new Date('2022-03-07'),
|
||||||
orders: [
|
fee: 1.3,
|
||||||
{
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
date: '2022-03-07',
|
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(1.3),
|
|
||||||
name: 'Novartis AG',
|
name: 'Novartis AG',
|
||||||
quantity: new Big(2),
|
symbol: 'NOVN.SW'
|
||||||
symbol: 'NOVN.SW',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(75.8)
|
|
||||||
},
|
},
|
||||||
{
|
type: 'BUY',
|
||||||
|
unitPrice: 75.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-04-08'),
|
||||||
|
fee: 2.95,
|
||||||
|
quantity: 1,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
date: '2022-04-08',
|
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(2.95),
|
|
||||||
name: 'Novartis AG',
|
name: 'Novartis AG',
|
||||||
quantity: new Big(1),
|
symbol: 'NOVN.SW'
|
||||||
symbol: 'NOVN.SW',
|
},
|
||||||
type: 'SELL',
|
type: 'SELL',
|
||||||
unitPrice: new Big(85.73)
|
unitPrice: 85.73
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
|
|
||||||
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||||
@ -87,7 +106,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
currentValue: new Big('87.8'),
|
currentValueInBaseCurrency: new Big('87.8'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||||
@ -107,6 +126,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('75.80'),
|
averagePrice: new Big('75.80'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('4.25'),
|
fee: new Big('4.25'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
@ -131,7 +152,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
'145.10285714285714285714'
|
'145.10285714285714285714'
|
||||||
),
|
),
|
||||||
transactionCount: 2
|
transactionCount: 2,
|
||||||
|
valueInBaseCurrency: new Big('87.8')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('75.80'),
|
totalInvestment: new Big('75.80'),
|
@ -1,11 +1,18 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
activityDummyData,
|
||||||
|
symbolProfileDummyData
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||||
|
import {
|
||||||
|
PerformanceCalculationType,
|
||||||
|
PortfolioCalculatorFactory
|
||||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
import Big from 'big.js';
|
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', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -19,9 +26,10 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null);
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
@ -29,41 +37,53 @@ describe('PortfolioCalculator', () => {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with NOVN.SW buy and sell', async () => {
|
it.only('with NOVN.SW buy and sell', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const activities: Activity[] = [
|
||||||
currentRateService,
|
{
|
||||||
exchangeRateDataService,
|
...activityDummyData,
|
||||||
currency: 'CHF',
|
date: new Date('2022-03-07'),
|
||||||
orders: [
|
fee: 0,
|
||||||
{
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
date: '2022-03-07',
|
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(0),
|
|
||||||
name: 'Novartis AG',
|
name: 'Novartis AG',
|
||||||
quantity: new Big(2),
|
symbol: 'NOVN.SW'
|
||||||
symbol: 'NOVN.SW',
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: new Big(75.8)
|
|
||||||
},
|
},
|
||||||
{
|
type: 'BUY',
|
||||||
|
unitPrice: 75.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...activityDummyData,
|
||||||
|
date: new Date('2022-04-08'),
|
||||||
|
fee: 0,
|
||||||
|
quantity: 2,
|
||||||
|
SymbolProfile: {
|
||||||
|
...symbolProfileDummyData,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
date: '2022-04-08',
|
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(0),
|
|
||||||
name: 'Novartis AG',
|
name: 'Novartis AG',
|
||||||
quantity: new Big(2),
|
symbol: 'NOVN.SW'
|
||||||
symbol: 'NOVN.SW',
|
},
|
||||||
type: 'SELL',
|
type: 'SELL',
|
||||||
unitPrice: new Big(85.73)
|
unitPrice: 85.73
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
});
|
|
||||||
|
|
||||||
portfolioCalculator.computeTransactionPoints();
|
const portfolioCalculator = factory.createCalculator({
|
||||||
|
activities,
|
||||||
|
calculationType: PerformanceCalculationType.TWR,
|
||||||
|
currency: 'CHF'
|
||||||
|
});
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
@ -113,7 +133,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
currentValue: new Big('0'),
|
currentValueInBaseCurrency: new Big('0'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('19.86'),
|
grossPerformance: new Big('19.86'),
|
||||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
@ -133,6 +153,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('0'),
|
averagePrice: new Big('0'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
dividend: new Big('0'),
|
||||||
|
dividendInBaseCurrency: new Big('0'),
|
||||||
fee: new Big('0'),
|
fee: new Big('0'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('19.86'),
|
grossPerformance: new Big('19.86'),
|
||||||
@ -155,7 +177,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
symbol: 'NOVN.SW',
|
symbol: 'NOVN.SW',
|
||||||
timeWeightedInvestment: new Big('151.6'),
|
timeWeightedInvestment: new Big('151.6'),
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
|
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
|
||||||
transactionCount: 2
|
transactionCount: 2,
|
||||||
|
valueInBaseCurrency: new Big('0')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('0'),
|
totalInvestment: new Big('0'),
|
@ -0,0 +1,27 @@
|
|||||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let factory: PortfolioCalculatorFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
factory = new PortfolioCalculatorFactory(
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip('Skip empty test', () => 1);
|
||||||
|
});
|
@ -0,0 +1,840 @@
|
|||||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||||
|
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||||
|
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
|
||||||
|
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
SymbolMetrics,
|
||||||
|
TimelinePosition,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
addMilliseconds,
|
||||||
|
differenceInDays,
|
||||||
|
format,
|
||||||
|
isBefore
|
||||||
|
} from 'date-fns';
|
||||||
|
import { cloneDeep, first, last, sortBy } from 'lodash';
|
||||||
|
|
||||||
|
export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||||
|
protected calculateOverallPerformance(
|
||||||
|
positions: TimelinePosition[]
|
||||||
|
): CurrentPositions {
|
||||||
|
let currentValueInBaseCurrency = new Big(0);
|
||||||
|
let grossPerformance = new Big(0);
|
||||||
|
let grossPerformanceWithCurrencyEffect = new Big(0);
|
||||||
|
let hasErrors = false;
|
||||||
|
let netPerformance = new Big(0);
|
||||||
|
let netPerformanceWithCurrencyEffect = new Big(0);
|
||||||
|
let totalInvestment = new Big(0);
|
||||||
|
let totalInvestmentWithCurrencyEffect = new Big(0);
|
||||||
|
let totalTimeWeightedInvestment = new Big(0);
|
||||||
|
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
|
||||||
|
|
||||||
|
for (const currentPosition of positions) {
|
||||||
|
if (currentPosition.valueInBaseCurrency) {
|
||||||
|
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
|
||||||
|
currentPosition.valueInBaseCurrency
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPosition.investment) {
|
||||||
|
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
||||||
|
|
||||||
|
totalInvestmentWithCurrencyEffect =
|
||||||
|
totalInvestmentWithCurrencyEffect.plus(
|
||||||
|
currentPosition.investmentWithCurrencyEffect
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPosition.grossPerformance) {
|
||||||
|
grossPerformance = grossPerformance.plus(
|
||||||
|
currentPosition.grossPerformance
|
||||||
|
);
|
||||||
|
|
||||||
|
grossPerformanceWithCurrencyEffect =
|
||||||
|
grossPerformanceWithCurrencyEffect.plus(
|
||||||
|
currentPosition.grossPerformanceWithCurrencyEffect
|
||||||
|
);
|
||||||
|
|
||||||
|
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||||
|
|
||||||
|
netPerformanceWithCurrencyEffect =
|
||||||
|
netPerformanceWithCurrencyEffect.plus(
|
||||||
|
currentPosition.netPerformanceWithCurrencyEffect
|
||||||
|
);
|
||||||
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPosition.timeWeightedInvestment) {
|
||||||
|
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
|
||||||
|
currentPosition.timeWeightedInvestment
|
||||||
|
);
|
||||||
|
|
||||||
|
totalTimeWeightedInvestmentWithCurrencyEffect =
|
||||||
|
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
|
||||||
|
currentPosition.timeWeightedInvestmentWithCurrencyEffect
|
||||||
|
);
|
||||||
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
|
Logger.warn(
|
||||||
|
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
|
||||||
|
'PortfolioCalculator'
|
||||||
|
);
|
||||||
|
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentValueInBaseCurrency,
|
||||||
|
grossPerformance,
|
||||||
|
grossPerformanceWithCurrencyEffect,
|
||||||
|
hasErrors,
|
||||||
|
netPerformance,
|
||||||
|
netPerformanceWithCurrencyEffect,
|
||||||
|
totalInvestment,
|
||||||
|
totalInvestmentWithCurrencyEffect,
|
||||||
|
netPerformancePercentage: totalTimeWeightedInvestment.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: netPerformance.div(totalTimeWeightedInvestment),
|
||||||
|
netPerformancePercentageWithCurrencyEffect:
|
||||||
|
totalTimeWeightedInvestmentWithCurrencyEffect.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: netPerformanceWithCurrencyEffect.div(
|
||||||
|
totalTimeWeightedInvestmentWithCurrencyEffect
|
||||||
|
),
|
||||||
|
grossPerformancePercentage: totalTimeWeightedInvestment.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: grossPerformance.div(totalTimeWeightedInvestment),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect:
|
||||||
|
totalTimeWeightedInvestmentWithCurrencyEffect.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: grossPerformanceWithCurrencyEffect.div(
|
||||||
|
totalTimeWeightedInvestmentWithCurrencyEffect
|
||||||
|
),
|
||||||
|
positions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSymbolMetrics({
|
||||||
|
dataSource,
|
||||||
|
end,
|
||||||
|
exchangeRates,
|
||||||
|
isChartMode = false,
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
step = 1,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
end: Date;
|
||||||
|
exchangeRates: { [dateString: string]: number };
|
||||||
|
isChartMode?: boolean;
|
||||||
|
marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
};
|
||||||
|
start: Date;
|
||||||
|
step?: number;
|
||||||
|
} & UniqueAsset): SymbolMetrics {
|
||||||
|
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
|
||||||
|
const currentValues: { [date: string]: Big } = {};
|
||||||
|
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||||
|
let fees = new Big(0);
|
||||||
|
let feesAtStartDate = new Big(0);
|
||||||
|
let feesAtStartDateWithCurrencyEffect = new Big(0);
|
||||||
|
let feesWithCurrencyEffect = new Big(0);
|
||||||
|
let grossPerformance = new Big(0);
|
||||||
|
let grossPerformanceWithCurrencyEffect = new Big(0);
|
||||||
|
let grossPerformanceAtStartDate = new Big(0);
|
||||||
|
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
|
||||||
|
let grossPerformanceFromSells = new Big(0);
|
||||||
|
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
|
||||||
|
let initialValue: Big;
|
||||||
|
let initialValueWithCurrencyEffect: Big;
|
||||||
|
let investmentAtStartDate: Big;
|
||||||
|
let investmentAtStartDateWithCurrencyEffect: Big;
|
||||||
|
const investmentValuesAccumulated: { [date: string]: Big } = {};
|
||||||
|
const investmentValuesAccumulatedWithCurrencyEffect: {
|
||||||
|
[date: string]: Big;
|
||||||
|
} = {};
|
||||||
|
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||||
|
let lastAveragePrice = new Big(0);
|
||||||
|
let lastAveragePriceWithCurrencyEffect = new Big(0);
|
||||||
|
const netPerformanceValues: { [date: string]: Big } = {};
|
||||||
|
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||||
|
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
|
||||||
|
|
||||||
|
const timeWeightedInvestmentValuesWithCurrencyEffect: {
|
||||||
|
[date: string]: Big;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
let totalDividend = new Big(0);
|
||||||
|
let totalDividendInBaseCurrency = new Big(0);
|
||||||
|
let totalInvestment = new Big(0);
|
||||||
|
let totalInvestmentFromBuyTransactions = new Big(0);
|
||||||
|
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
|
||||||
|
let totalInvestmentWithCurrencyEffect = new Big(0);
|
||||||
|
let totalQuantityFromBuyTransactions = new Big(0);
|
||||||
|
let totalUnits = new Big(0);
|
||||||
|
let valueAtStartDate: Big;
|
||||||
|
let valueAtStartDateWithCurrencyEffect: Big;
|
||||||
|
|
||||||
|
// Clone orders to keep the original values in this.orders
|
||||||
|
let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter(
|
||||||
|
({ SymbolProfile }) => {
|
||||||
|
return SymbolProfile.symbol === symbol;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orders.length <= 0) {
|
||||||
|
return {
|
||||||
|
currentValues: {},
|
||||||
|
currentValuesWithCurrencyEffect: {},
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
|
initialValue: new Big(0),
|
||||||
|
initialValueWithCurrencyEffect: new Big(0),
|
||||||
|
investmentValuesAccumulated: {},
|
||||||
|
investmentValuesAccumulatedWithCurrencyEffect: {},
|
||||||
|
investmentValuesWithCurrencyEffect: {},
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
netPerformanceValues: {},
|
||||||
|
netPerformanceValuesWithCurrencyEffect: {},
|
||||||
|
netPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
timeWeightedInvestment: new Big(0),
|
||||||
|
timeWeightedInvestmentValues: {},
|
||||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: {},
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
|
||||||
|
totalDividend: new Big(0),
|
||||||
|
totalDividendInBaseCurrency: new Big(0),
|
||||||
|
totalInvestment: new Big(0),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOfFirstTransaction = new Date(first(orders).date);
|
||||||
|
|
||||||
|
const unitPriceAtStartDate =
|
||||||
|
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
||||||
|
|
||||||
|
const unitPriceAtEndDate =
|
||||||
|
marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!unitPriceAtEndDate ||
|
||||||
|
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
currentValues: {},
|
||||||
|
currentValuesWithCurrencyEffect: {},
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
hasErrors: true,
|
||||||
|
initialValue: new Big(0),
|
||||||
|
initialValueWithCurrencyEffect: new Big(0),
|
||||||
|
investmentValuesAccumulated: {},
|
||||||
|
investmentValuesAccumulatedWithCurrencyEffect: {},
|
||||||
|
investmentValuesWithCurrencyEffect: {},
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
netPerformanceValues: {},
|
||||||
|
netPerformanceValuesWithCurrencyEffect: {},
|
||||||
|
netPerformanceWithCurrencyEffect: new Big(0),
|
||||||
|
timeWeightedInvestment: new Big(0),
|
||||||
|
timeWeightedInvestmentValues: {},
|
||||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: {},
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
|
||||||
|
totalDividend: new Big(0),
|
||||||
|
totalDividendInBaseCurrency: new Big(0),
|
||||||
|
totalInvestment: new Big(0),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a synthetic order at the start and the end date
|
||||||
|
orders.push({
|
||||||
|
date: format(start, DATE_FORMAT),
|
||||||
|
fee: new Big(0),
|
||||||
|
feeInBaseCurrency: new Big(0),
|
||||||
|
itemType: 'start',
|
||||||
|
quantity: new Big(0),
|
||||||
|
SymbolProfile: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: unitPriceAtStartDate
|
||||||
|
});
|
||||||
|
|
||||||
|
orders.push({
|
||||||
|
date: format(end, DATE_FORMAT),
|
||||||
|
fee: new Big(0),
|
||||||
|
feeInBaseCurrency: new Big(0),
|
||||||
|
itemType: 'end',
|
||||||
|
SymbolProfile: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
quantity: new Big(0),
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: unitPriceAtEndDate
|
||||||
|
});
|
||||||
|
|
||||||
|
let day = start;
|
||||||
|
let lastUnitPrice: Big;
|
||||||
|
|
||||||
|
if (isChartMode) {
|
||||||
|
const datesWithOrders = {};
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
datesWithOrders[order.date] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (isBefore(day, end)) {
|
||||||
|
const hasDate = datesWithOrders[format(day, DATE_FORMAT)];
|
||||||
|
|
||||||
|
if (!hasDate) {
|
||||||
|
orders.push({
|
||||||
|
date: format(day, DATE_FORMAT),
|
||||||
|
fee: new Big(0),
|
||||||
|
feeInBaseCurrency: new Big(0),
|
||||||
|
quantity: new Big(0),
|
||||||
|
SymbolProfile: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice:
|
||||||
|
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
|
||||||
|
lastUnitPrice
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUnitPrice = last(orders).unitPrice;
|
||||||
|
|
||||||
|
day = addDays(day, step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort orders so that the start and end placeholder order are at the correct
|
||||||
|
// position
|
||||||
|
orders = sortBy(orders, ({ date, itemType }) => {
|
||||||
|
let sortIndex = new Date(date);
|
||||||
|
|
||||||
|
if (itemType === 'end') {
|
||||||
|
sortIndex = addMilliseconds(sortIndex, 1);
|
||||||
|
} else if (itemType === 'start') {
|
||||||
|
sortIndex = addMilliseconds(sortIndex, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortIndex.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
|
||||||
|
return itemType === 'start';
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
|
||||||
|
return itemType === 'end';
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalInvestmentDays = 0;
|
||||||
|
let sumOfTimeWeightedInvestments = new Big(0);
|
||||||
|
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < orders.length; i += 1) {
|
||||||
|
const order = orders[i];
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log();
|
||||||
|
console.log();
|
||||||
|
console.log(i + 1, order.type, order.itemType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchangeRateAtOrderDate = exchangeRates[order.date];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.fee) {
|
||||||
|
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
|
||||||
|
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
|
||||||
|
exchangeRateAtOrderDate ?? 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.unitPrice) {
|
||||||
|
order.unitPriceInBaseCurrency = order.unitPrice.mul(
|
||||||
|
currentExchangeRate ?? 1
|
||||||
|
);
|
||||||
|
|
||||||
|
order.unitPriceInBaseCurrencyWithCurrencyEffect = order.unitPrice.mul(
|
||||||
|
exchangeRateAtOrderDate ?? 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
|
||||||
|
order.unitPriceInBaseCurrency
|
||||||
|
);
|
||||||
|
|
||||||
|
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
|
||||||
|
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect);
|
||||||
|
|
||||||
|
if (!investmentAtStartDate && i >= indexOfStartOrder) {
|
||||||
|
investmentAtStartDate = totalInvestment ?? new Big(0);
|
||||||
|
|
||||||
|
investmentAtStartDateWithCurrencyEffect =
|
||||||
|
totalInvestmentWithCurrencyEffect ?? new Big(0);
|
||||||
|
|
||||||
|
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||||
|
|
||||||
|
valueAtStartDateWithCurrencyEffect =
|
||||||
|
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transactionInvestment = new Big(0);
|
||||||
|
let transactionInvestmentWithCurrencyEffect = new Big(0);
|
||||||
|
|
||||||
|
if (order.type === 'BUY') {
|
||||||
|
transactionInvestment = order.quantity
|
||||||
|
.mul(order.unitPriceInBaseCurrency)
|
||||||
|
.mul(getFactor(order.type));
|
||||||
|
|
||||||
|
transactionInvestmentWithCurrencyEffect = order.quantity
|
||||||
|
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
|
||||||
|
.mul(getFactor(order.type));
|
||||||
|
|
||||||
|
totalQuantityFromBuyTransactions =
|
||||||
|
totalQuantityFromBuyTransactions.plus(order.quantity);
|
||||||
|
|
||||||
|
totalInvestmentFromBuyTransactions =
|
||||||
|
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
|
||||||
|
|
||||||
|
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
|
||||||
|
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
|
||||||
|
transactionInvestmentWithCurrencyEffect
|
||||||
|
);
|
||||||
|
} else if (order.type === 'SELL') {
|
||||||
|
if (totalUnits.gt(0)) {
|
||||||
|
transactionInvestment = totalInvestment
|
||||||
|
.div(totalUnits)
|
||||||
|
.mul(order.quantity)
|
||||||
|
.mul(getFactor(order.type));
|
||||||
|
transactionInvestmentWithCurrencyEffect =
|
||||||
|
totalInvestmentWithCurrencyEffect
|
||||||
|
.div(totalUnits)
|
||||||
|
.mul(order.quantity)
|
||||||
|
.mul(getFactor(order.type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log('totalInvestment', totalInvestment.toNumber());
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'totalInvestmentWithCurrencyEffect',
|
||||||
|
totalInvestmentWithCurrencyEffect.toNumber()
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('order.quantity', order.quantity.toNumber());
|
||||||
|
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'transactionInvestmentWithCurrencyEffect',
|
||||||
|
transactionInvestmentWithCurrencyEffect.toNumber()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalInvestmentBeforeTransaction = totalInvestment;
|
||||||
|
|
||||||
|
const totalInvestmentBeforeTransactionWithCurrencyEffect =
|
||||||
|
totalInvestmentWithCurrencyEffect;
|
||||||
|
|
||||||
|
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||||
|
|
||||||
|
totalInvestmentWithCurrencyEffect =
|
||||||
|
totalInvestmentWithCurrencyEffect.plus(
|
||||||
|
transactionInvestmentWithCurrencyEffect
|
||||||
|
);
|
||||||
|
|
||||||
|
if (i >= indexOfStartOrder && !initialValue) {
|
||||||
|
if (
|
||||||
|
i === indexOfStartOrder &&
|
||||||
|
!valueOfInvestmentBeforeTransaction.eq(0)
|
||||||
|
) {
|
||||||
|
initialValue = valueOfInvestmentBeforeTransaction;
|
||||||
|
|
||||||
|
initialValueWithCurrencyEffect =
|
||||||
|
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
|
||||||
|
} else if (transactionInvestment.gt(0)) {
|
||||||
|
initialValue = transactionInvestment;
|
||||||
|
|
||||||
|
initialValueWithCurrencyEffect =
|
||||||
|
transactionInvestmentWithCurrencyEffect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fees = fees.plus(order.feeInBaseCurrency ?? 0);
|
||||||
|
|
||||||
|
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
|
||||||
|
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
|
||||||
|
);
|
||||||
|
|
||||||
|
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
|
||||||
|
|
||||||
|
if (order.type === 'DIVIDEND') {
|
||||||
|
const dividend = order.quantity.mul(order.unitPrice);
|
||||||
|
|
||||||
|
totalDividend = totalDividend.plus(dividend);
|
||||||
|
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
|
||||||
|
dividend.mul(exchangeRateAtOrderDate ?? 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
|
||||||
|
|
||||||
|
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
|
||||||
|
order.unitPriceInBaseCurrencyWithCurrencyEffect
|
||||||
|
);
|
||||||
|
|
||||||
|
const grossPerformanceFromSell =
|
||||||
|
order.type === 'SELL'
|
||||||
|
? order.unitPriceInBaseCurrency
|
||||||
|
.minus(lastAveragePrice)
|
||||||
|
.mul(order.quantity)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
const grossPerformanceFromSellWithCurrencyEffect =
|
||||||
|
order.type === 'SELL'
|
||||||
|
? order.unitPriceInBaseCurrencyWithCurrencyEffect
|
||||||
|
.minus(lastAveragePriceWithCurrencyEffect)
|
||||||
|
.mul(order.quantity)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
||||||
|
grossPerformanceFromSell
|
||||||
|
);
|
||||||
|
|
||||||
|
grossPerformanceFromSellsWithCurrencyEffect =
|
||||||
|
grossPerformanceFromSellsWithCurrencyEffect.plus(
|
||||||
|
grossPerformanceFromSellWithCurrencyEffect
|
||||||
|
);
|
||||||
|
|
||||||
|
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: totalInvestmentFromBuyTransactions.div(
|
||||||
|
totalQuantityFromBuyTransactions
|
||||||
|
);
|
||||||
|
|
||||||
|
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
? new Big(0)
|
||||||
|
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
|
||||||
|
totalQuantityFromBuyTransactions
|
||||||
|
);
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log(
|
||||||
|
'grossPerformanceFromSells',
|
||||||
|
grossPerformanceFromSells.toNumber()
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'grossPerformanceFromSellWithCurrencyEffect',
|
||||||
|
grossPerformanceFromSellWithCurrencyEffect.toNumber()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGrossPerformance = valueOfInvestment
|
||||||
|
.minus(totalInvestment)
|
||||||
|
.plus(grossPerformanceFromSells);
|
||||||
|
|
||||||
|
const newGrossPerformanceWithCurrencyEffect =
|
||||||
|
valueOfInvestmentWithCurrencyEffect
|
||||||
|
.minus(totalInvestmentWithCurrencyEffect)
|
||||||
|
.plus(grossPerformanceFromSellsWithCurrencyEffect);
|
||||||
|
|
||||||
|
grossPerformance = newGrossPerformance;
|
||||||
|
|
||||||
|
grossPerformanceWithCurrencyEffect =
|
||||||
|
newGrossPerformanceWithCurrencyEffect;
|
||||||
|
|
||||||
|
if (order.itemType === 'start') {
|
||||||
|
feesAtStartDate = fees;
|
||||||
|
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
|
||||||
|
grossPerformanceAtStartDate = grossPerformance;
|
||||||
|
|
||||||
|
grossPerformanceAtStartDateWithCurrencyEffect =
|
||||||
|
grossPerformanceWithCurrencyEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i > indexOfStartOrder && ['BUY', 'SELL'].includes(order.type)) {
|
||||||
|
// Only consider periods with an investment for the calculation of
|
||||||
|
// the time weighted investment
|
||||||
|
if (valueOfInvestmentBeforeTransaction.gt(0)) {
|
||||||
|
// Calculate the number of days since the previous order
|
||||||
|
const orderDate = new Date(order.date);
|
||||||
|
const previousOrderDate = new Date(orders[i - 1].date);
|
||||||
|
|
||||||
|
let daysSinceLastOrder = differenceInDays(
|
||||||
|
orderDate,
|
||||||
|
previousOrderDate
|
||||||
|
);
|
||||||
|
if (daysSinceLastOrder <= 0) {
|
||||||
|
// The time between two activities on the same day is unknown
|
||||||
|
// -> Set it to the smallest floating point number greater than 0
|
||||||
|
daysSinceLastOrder = Number.EPSILON;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum up the total investment days since the start date to calculate
|
||||||
|
// the time weighted investment
|
||||||
|
totalInvestmentDays += daysSinceLastOrder;
|
||||||
|
|
||||||
|
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
|
||||||
|
valueAtStartDate
|
||||||
|
.minus(investmentAtStartDate)
|
||||||
|
.plus(totalInvestmentBeforeTransaction)
|
||||||
|
.mul(daysSinceLastOrder)
|
||||||
|
);
|
||||||
|
|
||||||
|
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
|
||||||
|
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
|
||||||
|
valueAtStartDateWithCurrencyEffect
|
||||||
|
.minus(investmentAtStartDateWithCurrencyEffect)
|
||||||
|
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
|
||||||
|
.mul(daysSinceLastOrder)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isChartMode) {
|
||||||
|
currentValues[order.date] = valueOfInvestment;
|
||||||
|
|
||||||
|
currentValuesWithCurrencyEffect[order.date] =
|
||||||
|
valueOfInvestmentWithCurrencyEffect;
|
||||||
|
|
||||||
|
netPerformanceValues[order.date] = grossPerformance
|
||||||
|
.minus(grossPerformanceAtStartDate)
|
||||||
|
.minus(fees.minus(feesAtStartDate));
|
||||||
|
|
||||||
|
netPerformanceValuesWithCurrencyEffect[order.date] =
|
||||||
|
grossPerformanceWithCurrencyEffect
|
||||||
|
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
|
||||||
|
.minus(
|
||||||
|
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
|
||||||
|
);
|
||||||
|
|
||||||
|
investmentValuesAccumulated[order.date] = totalInvestment;
|
||||||
|
|
||||||
|
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
|
||||||
|
totalInvestmentWithCurrencyEffect;
|
||||||
|
|
||||||
|
investmentValuesWithCurrencyEffect[order.date] = (
|
||||||
|
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
|
||||||
|
).add(transactionInvestmentWithCurrencyEffect);
|
||||||
|
|
||||||
|
timeWeightedInvestmentValues[order.date] =
|
||||||
|
totalInvestmentDays > 0
|
||||||
|
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
|
||||||
|
totalInvestmentDays > 0
|
||||||
|
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
|
||||||
|
totalInvestmentDays
|
||||||
|
)
|
||||||
|
: new Big(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log('totalInvestment', totalInvestment.toNumber());
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'totalInvestmentWithCurrencyEffect',
|
||||||
|
totalInvestmentWithCurrencyEffect.toNumber()
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'totalGrossPerformance',
|
||||||
|
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'totalGrossPerformanceWithCurrencyEffect',
|
||||||
|
grossPerformanceWithCurrencyEffect
|
||||||
|
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
|
||||||
|
.toNumber()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i === indexOfEndOrder) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalGrossPerformance = grossPerformance.minus(
|
||||||
|
grossPerformanceAtStartDate
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalGrossPerformanceWithCurrencyEffect =
|
||||||
|
grossPerformanceWithCurrencyEffect.minus(
|
||||||
|
grossPerformanceAtStartDateWithCurrencyEffect
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalNetPerformance = grossPerformance
|
||||||
|
.minus(grossPerformanceAtStartDate)
|
||||||
|
.minus(fees.minus(feesAtStartDate));
|
||||||
|
|
||||||
|
const totalNetPerformanceWithCurrencyEffect =
|
||||||
|
grossPerformanceWithCurrencyEffect
|
||||||
|
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
|
||||||
|
.minus(feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect));
|
||||||
|
|
||||||
|
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
|
||||||
|
totalInvestmentDays > 0
|
||||||
|
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
|
||||||
|
totalInvestmentDays > 0
|
||||||
|
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
|
||||||
|
totalInvestmentDays
|
||||||
|
)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
const grossPerformancePercentage =
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
|
||||||
|
? totalGrossPerformance.div(
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||||
|
)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
const grossPerformancePercentageWithCurrencyEffect =
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
? totalGrossPerformanceWithCurrencyEffect.div(
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
|
||||||
|
)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
const feesPerUnit = totalUnits.gt(0)
|
||||||
|
? fees.minus(feesAtStartDate).div(totalUnits)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
|
||||||
|
? feesWithCurrencyEffect
|
||||||
|
.minus(feesAtStartDateWithCurrencyEffect)
|
||||||
|
.div(totalUnits)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
const netPerformancePercentage =
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
|
||||||
|
? totalNetPerformance.div(
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||||
|
)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
const netPerformancePercentageWithCurrencyEffect =
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
? totalNetPerformanceWithCurrencyEffect.div(
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
|
||||||
|
)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log(
|
||||||
|
`
|
||||||
|
${symbol}
|
||||||
|
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
|
||||||
|
2
|
||||||
|
)} -> ${unitPriceAtEndDate.toFixed(2)}
|
||||||
|
Total investment: ${totalInvestment.toFixed(2)}
|
||||||
|
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
Total dividend: ${totalDividend.toFixed(2)}
|
||||||
|
Gross performance: ${totalGrossPerformance.toFixed(
|
||||||
|
2
|
||||||
|
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
|
||||||
|
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
|
||||||
|
2
|
||||||
|
)} / ${grossPerformancePercentageWithCurrencyEffect
|
||||||
|
.mul(100)
|
||||||
|
.toFixed(2)}%
|
||||||
|
Fees per unit: ${feesPerUnit.toFixed(2)}
|
||||||
|
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
Net performance: ${totalNetPerformance.toFixed(
|
||||||
|
2
|
||||||
|
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
|
||||||
|
Net performance with currency effect: ${totalNetPerformanceWithCurrencyEffect.toFixed(
|
||||||
|
2
|
||||||
|
)} / ${netPerformancePercentageWithCurrencyEffect.mul(100).toFixed(2)}%`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentValues,
|
||||||
|
currentValuesWithCurrencyEffect,
|
||||||
|
grossPerformancePercentage,
|
||||||
|
grossPerformancePercentageWithCurrencyEffect,
|
||||||
|
initialValue,
|
||||||
|
initialValueWithCurrencyEffect,
|
||||||
|
investmentValuesAccumulated,
|
||||||
|
investmentValuesAccumulatedWithCurrencyEffect,
|
||||||
|
investmentValuesWithCurrencyEffect,
|
||||||
|
netPerformancePercentage,
|
||||||
|
netPerformancePercentageWithCurrencyEffect,
|
||||||
|
netPerformanceValues,
|
||||||
|
netPerformanceValuesWithCurrencyEffect,
|
||||||
|
timeWeightedInvestmentValues,
|
||||||
|
timeWeightedInvestmentValuesWithCurrencyEffect,
|
||||||
|
totalDividend,
|
||||||
|
totalDividendInBaseCurrency,
|
||||||
|
totalInvestment,
|
||||||
|
totalInvestmentWithCurrencyEffect,
|
||||||
|
grossPerformance: totalGrossPerformance,
|
||||||
|
grossPerformanceWithCurrencyEffect:
|
||||||
|
totalGrossPerformanceWithCurrencyEffect,
|
||||||
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||||
|
netPerformance: totalNetPerformance,
|
||||||
|
netPerformanceWithCurrencyEffect: totalNetPerformanceWithCurrencyEffect,
|
||||||
|
timeWeightedInvestment:
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate,
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect:
|
||||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -43,6 +43,17 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
|
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
|
case 'MSFT':
|
||||||
|
if (isSameDay(parseDate('2021-09-16'), date)) {
|
||||||
|
return { marketPrice: 89.12 };
|
||||||
|
} else if (isSameDay(parseDate('2021-11-16'), date)) {
|
||||||
|
return { marketPrice: 339.51 };
|
||||||
|
} else if (isSameDay(parseDate('2023-07-10'), date)) {
|
||||||
|
return { marketPrice: 331.83 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
case 'NOVN.SW':
|
case 'NOVN.SW':
|
||||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||||
return { marketPrice: 87.8 };
|
return { marketPrice: 87.8 };
|
||||||
|
@ -107,7 +107,9 @@ describe('CurrentRateService', () => {
|
|||||||
|
|
||||||
currentRateService = new CurrentRateService(
|
currentRateService = new CurrentRateService(
|
||||||
dataProviderService,
|
dataProviderService,
|
||||||
marketDataService
|
marketDataService,
|
||||||
|
null,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
@ -6,8 +7,10 @@ import {
|
|||||||
ResponseError,
|
ResponseError,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
import { flatten, isEmpty, uniqBy } from 'lodash';
|
import { flatten, isEmpty, uniqBy } from 'lodash';
|
||||||
|
|
||||||
@ -19,16 +22,19 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
|
|||||||
export class CurrentRateService {
|
export class CurrentRateService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly marketDataService: MarketDataService
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly orderService: OrderService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// TODO: Pass user instead of using this.request.user
|
||||||
public async getValues({
|
public async getValues({
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery
|
dateQuery
|
||||||
}: GetValuesParams): Promise<GetValuesObject> {
|
}: GetValuesParams): Promise<GetValuesObject> {
|
||||||
const dataProviderInfos: DataProviderInfo[] = [];
|
const dataProviderInfos: DataProviderInfo[] = [];
|
||||||
|
|
||||||
const includeToday =
|
const includesToday =
|
||||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||||
@ -37,10 +43,10 @@ export class CurrentRateService {
|
|||||||
const quoteErrors: ResponseError['errors'] = [];
|
const quoteErrors: ResponseError['errors'] = [];
|
||||||
const today = resetHours(new Date());
|
const today = resetHours(new Date());
|
||||||
|
|
||||||
if (includeToday) {
|
if (includesToday) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.dataProviderService
|
this.dataProviderService
|
||||||
.getQuotes({ items: dataGatheringItems })
|
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
|
||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result: GetValueObject[] = [];
|
const result: GetValueObject[] = [];
|
||||||
|
|
||||||
@ -117,11 +123,17 @@ export class CurrentRateService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
// Fallback to unit price of latest activity
|
||||||
|
const latestActivity = await this.orderService.getLatestOrder({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
value = {
|
value = {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: today,
|
date: today,
|
||||||
marketPrice: 0
|
marketPrice: latestActivity?.unitPrice ?? 0
|
||||||
};
|
};
|
||||||
|
|
||||||
response.values.push(value);
|
response.values.push(value);
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
export interface CurrentPositions extends ResponseError {
|
export interface CurrentPositions extends ResponseError {
|
||||||
positions: TimelinePosition[];
|
currentValueInBaseCurrency: Big;
|
||||||
grossPerformance: Big;
|
grossPerformance: Big;
|
||||||
grossPerformanceWithCurrencyEffect: Big;
|
grossPerformanceWithCurrencyEffect: Big;
|
||||||
grossPerformancePercentage: Big;
|
grossPerformancePercentage: Big;
|
||||||
@ -14,6 +14,7 @@ export interface CurrentPositions extends ResponseError {
|
|||||||
netPerformanceWithCurrencyEffect: Big;
|
netPerformanceWithCurrencyEffect: Big;
|
||||||
netPerformancePercentage: Big;
|
netPerformancePercentage: Big;
|
||||||
netPerformancePercentageWithCurrencyEffect: Big;
|
netPerformancePercentageWithCurrencyEffect: Big;
|
||||||
currentValue: Big;
|
positions: TimelinePosition[];
|
||||||
totalInvestment: Big;
|
totalInvestment: Big;
|
||||||
|
totalInvestmentWithCurrencyEffect: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import { PortfolioOrder } from './portfolio-order.interface';
|
import { PortfolioOrder } from './portfolio-order.interface';
|
||||||
|
|
||||||
export interface PortfolioOrderItem extends PortfolioOrder {
|
export interface PortfolioOrderItem extends PortfolioOrder {
|
||||||
feeInBaseCurrency?: Big;
|
feeInBaseCurrency?: Big;
|
||||||
feeInBaseCurrencyWithCurrencyEffect?: Big;
|
feeInBaseCurrencyWithCurrencyEffect?: Big;
|
||||||
itemType?: '' | 'start' | 'end';
|
itemType?: 'end' | 'start';
|
||||||
unitPriceInBaseCurrency?: Big;
|
unitPriceInBaseCurrency?: Big;
|
||||||
unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
|
unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
|
||||||
}
|
}
|
@ -1,15 +1,12 @@
|
|||||||
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import Big from 'big.js';
|
|
||||||
|
|
||||||
export interface PortfolioOrder {
|
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
|
||||||
currency: string;
|
|
||||||
date: string;
|
date: string;
|
||||||
dataSource: DataSource;
|
|
||||||
fee: Big;
|
fee: Big;
|
||||||
name: string;
|
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
SymbolProfile: Pick<
|
||||||
tags?: Tag[];
|
Activity['SymbolProfile'],
|
||||||
type: TypeOfOrder;
|
'currency' | 'dataSource' | 'name' | 'symbol'
|
||||||
|
>;
|
||||||
unitPrice: Big;
|
unitPrice: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import {
|
import {
|
||||||
DataProviderInfo,
|
DataProviderInfo,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
HistoricalDataItem
|
HistoricalDataItem
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { Tag } from '@prisma/client';
|
import { Account, Tag } from '@prisma/client';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
|
accounts: Account[];
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
dataProviderInfo: DataProviderInfo;
|
dataProviderInfo: DataProviderInfo;
|
||||||
dividendInBaseCurrency: number;
|
dividendInBaseCurrency: number;
|
||||||
@ -26,7 +27,7 @@ export interface PortfolioPositionDetail {
|
|||||||
netPerformancePercent: number;
|
netPerformancePercent: number;
|
||||||
netPerformancePercentWithCurrencyEffect: number;
|
netPerformancePercentWithCurrencyEffect: number;
|
||||||
netPerformanceWithCurrencyEffect: number;
|
netPerformanceWithCurrencyEffect: number;
|
||||||
orders: OrderWithAccount[];
|
orders: Activity[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
SymbolProfile: EnhancedSymbolProfile;
|
SymbolProfile: EnhancedSymbolProfile;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { DataSource, Tag } from '@prisma/client';
|
import { DataSource, Tag } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
export interface TransactionPointSymbol {
|
export interface TransactionPointSymbol {
|
||||||
|
averagePrice: Big;
|
||||||
currency: string;
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
dividend: Big;
|
||||||
fee: Big;
|
fee: Big;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
investment: Big;
|
investment: Big;
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,16 +1,19 @@
|
|||||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
HEADER_KEY_IMPERSONATION
|
HEADER_KEY_IMPERSONATION
|
||||||
@ -18,6 +21,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioDividends,
|
PortfolioDividends,
|
||||||
|
PortfolioHoldingsResponse,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
@ -43,7 +47,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
@ -57,6 +61,8 @@ export class PortfolioController {
|
|||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly impersonationService: ImpersonationService,
|
||||||
|
private readonly orderService: OrderService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
@ -71,8 +77,11 @@ export class PortfolioController {
|
|||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string,
|
||||||
|
@Query('withLiabilities') withLiabilitiesParam = 'false'
|
||||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
|
const withLiabilities = withLiabilitiesParam === 'true';
|
||||||
|
|
||||||
let hasDetails = true;
|
let hasDetails = true;
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
const hasReadRestrictedAccessPermission =
|
const hasReadRestrictedAccessPermission =
|
||||||
@ -91,21 +100,15 @@ export class PortfolioController {
|
|||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const { accounts, hasErrors, holdings, platforms, summary } =
|
||||||
accounts,
|
await this.portfolioService.getDetails({
|
||||||
filteredValueInBaseCurrency,
|
dateRange,
|
||||||
filteredValueInPercentage,
|
filters,
|
||||||
hasErrors,
|
impersonationId,
|
||||||
holdings,
|
withLiabilities,
|
||||||
platforms,
|
userId: this.request.user.id,
|
||||||
summary,
|
withSummary: true
|
||||||
totalValueInBaseCurrency
|
});
|
||||||
} = await this.portfolioService.getDetails({
|
|
||||||
dateRange,
|
|
||||||
filters,
|
|
||||||
impersonationId,
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
@ -118,27 +121,23 @@ export class PortfolioController {
|
|||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
const totalInvestment = Object.values(holdings)
|
const totalInvestment = Object.values(holdings)
|
||||||
.map((portfolioPosition) => {
|
.map(({ investment }) => {
|
||||||
return portfolioPosition.investment;
|
return investment;
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
const totalValue = Object.values(holdings)
|
const totalValue = Object.values(holdings)
|
||||||
.map((portfolioPosition) => {
|
.filter(({ assetClass, assetSubClass }) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return assetClass !== 'CASH' && assetSubClass !== 'CASH';
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
})
|
||||||
portfolioPosition.currency,
|
.map(({ valueInBaseCurrency }) => {
|
||||||
this.request.user.Settings.settings.baseCurrency
|
return valueInBaseCurrency;
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
portfolioPosition.grossPerformance = null;
|
|
||||||
portfolioPosition.investment =
|
portfolioPosition.investment =
|
||||||
portfolioPosition.investment / totalInvestment;
|
portfolioPosition.investment / totalInvestment;
|
||||||
portfolioPosition.netPerformance = null;
|
|
||||||
portfolioPosition.quantity = null;
|
|
||||||
portfolioPosition.valueInPercentage =
|
portfolioPosition.valueInPercentage =
|
||||||
portfolioPosition.valueInBaseCurrency / totalValue;
|
portfolioPosition.valueInBaseCurrency / totalValue;
|
||||||
}
|
}
|
||||||
@ -164,19 +163,21 @@ export class PortfolioController {
|
|||||||
'currentGrossPerformanceWithCurrencyEffect',
|
'currentGrossPerformanceWithCurrencyEffect',
|
||||||
'currentNetPerformance',
|
'currentNetPerformance',
|
||||||
'currentNetPerformanceWithCurrencyEffect',
|
'currentNetPerformanceWithCurrencyEffect',
|
||||||
|
'currentNetWorth',
|
||||||
'currentValue',
|
'currentValue',
|
||||||
'dividend',
|
'dividendInBaseCurrency',
|
||||||
'emergencyFund',
|
'emergencyFund',
|
||||||
'excludedAccountsAndActivities',
|
'excludedAccountsAndActivities',
|
||||||
'fees',
|
'fees',
|
||||||
|
'filteredValueInBaseCurrency',
|
||||||
'fireWealth',
|
'fireWealth',
|
||||||
'interest',
|
'interest',
|
||||||
'items',
|
'items',
|
||||||
'liabilities',
|
'liabilities',
|
||||||
'netWorth',
|
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
'totalInvestment',
|
'totalInvestment',
|
||||||
'totalSell'
|
'totalSell',
|
||||||
|
'totalValueInBaseCurrency'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,12 +204,9 @@ export class PortfolioController {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
accounts,
|
accounts,
|
||||||
filteredValueInBaseCurrency,
|
|
||||||
filteredValueInPercentage,
|
|
||||||
hasError,
|
hasError,
|
||||||
holdings,
|
holdings,
|
||||||
platforms,
|
platforms,
|
||||||
totalValueInBaseCurrency,
|
|
||||||
summary: portfolioSummary
|
summary: portfolioSummary
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -235,11 +233,24 @@ export class PortfolioController {
|
|||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
let dividends = await this.portfolioService.getDividends({
|
const impersonationUserId =
|
||||||
dateRange,
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
|
const { endDate, startDate } = getInterval(dateRange);
|
||||||
|
|
||||||
|
const { activities } = await this.orderService.getOrders({
|
||||||
|
endDate,
|
||||||
filters,
|
filters,
|
||||||
groupBy,
|
startDate,
|
||||||
impersonationId
|
userCurrency,
|
||||||
|
userId: impersonationUserId || this.request.user.id,
|
||||||
|
types: ['DIVIDEND']
|
||||||
|
});
|
||||||
|
|
||||||
|
let dividends = await this.portfolioService.getDividends({
|
||||||
|
activities,
|
||||||
|
groupBy
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -269,6 +280,35 @@ export class PortfolioController {
|
|||||||
return { dividends };
|
return { dividends };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('holdings')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getHoldings(
|
||||||
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('holdingType') filterByHoldingType?: string,
|
||||||
|
@Query('query') filterBySearchQuery?: string,
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
|
): Promise<PortfolioHoldingsResponse> {
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByHoldingType,
|
||||||
|
filterBySearchQuery,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
|
const { holdings } = await this.portfolioService.getDetails({
|
||||||
|
filters,
|
||||||
|
impersonationId,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return { holdings: Object.values(holdings) };
|
||||||
|
}
|
||||||
|
|
||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@ -346,8 +386,12 @@ export class PortfolioController {
|
|||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string,
|
@Query('tags') filterByTags?: string,
|
||||||
@Query('withExcludedAccounts') withExcludedAccounts = false
|
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false',
|
||||||
|
@Query('withItems') withItemsParam = 'false'
|
||||||
): Promise<PortfolioPerformanceResponse> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
|
const withExcludedAccounts = withExcludedAccountsParam === 'true';
|
||||||
|
const withItems = withItemsParam === 'true';
|
||||||
|
|
||||||
const hasReadRestrictedAccessPermission =
|
const hasReadRestrictedAccessPermission =
|
||||||
this.userService.hasReadRestrictedAccessPermission({
|
this.userService.hasReadRestrictedAccessPermission({
|
||||||
impersonationId,
|
impersonationId,
|
||||||
@ -365,6 +409,7 @@ export class PortfolioController {
|
|||||||
filters,
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
withExcludedAccounts,
|
withExcludedAccounts,
|
||||||
|
withItems,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -377,6 +422,7 @@ export class PortfolioController {
|
|||||||
({
|
({
|
||||||
date,
|
date,
|
||||||
netPerformanceInPercentage,
|
netPerformanceInPercentage,
|
||||||
|
netPerformanceInPercentageWithCurrencyEffect,
|
||||||
netWorth,
|
netWorth,
|
||||||
totalInvestment,
|
totalInvestment,
|
||||||
value
|
value
|
||||||
@ -384,6 +430,7 @@ export class PortfolioController {
|
|||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
netPerformanceInPercentage,
|
netPerformanceInPercentage,
|
||||||
|
netPerformanceInPercentageWithCurrencyEffect,
|
||||||
netWorthInPercentage:
|
netWorthInPercentage:
|
||||||
performanceInformation.performance.currentNetWorth === 0
|
performanceInformation.performance.currentNetWorth === 0
|
||||||
? 0
|
? 0
|
||||||
@ -429,6 +476,10 @@ export class PortfolioController {
|
|||||||
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
performanceInformation.performance = nullifyValuesInObject(
|
||||||
|
performanceInformation.performance,
|
||||||
|
['currentNetPerformance', 'currentNetPerformancePercent']
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return performanceInformation;
|
return performanceInformation;
|
||||||
@ -483,7 +534,6 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { holdings } = await this.portfolioService.getDetails({
|
const { holdings } = await this.portfolioService.getDetails({
|
||||||
dateRange: 'max',
|
|
||||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||||
impersonationId: access.userId,
|
impersonationId: access.userId,
|
||||||
userId: user.id
|
userId: user.id
|
||||||
@ -515,7 +565,8 @@ export class PortfolioController {
|
|||||||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
name: portfolioPosition.name,
|
name: portfolioPosition.name,
|
||||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
netPerformancePercentWithCurrencyEffect:
|
||||||
|
portfolioPosition.netPerformancePercentWithCurrencyEffect,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
symbol: portfolioPosition.symbol,
|
symbol: portfolioPosition.symbol,
|
||||||
url: portfolioPosition.url,
|
url: portfolioPosition.url,
|
||||||
|
@ -15,6 +15,7 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym
|
|||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
@ -41,6 +42,7 @@ import { RulesService } from './rules.service';
|
|||||||
AccountBalanceService,
|
AccountBalanceService,
|
||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
RulesService
|
RulesService
|
||||||
]
|
]
|
||||||
|
@ -1,18 +1,19 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { Big } from 'big.js';
|
||||||
|
|
||||||
import Big from 'big.js';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
describe('PortfolioService', () => {
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
let portfolioService: PortfolioService;
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
beforeAll(async () => {
|
||||||
let currentRateService: CurrentRateService;
|
portfolioService = new PortfolioService(
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
null,
|
||||||
|
null,
|
||||||
beforeEach(() => {
|
null,
|
||||||
currentRateService = new CurrentRateService(null, null);
|
null,
|
||||||
|
null,
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
null,
|
||||||
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@ -21,16 +22,9 @@ describe('PortfolioCalculator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('annualized performance percentage', () => {
|
describe('annualized performance percentage', () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
currency: 'USD',
|
|
||||||
orders: []
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Get annualized performance', async () => {
|
it('Get annualized performance', async () => {
|
||||||
expect(
|
expect(
|
||||||
portfolioCalculator
|
portfolioService
|
||||||
.getAnnualizedPerformancePercent({
|
.getAnnualizedPerformancePercent({
|
||||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||||
netPerformancePercent: new Big(0)
|
netPerformancePercent: new Big(0)
|
||||||
@ -39,7 +33,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
).toEqual(0);
|
).toEqual(0);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
portfolioCalculator
|
portfolioService
|
||||||
.getAnnualizedPerformancePercent({
|
.getAnnualizedPerformancePercent({
|
||||||
daysInMarket: 0,
|
daysInMarket: 0,
|
||||||
netPerformancePercent: new Big(0)
|
netPerformancePercent: new Big(0)
|
||||||
@ -51,7 +45,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||||
*/
|
*/
|
||||||
expect(
|
expect(
|
||||||
portfolioCalculator
|
portfolioService
|
||||||
.getAnnualizedPerformancePercent({
|
.getAnnualizedPerformancePercent({
|
||||||
daysInMarket: 65, // < 1 year
|
daysInMarket: 65, // < 1 year
|
||||||
netPerformancePercent: new Big(0.1025)
|
netPerformancePercent: new Big(0.1025)
|
||||||
@ -60,7 +54,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
).toBeCloseTo(0.729705);
|
).toBeCloseTo(0.729705);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
portfolioCalculator
|
portfolioService
|
||||||
.getAnnualizedPerformancePercent({
|
.getAnnualizedPerformancePercent({
|
||||||
daysInMarket: 365, // 1 year
|
daysInMarket: 365, // 1 year
|
||||||
netPerformancePercent: new Big(0.05)
|
netPerformancePercent: new Big(0.05)
|
||||||
@ -72,7 +66,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||||
*/
|
*/
|
||||||
expect(
|
expect(
|
||||||
portfolioCalculator
|
portfolioService
|
||||||
.getAnnualizedPerformancePercent({
|
.getAnnualizedPerformancePercent({
|
||||||
daysInMarket: 575, // > 1 year
|
daysInMarket: 575, // > 1 year
|
||||||
netPerformancePercent: new Big(0.2374)
|
netPerformancePercent: new Big(0.2374)
|
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
inject: [ConfigurationService],
|
inject: [ConfigurationService],
|
||||||
useFactory: async (configurationService: ConfigurationService) => {
|
useFactory: async (configurationService: ConfigurationService) => {
|
||||||
return <RedisClientOptions>{
|
return <RedisClientOptions>{
|
||||||
|
db: configurationService.get('REDIS_DB'),
|
||||||
host: configurationService.get('REDIS_HOST'),
|
host: configurationService.get('REDIS_HOST'),
|
||||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||||
password: configurationService.get('REDIS_PASSWORD'),
|
password: configurationService.get('REDIS_PASSWORD'),
|
||||||
|
@ -21,7 +21,7 @@ export class RedisCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async get(key: string): Promise<string> {
|
public async get(key: string): Promise<string> {
|
||||||
return await this.cache.get(key);
|
return this.cache.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||||
@ -29,15 +29,15 @@ export class RedisCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async remove(key: string) {
|
public async remove(key: string) {
|
||||||
await this.cache.del(key);
|
return this.cache.del(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async reset() {
|
public async reset() {
|
||||||
await this.cache.reset();
|
return this.cache.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async set(key: string, value: string, ttlInSeconds?: number) {
|
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||||
await this.cache.set(
|
return this.cache.set(
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||||
|
@ -116,7 +116,7 @@ export class SubscriptionController {
|
|||||||
@Body() { couponId, priceId }: { couponId: string; priceId: string }
|
@Body() { couponId, priceId }: { couponId: string; priceId: string }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
return await this.subscriptionService.createCheckoutSession({
|
return this.subscriptionService.createCheckoutSession({
|
||||||
couponId,
|
couponId,
|
||||||
priceId,
|
priceId,
|
||||||
user: this.request.user
|
user: this.request.user
|
||||||
|
@ -39,9 +39,11 @@ export class SymbolController {
|
|||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async lookupSymbol(
|
public async lookupSymbol(
|
||||||
@Query('includeIndices') includeIndices: boolean = false,
|
@Query('includeIndices') includeIndicesParam = 'false',
|
||||||
@Query('query') query = ''
|
@Query('query') query = ''
|
||||||
): Promise<{ items: LookupItem[] }> {
|
): Promise<{ items: LookupItem[] }> {
|
||||||
|
const includeIndices = includeIndicesParam === 'true';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return this.symbolService.lookup({
|
return this.symbolService.lookup({
|
||||||
includeIndices,
|
includeIndices,
|
||||||
|
@ -7,20 +7,22 @@ import type {
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsISO4217CurrencyCode,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { eachYearOfInterval, format } from 'date-fns';
|
||||||
|
|
||||||
export class UpdateUserSettingDto {
|
export class UpdateUserSettingDto {
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
annualInterestRate?: number;
|
annualInterestRate?: number;
|
||||||
|
|
||||||
|
@IsISO4217CurrencyCode()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
|
||||||
baseCurrency?: string;
|
baseCurrency?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -31,7 +33,20 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
colorScheme?: ColorScheme;
|
colorScheme?: ColorScheme;
|
||||||
|
|
||||||
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd'])
|
@IsIn(<DateRange[]>[
|
||||||
|
'1d',
|
||||||
|
'1y',
|
||||||
|
'5y',
|
||||||
|
'max',
|
||||||
|
'mtd',
|
||||||
|
'wtd',
|
||||||
|
'ytd',
|
||||||
|
...eachYearOfInterval({ end: new Date(), start: new Date(0) }).map(
|
||||||
|
(date) => {
|
||||||
|
return format(date, 'yyyy');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
])
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
dateRange?: DateRange;
|
dateRange?: DateRange;
|
||||||
|
|
||||||
|
@ -2,7 +2,11 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
|
|||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import {
|
||||||
|
hasPermission,
|
||||||
|
hasRole,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -59,6 +63,13 @@ export class UserController {
|
|||||||
public async getUser(
|
public async getUser(
|
||||||
@Headers('accept-language') acceptLanguage: string
|
@Headers('accept-language') acceptLanguage: string
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
|
if (hasRole(this.request.user, 'INACTIVE')) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.userService.getUser(
|
return this.userService.getUser(
|
||||||
this.request.user,
|
this.request.user,
|
||||||
acceptLanguage?.split(',')?.[0]
|
acceptLanguage?.split(',')?.[0]
|
||||||
@ -124,7 +135,7 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.userService.updateUserSetting({
|
return this.userService.updateUserSetting({
|
||||||
userSettings,
|
userSettings,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
|
DEFAULT_LANGUAGE_CODE,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SYSTEM_MESSAGE,
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
locale
|
locale
|
||||||
@ -31,6 +33,8 @@ const crypto = require('crypto');
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
|
private i18nService = new I18nService();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
@ -47,13 +51,22 @@ export class UserService {
|
|||||||
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
||||||
aLocale = locale
|
aLocale = locale
|
||||||
): Promise<IUser> {
|
): Promise<IUser> {
|
||||||
const access = await this.prismaService.access.findMany({
|
let [access, firstActivity, tags] = await Promise.all([
|
||||||
include: {
|
this.prismaService.access.findMany({
|
||||||
User: true
|
include: {
|
||||||
},
|
User: true
|
||||||
orderBy: { alias: 'asc' },
|
},
|
||||||
where: { GranteeUser: { id } }
|
orderBy: { alias: 'asc' },
|
||||||
});
|
where: { GranteeUser: { id } }
|
||||||
|
}),
|
||||||
|
this.prismaService.order.findFirst({
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
where: { userId: id }
|
||||||
|
}),
|
||||||
|
this.tagService.getByUser(id)
|
||||||
|
]);
|
||||||
|
|
||||||
let systemMessage: SystemMessage;
|
let systemMessage: SystemMessage;
|
||||||
|
|
||||||
@ -65,8 +78,6 @@ export class UserService {
|
|||||||
systemMessage = systemMessageProperty;
|
systemMessage = systemMessageProperty;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tags = await this.tagService.getByUser(id);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
subscription.type === 'Basic'
|
subscription.type === 'Basic'
|
||||||
@ -87,6 +98,7 @@ export class UserService {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
accounts: Account,
|
accounts: Account,
|
||||||
|
dateOfFirstActivity: firstActivity?.date ?? new Date(),
|
||||||
settings: {
|
settings: {
|
||||||
...(<UserSettings>Settings.settings),
|
...(<UserSettings>Settings.settings),
|
||||||
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
|
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
|
||||||
@ -325,8 +337,10 @@ export class UserService {
|
|||||||
Account: {
|
Account: {
|
||||||
create: {
|
create: {
|
||||||
currency: DEFAULT_CURRENCY,
|
currency: DEFAULT_CURRENCY,
|
||||||
isDefault: true,
|
name: this.i18nService.getTranslation({
|
||||||
name: 'Default Account'
|
id: 'myAccount',
|
||||||
|
languageCode: DEFAULT_LANGUAGE_CODE // TODO
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Settings: {
|
Settings: {
|
||||||
@ -438,7 +452,7 @@ export class UserService {
|
|||||||
settings
|
settings
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
userId: userId
|
userId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -446,14 +460,15 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getRandomString(length: number) {
|
private getRandomString(length: number) {
|
||||||
|
const bytes = crypto.randomBytes(length);
|
||||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
result.push(
|
const randomByte = bytes[i];
|
||||||
characters.charAt(Math.floor(Math.random() * characters.length))
|
result.push(characters[randomByte % characters.length]);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.join('');
|
return result.join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,12 @@
|
|||||||
{
|
{
|
||||||
"CYBER24781": "CyberConnect",
|
"CYBER24781": "CyberConnect",
|
||||||
|
"JUP29210": "Jupiter",
|
||||||
"LUNA1": "Terra",
|
"LUNA1": "Terra",
|
||||||
"LUNA2": "Terra",
|
"LUNA2": "Terra",
|
||||||
"SGB1": "Songbird",
|
"SGB1": "Songbird",
|
||||||
|
"SMURFCAT": "Real Smurf Cat",
|
||||||
|
"TON11419": "Toncoin",
|
||||||
"UNI1": "Uniswap",
|
"UNI1": "Uniswap",
|
||||||
|
"UNI7083": "Uniswap",
|
||||||
"UST": "TerraUSD"
|
"UST": "TerraUSD"
|
||||||
}
|
}
|
||||||
|
@ -370,6 +370,14 @@
|
|||||||
<loc>https://ghostfol.io/en/faq</loc>
|
<loc>https://ghostfol.io/en/faq</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/saas</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/self-hosting</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/features</loc>
|
<loc>https://ghostfol.io/en/features</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -1224,10 +1232,12 @@
|
|||||||
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
|
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<!--
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/pl</loc>
|
<loc>https://ghostfol.io/pl</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
-->
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/pt</loc>
|
<loc>https://ghostfol.io/pt</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -1288,4 +1298,10 @@
|
|||||||
<loc>https://ghostfol.io/tr</loc>
|
<loc>https://ghostfol.io/tr</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<!--
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/zh</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
-->
|
||||||
</urlset>
|
</urlset>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
version: `v${require('../../../../package.json').version}`
|
version: `${require('../../../../package.json').version}`
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { cloneDeep, isArray, isObject } from 'lodash';
|
import { cloneDeep, isArray, isObject } from 'lodash';
|
||||||
|
|
||||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||||
|
80
apps/api/src/helper/portfolio.helper.ts
Normal file
80
apps/api/src/helper/portfolio.helper.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Type as ActivityType } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
endOfDay,
|
||||||
|
max,
|
||||||
|
subDays,
|
||||||
|
startOfMonth,
|
||||||
|
startOfWeek,
|
||||||
|
startOfYear,
|
||||||
|
subYears,
|
||||||
|
endOfYear
|
||||||
|
} from 'date-fns';
|
||||||
|
|
||||||
|
export function getFactor(activityType: ActivityType) {
|
||||||
|
let factor: number;
|
||||||
|
|
||||||
|
switch (activityType) {
|
||||||
|
case 'BUY':
|
||||||
|
case 'ITEM':
|
||||||
|
factor = 1;
|
||||||
|
break;
|
||||||
|
case 'LIABILITY':
|
||||||
|
case 'SELL':
|
||||||
|
factor = -1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
factor = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInterval(
|
||||||
|
aDateRange: DateRange,
|
||||||
|
portfolioStart = new Date(0)
|
||||||
|
) {
|
||||||
|
let endDate = endOfDay(new Date());
|
||||||
|
let startDate = portfolioStart;
|
||||||
|
|
||||||
|
switch (aDateRange) {
|
||||||
|
case '1d':
|
||||||
|
startDate = max([startDate, subDays(resetHours(new Date()), 1)]);
|
||||||
|
break;
|
||||||
|
case 'mtd':
|
||||||
|
startDate = max([
|
||||||
|
startDate,
|
||||||
|
subDays(startOfMonth(resetHours(new Date())), 1)
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
case 'wtd':
|
||||||
|
startDate = max([
|
||||||
|
startDate,
|
||||||
|
subDays(startOfWeek(resetHours(new Date()), { weekStartsOn: 1 }), 1)
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
case 'ytd':
|
||||||
|
startDate = max([
|
||||||
|
startDate,
|
||||||
|
subDays(startOfYear(resetHours(new Date())), 1)
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
case '1y':
|
||||||
|
startDate = max([startDate, subYears(resetHours(new Date()), 1)]);
|
||||||
|
break;
|
||||||
|
case '5y':
|
||||||
|
startDate = max([startDate, subYears(resetHours(new Date()), 5)]);
|
||||||
|
break;
|
||||||
|
case 'max':
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// '2024', '2023', '2022', etc.
|
||||||
|
endDate = endOfYear(new Date(aDateRange));
|
||||||
|
startDate = max([startDate, new Date(aDateRange)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { endDate, startDate };
|
||||||
|
}
|
@ -49,14 +49,14 @@ export class RedactValuesInResponseInterceptor<T>
|
|||||||
'dividendInBaseCurrency',
|
'dividendInBaseCurrency',
|
||||||
'fee',
|
'fee',
|
||||||
'feeInBaseCurrency',
|
'feeInBaseCurrency',
|
||||||
'filteredValueInBaseCurrency',
|
|
||||||
'grossPerformance',
|
'grossPerformance',
|
||||||
|
'grossPerformanceWithCurrencyEffect',
|
||||||
'investment',
|
'investment',
|
||||||
'netPerformance',
|
'netPerformance',
|
||||||
|
'netPerformanceWithCurrencyEffect',
|
||||||
'quantity',
|
'quantity',
|
||||||
'symbolMapping',
|
'symbolMapping',
|
||||||
'totalBalanceInBaseCurrency',
|
'totalBalanceInBaseCurrency',
|
||||||
'totalValueInBaseCurrency',
|
|
||||||
'unitPrice',
|
'unitPrice',
|
||||||
'value',
|
'value',
|
||||||
'valueInBaseCurrency'
|
'valueInBaseCurrency'
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
|
||||||
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
|
import { Account, SymbolProfile, Type as ActivityType } from '@prisma/client';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export class Order {
|
export class Order {
|
||||||
@ -14,7 +14,7 @@ export class Order {
|
|||||||
private symbol: string;
|
private symbol: string;
|
||||||
private symbolProfile: SymbolProfile;
|
private symbolProfile: SymbolProfile;
|
||||||
private total: number;
|
private total: number;
|
||||||
private type: TypeOfOrder;
|
private type: ActivityType;
|
||||||
private unitPrice: number;
|
private unitPrice: number;
|
||||||
|
|
||||||
public constructor(data: IOrder) {
|
public constructor(data: IOrder) {
|
||||||
|
@ -35,7 +35,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxItem;
|
let maxItem: (typeof accounts)[0];
|
||||||
let totalInvestment = 0;
|
let totalInvestment = 0;
|
||||||
|
|
||||||
for (const account of Object.values(accounts)) {
|
for (const account of Object.values(accounts)) {
|
||||||
@ -52,7 +52,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
|
||||||
|
|
||||||
if (maxInvestmentRatio > ruleSettings.threshold) {
|
if (maxInvestmentRatio > ruleSettings.threshold) {
|
||||||
return {
|
return {
|
||||||
|
@ -43,7 +43,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
|||||||
|
|
||||||
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
|
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
|
||||||
|
|
||||||
if (maxItem.groupKey !== ruleSettings.baseCurrency) {
|
if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
|
||||||
return {
|
return {
|
||||||
evaluation: `The major part of your current investment is not in your base currency (${(
|
evaluation: `The major part of your current investment is not in your base currency (${(
|
||||||
baseCurrencyValueRatio * 100
|
baseCurrencyValueRatio * 100
|
||||||
|
@ -37,7 +37,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxValueRatio = maxItem.value / totalValue;
|
const maxValueRatio = maxItem?.value / totalValue || 0;
|
||||||
|
|
||||||
if (maxValueRatio > ruleSettings.threshold) {
|
if (maxValueRatio > ruleSettings.threshold) {
|
||||||
return {
|
return {
|
||||||
@ -52,7 +52,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
evaluation: `The major part of your current investment is in ${
|
evaluation: `The major part of your current investment is in ${
|
||||||
maxItem.groupKey
|
maxItem?.groupKey ?? ruleSettings.baseCurrency
|
||||||
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
|
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||||
ruleSettings.threshold * 100
|
ruleSettings.threshold * 100
|
||||||
}%`,
|
}%`,
|
||||||
|
@ -10,18 +10,21 @@ export class ApiService {
|
|||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
filterByAssetSubClasses,
|
filterByAssetSubClasses,
|
||||||
|
filterByHoldingType,
|
||||||
filterBySearchQuery,
|
filterBySearchQuery,
|
||||||
filterByTags
|
filterByTags
|
||||||
}: {
|
}: {
|
||||||
filterByAccounts?: string;
|
filterByAccounts?: string;
|
||||||
filterByAssetClasses?: string;
|
filterByAssetClasses?: string;
|
||||||
filterByAssetSubClasses?: string;
|
filterByAssetSubClasses?: string;
|
||||||
|
filterByHoldingType?: string;
|
||||||
filterBySearchQuery?: string;
|
filterBySearchQuery?: string;
|
||||||
filterByTags?: string;
|
filterByTags?: string;
|
||||||
}): Filter[] {
|
}): Filter[] {
|
||||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||||
|
const holdingType = filterByHoldingType;
|
||||||
const searchQuery = filterBySearchQuery?.toLowerCase();
|
const searchQuery = filterBySearchQuery?.toLowerCase();
|
||||||
const tagIds = filterByTags?.split(',') ?? [];
|
const tagIds = filterByTags?.split(',') ?? [];
|
||||||
|
|
||||||
@ -52,6 +55,13 @@ export class ApiService {
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (holdingType) {
|
||||||
|
filters.push({
|
||||||
|
id: holdingType,
|
||||||
|
type: 'HOLDING_TYPE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
filters.push({
|
filters.push({
|
||||||
id: searchQuery,
|
id: searchQuery,
|
||||||
|
@ -3,7 +3,7 @@ import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
|
|||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
|
import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ConfigurationService {
|
export class ConfigurationService {
|
||||||
@ -27,7 +27,6 @@ export class ConfigurationService {
|
|||||||
DATA_SOURCES: json({
|
DATA_SOURCES: json({
|
||||||
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
|
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
|
||||||
}),
|
}),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||||
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
|
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
|
||||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||||
@ -44,18 +43,18 @@ export class ConfigurationService {
|
|||||||
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
PORT: port({ default: 3333 }),
|
PORT: port({ default: 3333 }),
|
||||||
|
REDIS_DB: num({ default: 0 }),
|
||||||
REDIS_HOST: str({ default: 'localhost' }),
|
REDIS_HOST: str({ default: 'localhost' }),
|
||||||
REDIS_PASSWORD: str({ default: '' }),
|
REDIS_PASSWORD: str({ default: '' }),
|
||||||
REDIS_PORT: port({ default: 6379 }),
|
REDIS_PORT: port({ default: 6379 }),
|
||||||
REQUEST_TIMEOUT: num({ default: 2000 }),
|
REQUEST_TIMEOUT: num({ default: 2000 }),
|
||||||
ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
|
ROOT_URL: url({ default: DEFAULT_ROOT_URL }),
|
||||||
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||||
STRIPE_SECRET_KEY: str({ default: '' }),
|
STRIPE_SECRET_KEY: str({ default: '' }),
|
||||||
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
|
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
|
||||||
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
|
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
|
||||||
TWITTER_API_KEY: str({ default: 'dummyApiKey' }),
|
TWITTER_API_KEY: str({ default: 'dummyApiKey' }),
|
||||||
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }),
|
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' })
|
||||||
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,12 +37,14 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
return !!this.configurationService.get('API_KEY_ALPHA_VANTAGE');
|
return !!this.configurationService.get('API_KEY_ALPHA_VANTAGE');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile({
|
||||||
aSymbol: string
|
symbol
|
||||||
): Promise<Partial<SymbolProfile>> {
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
return {
|
return {
|
||||||
dataSource: this.getName(),
|
symbol,
|
||||||
symbol: aSymbol
|
dataSource: this.getName()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,15 +52,17 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile({
|
||||||
aSymbol: string
|
symbol
|
||||||
): Promise<Partial<SymbolProfile>> {
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
const response: Partial<SymbolProfile> = {
|
const response: Partial<SymbolProfile> = {
|
||||||
|
symbol,
|
||||||
assetClass: AssetClass.CASH,
|
assetClass: AssetClass.CASH,
|
||||||
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||||
currency: DEFAULT_CURRENCY,
|
currency: DEFAULT_CURRENCY,
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName()
|
||||||
symbol: aSymbol
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -70,7 +72,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
const { name } = await got(`${this.apiUrl}/coins/${aSymbol}`, {
|
const { name } = await got(`${this.apiUrl}/coins/${symbol}`, {
|
||||||
headers: this.headers,
|
headers: this.headers,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
@ -81,7 +83,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
let message = error;
|
let message = error;
|
||||||
|
|
||||||
if (error?.code === 'ABORT_ERR') {
|
if (error?.code === 'ABORT_ERR') {
|
||||||
message = `RequestError: The operation to get the asset profile for ${aSymbol} was aborted because the request to the data provider took more than ${this.configurationService.get(
|
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||||
'REQUEST_TIMEOUT'
|
'REQUEST_TIMEOUT'
|
||||||
)}ms`;
|
)}ms`;
|
||||||
}
|
}
|
||||||
|
@ -5,12 +5,12 @@ import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
|||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
|
import { countries } from 'countries-list';
|
||||||
import got from 'got';
|
import got from 'got';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||||
private static baseUrl = 'https://www.trackinsight.com/data-api';
|
private static baseUrl = 'https://www.trackinsight.com/data-api';
|
||||||
private static countries = require('countries-list/dist/countries.json');
|
|
||||||
private static countriesMapping = {
|
private static countriesMapping = {
|
||||||
'Russian Federation': 'Russia'
|
'Russian Federation': 'Russia'
|
||||||
};
|
};
|
||||||
@ -131,20 +131,19 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
(response.countries as unknown as Country[]).length === 0
|
(response.countries as unknown as Country[]).length === 0
|
||||||
) {
|
) {
|
||||||
response.countries = [];
|
response.countries = [];
|
||||||
|
|
||||||
for (const [name, value] of Object.entries<any>(
|
for (const [name, value] of Object.entries<any>(
|
||||||
holdings?.countries ?? {}
|
holdings?.countries ?? {}
|
||||||
)) {
|
)) {
|
||||||
let countryCode: string;
|
let countryCode: string;
|
||||||
|
|
||||||
for (const [key, country] of Object.entries<any>(
|
for (const [code, country] of Object.entries(countries)) {
|
||||||
TrackinsightDataEnhancerService.countries
|
|
||||||
)) {
|
|
||||||
if (
|
if (
|
||||||
country.name === name ||
|
country.name === name ||
|
||||||
country.name ===
|
country.name ===
|
||||||
TrackinsightDataEnhancerService.countriesMapping[name]
|
TrackinsightDataEnhancerService.countriesMapping[name]
|
||||||
) {
|
) {
|
||||||
countryCode = key;
|
countryCode = code;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -196,7 +196,9 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
|||||||
shortName: assetProfile.price.shortName,
|
shortName: assetProfile.price.shortName,
|
||||||
symbol: assetProfile.price.symbol
|
symbol: assetProfile.price.symbol
|
||||||
});
|
});
|
||||||
response.symbol = assetProfile.price.symbol;
|
response.symbol = this.convertFromYahooFinanceSymbol(
|
||||||
|
assetProfile.price.symbol
|
||||||
|
);
|
||||||
|
|
||||||
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
||||||
response.sectors = [];
|
response.sectors = [];
|
||||||
|
@ -20,7 +20,7 @@ import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
|||||||
|
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { eachDayOfInterval, format, isValid } from 'date-fns';
|
import { eachDayOfInterval, format, isValid } from 'date-fns';
|
||||||
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
|
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
@ -92,7 +92,9 @@ export class DataProviderService {
|
|||||||
|
|
||||||
for (const symbol of symbols) {
|
for (const symbol of symbols) {
|
||||||
const promise = Promise.resolve(
|
const promise = Promise.resolve(
|
||||||
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol)
|
this.getDataProvider(DataSource[dataSource]).getAssetProfile({
|
||||||
|
symbol
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
@ -202,13 +204,14 @@ export class DataProviderService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const queryRaw = `SELECT *
|
const queryRaw = `
|
||||||
FROM "MarketData"
|
SELECT *
|
||||||
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
|
FROM "MarketData"
|
||||||
AND "symbol" IN ('${symbols.join(
|
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
|
||||||
`','`
|
AND "symbol" IN ('${symbols.join(
|
||||||
)}') ${granularityQuery} ${rangeQuery}
|
`','`
|
||||||
ORDER BY date;`;
|
)}') ${granularityQuery} ${rangeQuery}
|
||||||
|
ORDER BY date;`;
|
||||||
|
|
||||||
const marketDataByGranularity: MarketData[] =
|
const marketDataByGranularity: MarketData[] =
|
||||||
await this.prismaService.$queryRawUnsafe(queryRaw);
|
await this.prismaService.$queryRawUnsafe(queryRaw);
|
||||||
@ -335,11 +338,13 @@ export class DataProviderService {
|
|||||||
public async getQuotes({
|
public async getQuotes({
|
||||||
items,
|
items,
|
||||||
requestTimeout,
|
requestTimeout,
|
||||||
useCache = true
|
useCache = true,
|
||||||
|
user
|
||||||
}: {
|
}: {
|
||||||
items: UniqueAsset[];
|
items: UniqueAsset[];
|
||||||
requestTimeout?: number;
|
requestTimeout?: number;
|
||||||
useCache?: boolean;
|
useCache?: boolean;
|
||||||
|
user?: UserWithSettings;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
[symbol: string]: IDataProviderResponse;
|
[symbol: string]: IDataProviderResponse;
|
||||||
}> {
|
}> {
|
||||||
@ -405,6 +410,14 @@ export class DataProviderService {
|
|||||||
)) {
|
)) {
|
||||||
const dataProvider = this.getDataProvider(DataSource[dataSource]);
|
const dataProvider = this.getDataProvider(DataSource[dataSource]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
dataProvider.getDataProviderInfo().isPremium &&
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
user?.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||||
return dataGatheringItem.symbol;
|
return dataGatheringItem.symbol;
|
||||||
});
|
});
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
REPLACE_NAME_PARTS
|
REPLACE_NAME_PARTS
|
||||||
@ -35,7 +36,8 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
private readonly URL = 'https://eodhistoricaldata.com/api';
|
private readonly URL = 'https://eodhistoricaldata.com/api';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {
|
) {
|
||||||
this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA');
|
this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA');
|
||||||
}
|
}
|
||||||
@ -44,19 +46,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile({
|
||||||
aSymbol: string
|
symbol
|
||||||
): Promise<Partial<SymbolProfile>> {
|
}: {
|
||||||
const [searchResult] = await this.getSearchResult(aSymbol);
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
|
const [searchResult] = await this.getSearchResult(symbol);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
symbol,
|
||||||
assetClass: searchResult?.assetClass,
|
assetClass: searchResult?.assetClass,
|
||||||
assetSubClass: searchResult?.assetSubClass,
|
assetSubClass: searchResult?.assetSubClass,
|
||||||
currency: this.convertCurrency(searchResult?.currency),
|
currency: this.convertCurrency(searchResult?.currency),
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
isin: searchResult?.isin,
|
isin: searchResult?.isin,
|
||||||
name: searchResult?.name,
|
name: searchResult?.name
|
||||||
symbol: aSymbol
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,51 +232,53 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
? [realTimeResponse]
|
? [realTimeResponse]
|
||||||
: realTimeResponse;
|
: realTimeResponse;
|
||||||
|
|
||||||
const searchResponse = await Promise.all(
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
eodHistoricalDataSymbols
|
symbols.map((symbol) => {
|
||||||
.filter((symbol) => {
|
return {
|
||||||
return !symbol.endsWith('.FOREX');
|
symbol,
|
||||||
})
|
dataSource: this.getName()
|
||||||
.map((symbol) => {
|
};
|
||||||
return this.search({ query: symbol });
|
})
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const lookupItems = searchResponse.flat().map(({ items }) => {
|
for (const { close, code, timestamp } of quotes) {
|
||||||
return items[0];
|
let currency: string;
|
||||||
});
|
|
||||||
|
|
||||||
response = quotes.reduce(
|
if (code.endsWith('.FOREX')) {
|
||||||
(
|
currency = this.convertFromEodSymbol(code)?.replace(
|
||||||
result: { [symbol: string]: IDataProviderResponse },
|
DEFAULT_CURRENCY,
|
||||||
{ close, code, timestamp }
|
''
|
||||||
) => {
|
);
|
||||||
const currency = lookupItems.find((lookupItem) => {
|
}
|
||||||
return lookupItem.symbol === code;
|
|
||||||
|
if (!currency) {
|
||||||
|
currency = symbolProfiles.find(({ symbol }) => {
|
||||||
|
return symbol === code;
|
||||||
})?.currency;
|
})?.currency;
|
||||||
|
}
|
||||||
|
|
||||||
if (isNumber(close)) {
|
if (!currency) {
|
||||||
result[this.convertFromEodSymbol(code)] = {
|
const { items } = await this.search({ query: code });
|
||||||
currency:
|
|
||||||
currency ??
|
if (items.length === 1) {
|
||||||
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
|
currency = items[0].currency;
|
||||||
dataSource: this.getName(),
|
|
||||||
marketPrice: close,
|
|
||||||
marketState: isToday(new Date(timestamp * 1000))
|
|
||||||
? 'open'
|
|
||||||
: 'closed'
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
Logger.error(
|
|
||||||
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`,
|
|
||||||
'EodHistoricalDataService'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
if (isNumber(close)) {
|
||||||
},
|
response[this.convertFromEodSymbol(code)] = {
|
||||||
{}
|
currency,
|
||||||
);
|
dataSource: this.getName(),
|
||||||
|
marketPrice: close,
|
||||||
|
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
Logger.error(
|
||||||
|
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`,
|
||||||
|
'EodHistoricalDataService'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -37,12 +37,14 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile({
|
||||||
aSymbol: string
|
symbol
|
||||||
): Promise<Partial<SymbolProfile>> {
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
return {
|
return {
|
||||||
dataSource: this.getName(),
|
symbol,
|
||||||
symbol: aSymbol
|
dataSource: this.getName()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,12 +33,14 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile({
|
||||||
aSymbol: string
|
symbol
|
||||||
): Promise<Partial<SymbolProfile>> {
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
return {
|
return {
|
||||||
dataSource: this.getName(),
|
symbol,
|
||||||
symbol: aSymbol
|
dataSource: this.getName()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,7 +11,11 @@ import { DataSource, SymbolProfile } from '@prisma/client';
|
|||||||
export interface DataProviderInterface {
|
export interface DataProviderInterface {
|
||||||
canHandle(symbol: string): boolean;
|
canHandle(symbol: string): boolean;
|
||||||
|
|
||||||
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
getAssetProfile({
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>>;
|
||||||
|
|
||||||
getDataProviderInfo(): DataProviderInfo;
|
getDataProviderInfo(): DataProviderInfo;
|
||||||
|
|
||||||
|
@ -43,16 +43,18 @@ export class ManualService implements DataProviderInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile({
|
||||||
aSymbol: string
|
symbol
|
||||||
): Promise<Partial<SymbolProfile>> {
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
const assetProfile: Partial<SymbolProfile> = {
|
const assetProfile: Partial<SymbolProfile> = {
|
||||||
dataSource: this.getName(),
|
symbol,
|
||||||
symbol: aSymbol
|
dataSource: this.getName()
|
||||||
};
|
};
|
||||||
|
|
||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
{ dataSource: this.getName(), symbol: aSymbol }
|
{ symbol, dataSource: this.getName() }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (symbolProfile) {
|
if (symbolProfile) {
|
||||||
@ -164,13 +166,15 @@ export class ManualService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const symbolProfile of symbolProfiles) {
|
for (const { currency, symbol } of symbolProfiles) {
|
||||||
response[symbolProfile.symbol] = {
|
let marketPrice = marketData.find((marketDataItem) => {
|
||||||
currency: symbolProfile.currency,
|
return marketDataItem.symbol === symbol;
|
||||||
|
})?.marketPrice;
|
||||||
|
|
||||||
|
response[symbol] = {
|
||||||
|
currency,
|
||||||
|
marketPrice,
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
marketPrice: marketData.find((marketDataItem) => {
|
|
||||||
return marketDataItem.symbol === symbolProfile.symbol;
|
|
||||||
})?.marketPrice,
|
|
||||||
marketState: 'delayed'
|
marketState: 'delayed'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -30,12 +30,14 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
return !!this.configurationService.get('API_KEY_RAPID_API');
|
return !!this.configurationService.get('API_KEY_RAPID_API');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile({
|
||||||
aSymbol: string
|
symbol
|
||||||
): Promise<Partial<SymbolProfile>> {
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
return {
|
return {
|
||||||
dataSource: this.getName(),
|
symbol,
|
||||||
symbol: aSymbol
|
dataSource: this.getName()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,20 +33,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile({
|
||||||
aSymbol: string
|
symbol
|
||||||
): Promise<Partial<SymbolProfile>> {
|
}: {
|
||||||
const { assetClass, assetSubClass, currency, name, symbol } =
|
symbol: string;
|
||||||
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
|
}): Promise<Partial<SymbolProfile>> {
|
||||||
|
return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol);
|
||||||
return {
|
|
||||||
assetClass,
|
|
||||||
assetSubClass,
|
|
||||||
currency,
|
|
||||||
name,
|
|
||||||
symbol,
|
|
||||||
dataSource: this.getName()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDataProviderInfo(): DataProviderInfo {
|
public getDataProviderInfo(): DataProviderInfo {
|
||||||
|
@ -22,6 +22,14 @@ export const ExchangeRateDataServiceMock = {
|
|||||||
'2023-07-10': 0.8854
|
'2023-07-10': 0.8854
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if (targetCurrency === 'USD') {
|
||||||
|
return Promise.resolve({
|
||||||
|
USDUSD: {
|
||||||
|
'2018-01-01': 1,
|
||||||
|
'2021-11-16': 1,
|
||||||
|
'2023-07-10': 1
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
|
@ -73,7 +73,17 @@ export class ExchangeRateDataService {
|
|||||||
currencyTo: targetCurrency
|
currencyTo: targetCurrency
|
||||||
});
|
});
|
||||||
|
|
||||||
let previousExchangeRate = 1;
|
const dateStrings = Object.keys(
|
||||||
|
exchangeRatesByCurrency[`${currency}${targetCurrency}`]
|
||||||
|
);
|
||||||
|
const lastDateString = dateStrings.reduce((a, b) => {
|
||||||
|
return a > b ? a : b;
|
||||||
|
});
|
||||||
|
|
||||||
|
let previousExchangeRate =
|
||||||
|
exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[
|
||||||
|
lastDateString
|
||||||
|
] ?? 1;
|
||||||
|
|
||||||
// Start from the most recent date and fill in missing exchange rates
|
// Start from the most recent date and fill in missing exchange rates
|
||||||
// using the latest available rate
|
// using the latest available rate
|
||||||
@ -94,7 +104,7 @@ export class ExchangeRateDataService {
|
|||||||
exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] =
|
exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] =
|
||||||
previousExchangeRate;
|
previousExchangeRate;
|
||||||
|
|
||||||
if (currency === DEFAULT_CURRENCY) {
|
if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`,
|
`No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`,
|
||||||
'ExchangeRateDataService'
|
'ExchangeRateDataService'
|
||||||
@ -433,13 +443,17 @@ export class ExchangeRateDataService {
|
|||||||
]) *
|
]) *
|
||||||
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)];
|
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)];
|
||||||
|
|
||||||
factors[format(date, DATE_FORMAT)] = factor;
|
if (isNaN(factor)) {
|
||||||
|
throw new Error('Exchange rate is not a number');
|
||||||
|
} else {
|
||||||
|
factors[format(date, DATE_FORMAT)] = factor;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
|
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
|
||||||
date,
|
date,
|
||||||
DATE_FORMAT
|
DATE_FORMAT
|
||||||
)}`,
|
)}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom} and ${DEFAULT_CURRENCY}${currencyTo}.`,
|
||||||
'ExchangeRateDataService'
|
'ExchangeRateDataService'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -451,7 +465,7 @@ export class ExchangeRateDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async prepareCurrencies(): Promise<string[]> {
|
private async prepareCurrencies(): Promise<string[]> {
|
||||||
let currencies: string[] = [];
|
let currencies: string[] = [DEFAULT_CURRENCY];
|
||||||
|
|
||||||
(
|
(
|
||||||
await this.prismaService.account.findMany({
|
await this.prismaService.account.findMany({
|
||||||
|
@ -15,7 +15,6 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
DATA_SOURCE_EXCHANGE_RATES: string;
|
DATA_SOURCE_EXCHANGE_RATES: string;
|
||||||
DATA_SOURCE_IMPORT: string;
|
DATA_SOURCE_IMPORT: string;
|
||||||
DATA_SOURCES: string[];
|
DATA_SOURCES: string[];
|
||||||
ENABLE_FEATURE_BLOG: boolean;
|
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||||
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
|
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
|
||||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||||
@ -31,6 +30,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
MAX_ACTIVITIES_TO_IMPORT: number;
|
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||||
MAX_ITEM_IN_CACHE: number;
|
MAX_ITEM_IN_CACHE: number;
|
||||||
PORT: number;
|
PORT: number;
|
||||||
|
REDIS_DB: number;
|
||||||
REDIS_HOST: string;
|
REDIS_HOST: string;
|
||||||
REDIS_PASSWORD: string;
|
REDIS_PASSWORD: string;
|
||||||
REDIS_PORT: number;
|
REDIS_PORT: number;
|
||||||
@ -42,5 +42,4 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
TWITTER_ACCESS_TOKEN_SECRET: string;
|
TWITTER_ACCESS_TOKEN_SECRET: string;
|
||||||
TWITTER_API_KEY: string;
|
TWITTER_API_KEY: string;
|
||||||
TWITTER_API_SECRET: string;
|
TWITTER_API_SECRET: string;
|
||||||
WEB_AUTH_RP_ID: string;
|
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import {
|
|||||||
Account,
|
Account,
|
||||||
DataSource,
|
DataSource,
|
||||||
SymbolProfile,
|
SymbolProfile,
|
||||||
Type as TypeOfOrder
|
Type as ActivityType
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
export interface IOrder {
|
export interface IOrder {
|
||||||
@ -18,7 +18,7 @@ export interface IOrder {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
symbolProfile: SymbolProfile;
|
symbolProfile: SymbolProfile;
|
||||||
type: TypeOfOrder;
|
type: ActivityType;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -97,7 +97,8 @@ export class SymbolProfileService {
|
|||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
sectors,
|
sectors,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping,
|
||||||
|
SymbolProfileOverrides
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
return this.prismaService.symbolProfile.update({
|
return this.prismaService.symbolProfile.update({
|
||||||
data: {
|
data: {
|
||||||
@ -109,7 +110,8 @@ export class SymbolProfileService {
|
|||||||
name,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
sectors,
|
sectors,
|
||||||
symbolMapping
|
symbolMapping,
|
||||||
|
SymbolProfileOverrides
|
||||||
},
|
},
|
||||||
where: { dataSource_symbol: { dataSource, symbol } }
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
});
|
});
|
||||||
@ -189,9 +191,8 @@ export class SymbolProfileService {
|
|||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
weight,
|
weight,
|
||||||
continent:
|
continent: continents[countries[code]?.continent] ?? UNKNOWN_KEY,
|
||||||
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
name: countries[code]?.name ?? UNKNOWN_KEY
|
||||||
name: countries[code as string]?.name ?? UNKNOWN_KEY
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -13,13 +13,13 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"executor": "@nx/angular:webpack-browser",
|
"executor": "@nx/angular:webpack-browser",
|
||||||
"options": {
|
"options": {
|
||||||
|
"deleteOutputPath": false,
|
||||||
"localize": true,
|
"localize": true,
|
||||||
"outputPath": "dist/apps/client",
|
"outputPath": "dist/apps/client",
|
||||||
"index": "apps/client/src/index.html",
|
"index": "apps/client/src/index.html",
|
||||||
"main": "apps/client/src/main.ts",
|
"main": "apps/client/src/main.ts",
|
||||||
"polyfills": "apps/client/src/polyfills.ts",
|
"polyfills": "apps/client/src/polyfills.ts",
|
||||||
"tsConfig": "apps/client/tsconfig.app.json",
|
"tsConfig": "apps/client/tsconfig.app.json",
|
||||||
"assets": [],
|
|
||||||
"styles": [
|
"styles": [
|
||||||
"apps/client/src/assets/fonts/inter.css",
|
"apps/client/src/assets/fonts/inter.css",
|
||||||
"apps/client/src/styles/theme.scss",
|
"apps/client/src/styles/theme.scss",
|
||||||
@ -72,6 +72,10 @@
|
|||||||
"baseHref": "/tr/",
|
"baseHref": "/tr/",
|
||||||
"localize": ["tr"]
|
"localize": ["tr"]
|
||||||
},
|
},
|
||||||
|
"development-zh": {
|
||||||
|
"baseHref": "/zh/",
|
||||||
|
"localize": ["zh"]
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
@ -108,13 +112,22 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "shx mkdir -p dist/apps/client"
|
"command": "shx rm -rf dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "shx cp -r apps/client/src/assets dist/apps/client"
|
"command": "shx mkdir -p dist/apps/client/.well-known"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client"
|
"command": "shx mkdir -p dist/apps/client/assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "shx mkdir -p dist/apps/client/ionicons"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "shx cp -r apps/client/src/assets/* dist/apps/client/assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "shx cp -r apps/client/src/assets/.well-known/* dist/apps/client/.well-known"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
|
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
|
||||||
@ -128,9 +141,6 @@
|
|||||||
{
|
{
|
||||||
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
|
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
|
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
|
||||||
},
|
},
|
||||||
@ -138,7 +148,7 @@
|
|||||||
"command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
|
"command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
|
"command": "shx cp -r node_modules/ionicons/dist/ionicons/* dist/apps/client/ionicons"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "shx cp CHANGELOG.md dist/apps/client/assets"
|
"command": "shx cp CHANGELOG.md dist/apps/client/assets"
|
||||||
@ -146,7 +156,8 @@
|
|||||||
{
|
{
|
||||||
"command": "shx cp LICENSE dist/apps/client/assets"
|
"command": "shx cp LICENSE dist/apps/client/assets"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"parallel": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
@ -183,6 +194,9 @@
|
|||||||
"development-tr": {
|
"development-tr": {
|
||||||
"buildTarget": "client:build:development-tr"
|
"buildTarget": "client:build:development-tr"
|
||||||
},
|
},
|
||||||
|
"development-zh": {
|
||||||
|
"buildTarget": "client:build:development-zh"
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "client:build:production"
|
"buildTarget": "client:build:production"
|
||||||
}
|
}
|
||||||
@ -202,7 +216,8 @@
|
|||||||
"messages.nl.xlf",
|
"messages.nl.xlf",
|
||||||
"messages.pl.xlf",
|
"messages.pl.xlf",
|
||||||
"messages.pt.xlf",
|
"messages.pt.xlf",
|
||||||
"messages.tr.xlf"
|
"messages.tr.xlf",
|
||||||
|
"messages.zh.xlf"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -253,6 +268,10 @@
|
|||||||
"tr": {
|
"tr": {
|
||||||
"baseHref": "/tr/",
|
"baseHref": "/tr/",
|
||||||
"translation": "apps/client/src/locales/messages.tr.xlf"
|
"translation": "apps/client/src/locales/messages.tr.xlf"
|
||||||
|
},
|
||||||
|
"zh": {
|
||||||
|
"baseHref": "/zh/",
|
||||||
|
"translation": "apps/client/src/locales/messages.zh.xlf"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sourceLocale": "en"
|
"sourceLocale": "en"
|
||||||
|
@ -64,7 +64,7 @@
|
|||||||
<div class="h6 mt-2">Ghostfolio</div>
|
<div class="h6 mt-2">Ghostfolio</div>
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
<li><a i18n [routerLink]="routerLinkAbout">About</a></li>
|
<li><a i18n [routerLink]="routerLinkAbout">About</a></li>
|
||||||
<li *ngIf="hasPermissionForBlog">
|
<li *ngIf="hasPermissionForSubscription">
|
||||||
<a i18n [routerLink]="['/blog']">Blog</a>
|
<a i18n [routerLink]="['/blog']">Blog</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@ -163,6 +163,11 @@
|
|||||||
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
|
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
|
||||||
</li>
|
</li>
|
||||||
-->
|
-->
|
||||||
|
<!--
|
||||||
|
<li>
|
||||||
|
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
|
||||||
|
</li>
|
||||||
|
-->
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
@import 'apps/client/src/styles/ghostfolio-style';
|
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { getCssVariable } from '@ghostfolio/common/helper';
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { ColorScheme } from '@ghostfolio/common/types';
|
import { ColorScheme } from '@ghostfolio/common/types';
|
||||||
@ -38,7 +39,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
public currentYear = new Date().getFullYear();
|
public currentYear = new Date().getFullYear();
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasInfoMessage: boolean;
|
public hasInfoMessage: boolean;
|
||||||
public hasPermissionForBlog: boolean;
|
|
||||||
public hasPermissionForStatistics: boolean;
|
public hasPermissionForStatistics: boolean;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
@ -81,11 +81,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
this.info = this.dataService.fetchInfo();
|
this.info = this.dataService.fetchInfo();
|
||||||
|
|
||||||
this.hasPermissionForBlog = hasPermission(
|
|
||||||
this.info?.globalPermissions,
|
|
||||||
permissions.enableBlog
|
|
||||||
);
|
|
||||||
|
|
||||||
this.hasPermissionForSubscription = hasPermission(
|
this.hasPermissionForSubscription = hasPermission(
|
||||||
this.info?.globalPermissions,
|
this.info?.globalPermissions,
|
||||||
permissions.enableSubscription
|
permissions.enableSubscription
|
||||||
@ -111,6 +106,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
this.hasTabs =
|
this.hasTabs =
|
||||||
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
|
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
|
||||||
|
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
|
||||||
this.currentRoute === 'account' ||
|
this.currentRoute === 'account' ||
|
||||||
this.currentRoute === 'admin' ||
|
this.currentRoute === 'admin' ||
|
||||||
this.currentRoute === 'home' ||
|
this.currentRoute === 'home' ||
|
||||||
@ -120,7 +116,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
this.showFooter =
|
this.showFooter =
|
||||||
(this.currentRoute === 'blog' ||
|
(this.currentRoute === 'blog' ||
|
||||||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
|
|
||||||
this.currentRoute === this.routerLinkFeatures[0].slice(1) ||
|
this.currentRoute === this.routerLinkFeatures[0].slice(1) ||
|
||||||
this.currentRoute === this.routerLinkMarkets[0].slice(1) ||
|
this.currentRoute === this.routerLinkMarkets[0].slice(1) ||
|
||||||
this.currentRoute === 'open' ||
|
this.currentRoute === 'open' ||
|
||||||
@ -193,20 +188,28 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
? userPreferredColorScheme === 'DARK'
|
? userPreferredColorScheme === 'DARK'
|
||||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
this.toggleThemeStyleClass(isDarkTheme);
|
this.toggleTheme(isDarkTheme);
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
|
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
|
||||||
if (!this.user?.settings.colorScheme) {
|
if (!this.user?.settings.colorScheme) {
|
||||||
this.toggleThemeStyleClass(event.matches);
|
this.toggleTheme(event.matches);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleThemeStyleClass(isDarkTheme: boolean) {
|
private toggleTheme(isDarkTheme: boolean) {
|
||||||
|
const themeColor = getCssVariable(
|
||||||
|
isDarkTheme ? '--dark-background' : '--light-background'
|
||||||
|
);
|
||||||
|
|
||||||
if (isDarkTheme) {
|
if (isDarkTheme) {
|
||||||
this.document.body.classList.add('is-dark-theme');
|
this.document.body.classList.add('is-dark-theme');
|
||||||
} else {
|
} else {
|
||||||
this.document.body.classList.remove('is-dark-theme');
|
this.document.body.classList.remove('is-dark-theme');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.document
|
||||||
|
.querySelector('meta[name="theme-color"]')
|
||||||
|
.setAttribute('content', themeColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
@import 'apps/client/src/styles/ghostfolio-style';
|
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
@ -21,7 +22,7 @@ import {
|
|||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { Sort, SortDirection } from '@angular/material/sort';
|
import { Sort, SortDirection } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
@ -41,7 +42,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
public activities: OrderWithAccount[];
|
public activities: OrderWithAccount[];
|
||||||
public balance: number;
|
public balance: number;
|
||||||
public currency: string;
|
public currency: string;
|
||||||
public dataSource: MatTableDataSource<OrderWithAccount>;
|
public dataSource: MatTableDataSource<Activity>;
|
||||||
public equity: number;
|
public equity: number;
|
||||||
public hasPermissionToDeleteAccountBalance: boolean;
|
public hasPermissionToDeleteAccountBalance: boolean;
|
||||||
public historicalDataItems: HistoricalDataItem[];
|
public historicalDataItems: HistoricalDataItem[];
|
||||||
@ -115,7 +116,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchPortfolioDetails({
|
.fetchPortfolioHoldings({
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
type: 'ACCOUNT',
|
type: 'ACCOUNT',
|
||||||
@ -125,11 +126,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ holdings }) => {
|
.subscribe(({ holdings }) => {
|
||||||
this.holdings = [];
|
this.holdings = holdings;
|
||||||
|
|
||||||
for (const [symbol, holding] of Object.entries(holdings)) {
|
|
||||||
this.holdings.push(holding);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
@ -227,7 +224,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
range: 'max',
|
range: 'max',
|
||||||
withExcludedAccounts: true
|
withExcludedAccounts: true,
|
||||||
|
withItems: true
|
||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ chart }) => {
|
.subscribe(({ chart }) => {
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user