Compare commits
180 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 | |||
d00489b547 | |||
2985dd67c5 | |||
5eba764c04 | |||
cc0ce18627 | |||
b758654158 | |||
d5d40c0ea1 | |||
fd294d4d2b | |||
e82cf2e7d0 | |||
446c7cb517 | |||
e921ed7f52 | |||
865402be3a | |||
6eb659d7e6 | |||
37430b7bdc | |||
ef9d77312e | |||
ccaf06360a | |||
f83e75df44 | |||
00a2b60eb5 | |||
fcbf2f1645 | |||
460266a501 | |||
9fe90273c7 | |||
4078229fe6 | |||
609c03f174 | |||
e7d4641d13 |
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
|
||||
REDIS_HOST=localhost
|
||||
@ -10,6 +10,7 @@ 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>
|
||||
|
@ -1,3 +1,4 @@
|
||||
/.nx/cache
|
||||
/apps/client/src/polyfills.ts
|
||||
/dist
|
||||
/test/import
|
||||
|
21
.prettierrc
21
.prettierrc
@ -9,7 +9,26 @@
|
||||
],
|
||||
"attributeSort": "ASC",
|
||||
"endOfLine": "auto",
|
||||
"plugins": ["prettier-plugin-organize-attributes"],
|
||||
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.ts",
|
||||
"options": {
|
||||
"importOrderParserPlugins": ["decorators-legacy", "typescript"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
"prettier-plugin-organize-attributes",
|
||||
"@trivago/prettier-plugin-sort-imports"
|
||||
],
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
|
341
CHANGELOG.md
341
CHANGELOG.md
@ -5,6 +5,347 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 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
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the ordered list of the _Top 3_ and _Bottom 3_ performers on the analysis page in Safari
|
||||
- Replaced `import-sort` with `prettier-plugin-sort-imports`
|
||||
- Upgraded `eslint` dependencies
|
||||
- Upgraded `Nx` from version `17.2.8` to `18.0.4`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the date conversion of the import of historical market data in the admin control panel
|
||||
|
||||
## 2.50.0 - 2024-02-11
|
||||
|
||||
### Added
|
||||
|
||||
- Introduced a setting to disable the data gathering in the admin control
|
||||
|
||||
### Changed
|
||||
|
||||
- Harmonized the environment variables of various API keys
|
||||
- Upgraded `prisma` from version `5.8.1` to `5.9.1`
|
||||
|
||||
### Todo
|
||||
|
||||
- Rename the environment variable from `ALPHA_VANTAGE_API_KEY` to `API_KEY_ALPHA_VANTAGE`
|
||||
- Rename the environment variable from `BETTER_UPTIME_API_KEY` to `API_KEY_BETTER_UPTIME`
|
||||
- Rename the environment variable from `EOD_HISTORICAL_DATA_API_KEY` to `API_KEY_EOD_HISTORICAL_DATA`
|
||||
- Rename the environment variable from `FINANCIAL_MODELING_PREP_API_KEY` to `API_KEY_FINANCIAL_MODELING_PREP`
|
||||
- Rename the environment variable from `OPEN_FIGI_API_KEY` to `API_KEY_OPEN_FIGI`
|
||||
- Rename the environment variable from `RAPID_API_API_KEY` to `API_KEY_RAPID_API`
|
||||
|
||||
## 2.49.0 - 2024-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added a button to apply the active filters in the assistant
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the assistant from experimental to general availability
|
||||
- Improved the usability by reloading the content with a logo click on the home page
|
||||
- Upgraded `yahoo-finance2` from version `2.9.0` to `2.9.1`
|
||||
|
||||
## 2.48.1 - 2024-02-06
|
||||
|
||||
### Fixed
|
||||
|
@ -57,6 +57,7 @@ RUN apt update && apt install -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE ${PORT:-3333}
|
||||
CMD [ "yarn", "start:production" ]
|
||||
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||
|
11
README.md
11
README.md
@ -7,7 +7,7 @@
|
||||
**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) |
|
||||
[**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)
|
||||
[](#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_PASSWORD` | | The password 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_PASSWORD` | | The password of _Redis_ |
|
||||
| `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)
|
||||
|
||||
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
|
||||
|
||||
@ -153,7 +154,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- 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
|
||||
|
||||
@ -280,6 +281,10 @@ Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Sl
|
||||
|
||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||
|
||||
## Analytics
|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)
|
||||
|
@ -13,7 +13,6 @@ export default {
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
testTimeout: 10000,
|
||||
testEnvironment: 'node',
|
||||
preset: '../../jest.preset.js'
|
||||
};
|
||||
|
@ -9,12 +9,13 @@
|
||||
"build": {
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"configurations": {
|
||||
@ -33,6 +34,26 @@
|
||||
},
|
||||
"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": {
|
||||
"executor": "@nx/js:node",
|
||||
"options": {
|
||||
|
@ -4,6 +4,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -82,7 +83,7 @@ export class AccessController {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.accessService.createAccess({
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessController } from './access.controller';
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Access, Prisma } from '@prisma/client';
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccountBalanceController } from './account-balance.controller';
|
||||
|
@ -2,6 +2,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AccountBalance, Prisma } from '@prisma/client';
|
||||
|
||||
|
@ -14,6 +14,7 @@ import type {
|
||||
AccountWithValue,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -62,7 +63,7 @@ export class AccountController {
|
||||
{ Order: true }
|
||||
);
|
||||
|
||||
if (account?.isDefault || account?.Order.length > 0) {
|
||||
if (!account || account?.Order.length > 0) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
|
@ -7,6 +7,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccountController } from './account.controller';
|
||||
|
@ -2,9 +2,10 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { CashDetails } from './interfaces/cash-details.interface';
|
||||
@ -20,10 +21,8 @@ export class AccountService {
|
||||
public async account({
|
||||
id_userId
|
||||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||
const { id, userId } = id_userId;
|
||||
|
||||
const [account] = await this.accounts({
|
||||
where: { id, userId }
|
||||
where: id_userId
|
||||
});
|
||||
|
||||
return account;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -19,7 +20,7 @@ export class CreateAccountDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -19,7 +20,7 @@ export class UpdateAccountDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
|
@ -25,6 +25,7 @@ import type {
|
||||
MarketDataPreset,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -255,7 +256,7 @@ export class AdminController {
|
||||
dataSource,
|
||||
marketPrice,
|
||||
symbol,
|
||||
date: resetHours(parseISO(date)),
|
||||
date: parseISO(date),
|
||||
state: 'CLOSE'
|
||||
})
|
||||
);
|
||||
@ -338,6 +339,6 @@ export class AdminController {
|
||||
@Param('key') key: string,
|
||||
@Body() data: PropertyDto
|
||||
) {
|
||||
return await this.adminService.putSetting(key, data.value);
|
||||
return this.adminService.putSetting(key, data.value);
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminController } from './admin.controller';
|
||||
|
@ -22,6 +22,7 @@ import {
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
AssetSubClass,
|
||||
@ -70,7 +71,7 @@ export class AdminService {
|
||||
);
|
||||
}
|
||||
|
||||
return await this.symbolProfileService.add(
|
||||
return this.symbolProfileService.add(
|
||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||
);
|
||||
} catch (error) {
|
||||
@ -211,6 +212,7 @@ export class AdminService {
|
||||
countries: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
id: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
@ -225,7 +227,7 @@ export class AdminService {
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
|
||||
let marketData = assetProfiles.map(
|
||||
let marketData: AdminMarketDataItem[] = assetProfiles.map(
|
||||
({
|
||||
_count,
|
||||
assetClass,
|
||||
@ -234,6 +236,7 @@ export class AdminService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
Order,
|
||||
sectors,
|
||||
@ -256,6 +259,7 @@ export class AdminService {
|
||||
currency,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
@ -330,19 +334,35 @@ export class AdminService {
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
await this.symbolProfileService.updateSymbolProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping
|
||||
});
|
||||
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
|
||||
{
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
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([
|
||||
{
|
||||
@ -396,6 +416,7 @@ export class AdminService {
|
||||
assetClass: 'CASH',
|
||||
countriesCount: 0,
|
||||
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||
id: undefined,
|
||||
name: symbol,
|
||||
sectorsCount: 0
|
||||
};
|
||||
@ -439,13 +460,14 @@ export class AdminService {
|
||||
},
|
||||
createdAt: true,
|
||||
id: true,
|
||||
role: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||
({ _count, Analytics, createdAt, id, role, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics
|
||||
@ -455,13 +477,17 @@ export class AdminService {
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.subscriptionService.getSubscription(Subscription)
|
||||
? this.subscriptionService.getSubscription({
|
||||
createdAt,
|
||||
subscriptions: Subscription
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
createdAt,
|
||||
engagement,
|
||||
id,
|
||||
role,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
country: Analytics?.country,
|
||||
|
@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { QueueController } from './queue.controller';
|
||||
|
@ -3,6 +3,7 @@ import {
|
||||
QUEUE_JOB_STATUS_LIST
|
||||
} from '@ghostfolio/common/config';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JobStatus, Queue } from 'bull';
|
||||
|
@ -2,6 +2,7 @@ import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString
|
||||
@ -24,7 +25,7 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
countries?: Prisma.InputJsonArray;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
|
@ -1,22 +1,23 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
SUPPORTED_LANGUAGE_CODES
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { join } from 'path';
|
||||
|
||||
import { AccessModule } from './access/access.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
@ -52,6 +53,7 @@ import { UserModule } from './user/user.module';
|
||||
BenchmarkModule,
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
||||
host: process.env.REDIS_HOST,
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD
|
||||
@ -73,6 +75,7 @@ import { UserModule } from './user/user.module';
|
||||
PlatformModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
|
@ -2,6 +2,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-devic
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthDevice, Prisma } from '@prisma/client';
|
||||
|
||||
|
@ -3,6 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
|
@ -5,6 +5,7 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
|
||||
import { Provider } from '@prisma/client';
|
||||
|
||||
export interface AuthDeviceDialogParams {
|
||||
|
@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||
|
@ -3,6 +3,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
@ -40,7 +41,7 @@ export class WebAuthService {
|
||||
) {}
|
||||
|
||||
get rpID() {
|
||||
return this.configurationService.get('WEB_AUTH_RP_ID');
|
||||
return new URL(this.configurationService.get('ROOT_URL')).hostname;
|
||||
}
|
||||
|
||||
get expectedOrigin() {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import type {
|
||||
@ -8,7 +9,8 @@ import type {
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -18,6 +20,7 @@ import {
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
@ -105,13 +108,18 @@ export class BenchmarkController {
|
||||
public async getBenchmarkMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('startDateString') startDateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('range') dateRange: DateRange = 'max'
|
||||
): Promise<BenchmarkMarketDataDetails> {
|
||||
const startDate = new Date(startDateString);
|
||||
const { endDate, startDate } = getInterval(
|
||||
dateRange,
|
||||
new Date(startDateString)
|
||||
);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
return this.benchmarkService.getMarketDataBySymbol({
|
||||
dataSource,
|
||||
endDate,
|
||||
startDate,
|
||||
symbol,
|
||||
userCurrency
|
||||
|
@ -7,6 +7,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BenchmarkController } from './benchmark.controller';
|
||||
|
@ -13,7 +13,8 @@ import {
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
calculateBenchmarkTrend,
|
||||
parseDate
|
||||
parseDate,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Benchmark,
|
||||
@ -23,10 +24,17 @@ import {
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format, isSameDay, subDays } from 'date-fns';
|
||||
import { Big } from 'big.js';
|
||||
import {
|
||||
differenceInDays,
|
||||
eachDayOfInterval,
|
||||
format,
|
||||
isSameDay,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import { isNumber, last, uniqBy } from 'lodash';
|
||||
import ms from 'ms';
|
||||
|
||||
@ -109,7 +117,9 @@ export class BenchmarkService {
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
}),
|
||||
requestTimeout: ms('30 seconds'),
|
||||
useCache: false
|
||||
});
|
||||
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
@ -162,7 +172,7 @@ export class BenchmarkService {
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
ms('2 hours') / 1000
|
||||
);
|
||||
}
|
||||
|
||||
@ -205,15 +215,28 @@ export class BenchmarkService {
|
||||
|
||||
public async getMarketDataBySymbol({
|
||||
dataSource,
|
||||
endDate = new Date(),
|
||||
startDate,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: {
|
||||
endDate?: Date;
|
||||
startDate: Date;
|
||||
userCurrency: string;
|
||||
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||
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([
|
||||
this.symbolService.get({
|
||||
dataGatheringItem: {
|
||||
@ -229,7 +252,7 @@ export class BenchmarkService {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: {
|
||||
gte: startDate
|
||||
in: dates
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -263,17 +286,7 @@ export class BenchmarkService {
|
||||
return { marketData };
|
||||
}
|
||||
|
||||
const step = Math.round(
|
||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||
);
|
||||
|
||||
let i = 0;
|
||||
|
||||
for (let marketDataItem of marketDataItems) {
|
||||
if (i % step !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exchangeRate =
|
||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||
format(marketDataItem.date, DATE_FORMAT)
|
||||
@ -296,15 +309,15 @@ export class BenchmarkService {
|
||||
});
|
||||
}
|
||||
|
||||
const includesToday = isSameDay(
|
||||
const includesEndDate = isSameDay(
|
||||
parseDate(last(marketData).date),
|
||||
new Date()
|
||||
endDate
|
||||
);
|
||||
|
||||
if (currentSymbolItem?.marketPrice && !includesToday) {
|
||||
if (currentSymbolItem?.marketPrice && !includesEndDate) {
|
||||
const exchangeRate =
|
||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||
format(new Date(), DATE_FORMAT)
|
||||
format(endDate, DATE_FORMAT)
|
||||
];
|
||||
|
||||
const exchangeRateFactor =
|
||||
@ -313,7 +326,7 @@ export class BenchmarkService {
|
||||
: 1;
|
||||
|
||||
marketData.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
date: format(endDate, DATE_FORMAT),
|
||||
value:
|
||||
this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
|
1
apps/api/src/app/cache/cache.controller.ts
vendored
1
apps/api/src/app/cache/cache.controller.ts
vendored
@ -2,6 +2,7 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
|
1
apps/api/src/app/cache/cache.module.ts
vendored
1
apps/api/src/app/cache/cache.module.ts
vendored
@ -5,6 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheController } from './cache.controller';
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExchangeRateController } from './exchange-rate.controller';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
@ -5,6 +5,7 @@ import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExportController } from './export.controller';
|
||||
|
@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { Filter, Export } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@ -94,7 +95,10 @@ export class ExportService {
|
||||
: SymbolProfile.symbol
|
||||
};
|
||||
}
|
||||
)
|
||||
),
|
||||
user: {
|
||||
settings: { currency: userCurrency }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HealthController } from './health.controller';
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||
|
||||
|
@ -6,6 +6,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
|
||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -42,8 +43,10 @@ export class ImportController {
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async import(
|
||||
@Body() importData: ImportDataDto,
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
@Query('dryRun') isDryRunParam = 'false'
|
||||
): Promise<ImportResponse> {
|
||||
const isDryRun = isDryRunParam === 'true';
|
||||
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||
) {
|
||||
|
@ -10,6 +10,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
|
@ -24,9 +24,10 @@ import {
|
||||
OrderWithAccount,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { uniqBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@ -116,7 +117,7 @@ export class ImportService {
|
||||
feeInBaseCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
isDraft: false,
|
||||
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||
SymbolProfile: assetProfile,
|
||||
symbolProfileId: assetProfile.id,
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: marketPrice,
|
||||
@ -520,22 +521,14 @@ export class ImportService {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol,
|
||||
assetClass: null,
|
||||
assetSubClass: null,
|
||||
comment: null,
|
||||
countries: null,
|
||||
activitiesCount: undefined,
|
||||
assetClass: undefined,
|
||||
assetSubClass: undefined,
|
||||
countries: undefined,
|
||||
createdAt: undefined,
|
||||
figi: null,
|
||||
figiComposite: null,
|
||||
figiShareClass: null,
|
||||
id: undefined,
|
||||
isin: null,
|
||||
name: null,
|
||||
scraperConfiguration: null,
|
||||
sectors: null,
|
||||
symbolMapping: null,
|
||||
updatedAt: undefined,
|
||||
url: null
|
||||
sectors: undefined,
|
||||
updatedAt: undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -569,17 +562,10 @@ export class ImportService {
|
||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
|
||||
const uniqueActivitiesDto = uniqBy(
|
||||
activitiesDto,
|
||||
({ dataSource, symbol }) => {
|
||||
return getAssetProfileIdentifier({ dataSource, symbol });
|
||||
}
|
||||
);
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, symbol, type }
|
||||
] of uniqueActivitiesDto.entries()) {
|
||||
] of activitiesDto.entries()) {
|
||||
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||
throw new Error(
|
||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||
@ -601,37 +587,33 @@ export class ImportService {
|
||||
}
|
||||
}
|
||||
|
||||
const assetProfile = {
|
||||
currency,
|
||||
...(
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol]
|
||||
};
|
||||
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
|
||||
const assetProfile = {
|
||||
currency,
|
||||
...(
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol]
|
||||
};
|
||||
|
||||
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
||||
if (!assetProfile?.name) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
||||
if (!assetProfile?.name) {
|
||||
throw new Error(
|
||||
`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 (
|
||||
assetProfile.currency !== currency &&
|
||||
!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;
|
||||
}
|
||||
|
||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||
assetProfile;
|
||||
}
|
||||
|
||||
return assetProfiles;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||
|
||||
import { InfoService } from './info.service';
|
||||
|
@ -10,6 +10,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as cheerio from 'cheerio';
|
||||
@ -59,10 +60,6 @@ export class InfoService {
|
||||
|
||||
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_SUBSCRIPTION')) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(
|
||||
@ -351,7 +348,7 @@ export class InfoService {
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.configurationService.get(
|
||||
'BETTER_UPTIME_API_KEY'
|
||||
'API_KEY_BETTER_UPTIME'
|
||||
)}`
|
||||
},
|
||||
// @ts-ignore
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LogoController } from './logo.controller';
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import got from 'got';
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -38,7 +39,7 @@ export class CreateOrderDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@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 {
|
||||
activities: Activity[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
export interface Activity extends Order {
|
||||
Account?: AccountWithPlatform;
|
||||
error?: ActivityError;
|
||||
feeInBaseCurrency: number;
|
||||
SymbolProfile?: EnhancedSymbolProfile;
|
||||
tags?: Tag[];
|
||||
updateAccountBalance?: boolean;
|
||||
value: number;
|
||||
valueInBaseCurrency: number;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
@ -8,7 +9,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -83,6 +85,7 @@ export class OrderController {
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@ -95,14 +98,18 @@ export class OrderController {
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const { endDate, startDate } = getInterval(dateRange);
|
||||
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const { activities, count } = await this.orderService.getOrders({
|
||||
endDate,
|
||||
filters,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
startDate,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
skip: isNaN(skip) ? undefined : skip,
|
||||
|
@ -11,6 +11,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OrderController } from './order.controller';
|
||||
|
@ -8,8 +8,9 @@ import {
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
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 { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
@ -18,11 +19,11 @@ import {
|
||||
Order,
|
||||
Prisma,
|
||||
Tag,
|
||||
Type as TypeOfOrder
|
||||
Type as ActivityType
|
||||
} from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
import { groupBy, uniqBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
@ -69,12 +70,7 @@ export class OrderService {
|
||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||
const userId = data.userId;
|
||||
|
||||
if (
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
) {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
@ -129,13 +125,9 @@ export class OrderService {
|
||||
|
||||
const orderData: Prisma.OrderCreateInput = data;
|
||||
|
||||
const isDraft =
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
|
||||
const order = await this.prismaService.order.create({
|
||||
data: {
|
||||
@ -156,7 +148,7 @@ export class OrderService {
|
||||
.plus(data.fee)
|
||||
.toNumber();
|
||||
|
||||
if (data.type === 'BUY') {
|
||||
if (['BUY', 'FEE'].includes(data.type)) {
|
||||
amount = new Big(amount).mul(-1).toNumber();
|
||||
}
|
||||
|
||||
@ -179,12 +171,7 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
if (
|
||||
order.type === 'FEE' ||
|
||||
order.type === 'INTEREST' ||
|
||||
order.type === 'ITEM' ||
|
||||
order.type === 'LIABILITY'
|
||||
) {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
@ -199,25 +186,40 @@ export class OrderService {
|
||||
return count;
|
||||
}
|
||||
|
||||
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
|
||||
return this.prismaService.order.findFirst({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
where: {
|
||||
SymbolProfile: { dataSource, symbol }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getOrders({
|
||||
endDate,
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
skip,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
startDate,
|
||||
take = Number.MAX_SAFE_INTEGER,
|
||||
types,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
endDate?: Date;
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
skip?: number;
|
||||
sortColumn?: string;
|
||||
sortDirection?: Prisma.SortOrder;
|
||||
startDate?: Date;
|
||||
take?: number;
|
||||
types?: TypeOfOrder[];
|
||||
types?: ActivityType[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
@ -227,6 +229,18 @@ export class OrderService {
|
||||
];
|
||||
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 {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
@ -291,13 +305,14 @@ export class OrderService {
|
||||
}
|
||||
|
||||
if (types) {
|
||||
where.OR = types.map((type) => {
|
||||
return {
|
||||
type: {
|
||||
equals: type
|
||||
}
|
||||
};
|
||||
});
|
||||
where.type = { in: types };
|
||||
}
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.OR = [
|
||||
{ Account: null },
|
||||
{ Account: { NOT: { isExcluded: true } } }
|
||||
];
|
||||
}
|
||||
|
||||
const [orders, count] = await Promise.all([
|
||||
@ -321,33 +336,53 @@ export class OrderService {
|
||||
this.prismaService.order.count({ where })
|
||||
]);
|
||||
|
||||
const activities = orders
|
||||
.filter((order) => {
|
||||
return (
|
||||
withExcludedAccounts ||
|
||||
!order.Account ||
|
||||
order.Account?.isExcluded === false
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
const uniqueAssets = uniqBy(
|
||||
orders.map(({ SymbolProfile }) => {
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
};
|
||||
}),
|
||||
({ 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 };
|
||||
}
|
||||
|
||||
@ -370,13 +405,10 @@ export class OrderService {
|
||||
dataSource?: DataSource;
|
||||
symbol?: string;
|
||||
tags?: Tag[];
|
||||
type?: ActivityType;
|
||||
};
|
||||
where: Prisma.OrderWhereUniqueInput;
|
||||
}): Promise<Order> {
|
||||
if (data.Account.connect.id_userId.id === null) {
|
||||
delete data.Account;
|
||||
}
|
||||
|
||||
if (!data.comment) {
|
||||
data.comment = null;
|
||||
}
|
||||
@ -385,13 +417,12 @@ export class OrderService {
|
||||
|
||||
let isDraft = false;
|
||||
|
||||
if (
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
) {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||
delete data.SymbolProfile.connect;
|
||||
|
||||
if (data.Account?.connect?.id_userId?.id === null) {
|
||||
data.Account = { disconnect: true };
|
||||
}
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
||||
|
@ -9,6 +9,7 @@ import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -37,7 +38,7 @@ export class UpdateOrderDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PlatformController } from './platform.controller';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Platform, Prisma } from '@prisma/client';
|
||||
|
||||
|
@ -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,10 +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 { 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';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
@ -18,9 +26,10 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -28,41 +37,53 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-22'),
|
||||
fee: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
date: '2021-11-22',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.55),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(142.9)
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
{
|
||||
type: 'BUY',
|
||||
unitPrice: 142.9
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 1.65,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.65),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(136.6)
|
||||
}
|
||||
]
|
||||
});
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 136.6
|
||||
}
|
||||
];
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
});
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
@ -86,7 +107,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('0'),
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
@ -106,6 +127,8 @@ describe('PortfolioCalculator', () => {
|
||||
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'),
|
||||
@ -128,7 +151,8 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW',
|
||||
timeWeightedInvestment: new Big('285.8'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
||||
transactionCount: 2
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0'),
|
@ -1,10 +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 { 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';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
@ -18,9 +26,10 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -28,30 +37,38 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.55),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(136.6)
|
||||
}
|
||||
]
|
||||
});
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 136.6
|
||||
}
|
||||
];
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
});
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
@ -75,7 +92,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('297.8'),
|
||||
currentValueInBaseCurrency: new Big('297.8'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('24.6'),
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||
@ -95,6 +112,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('136.6'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1.55'),
|
||||
firstBuyDate: '2021-11-30',
|
||||
grossPerformance: new Big('24.6'),
|
||||
@ -117,7 +136,8 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW',
|
||||
timeWeightedInvestment: new Big('273.2'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||
transactionCount: 1
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('297.8')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('273.2'),
|
@ -1,11 +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 { 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';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
@ -31,9 +39,10 @@ jest.mock(
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -41,41 +50,53 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BTCUSD buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2015-01-01'),
|
||||
fee: 0,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
date: '2015-01-01',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BTCUSD',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(320.43)
|
||||
symbol: 'BTCUSD'
|
||||
},
|
||||
{
|
||||
type: 'BUY',
|
||||
unitPrice: 320.43
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2017-12-31'),
|
||||
fee: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
date: '2017-12-31',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BTCUSD',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(14156.4)
|
||||
}
|
||||
]
|
||||
});
|
||||
symbol: 'BTCUSD'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 14156.4
|
||||
}
|
||||
];
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
});
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
@ -99,7 +120,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('13298.425356'),
|
||||
currentValueInBaseCurrency: new Big('13298.425356'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
@ -119,6 +140,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('320.43'),
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
@ -148,7 +171,8 @@ describe('PortfolioCalculator', () => {
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'636.79469348020066587024'
|
||||
),
|
||||
transactionCount: 2
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('13298.425356')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('320.43'),
|
@ -1,11 +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 { 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';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
@ -31,9 +39,10 @@ jest.mock(
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -41,30 +50,38 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with GOOGL buy', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2023-01-03'),
|
||||
fee: 1,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
date: '2023-01-03',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1),
|
||||
name: 'Alphabet Inc.',
|
||||
quantity: new Big(1),
|
||||
symbol: 'GOOGL',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(89.12)
|
||||
}
|
||||
]
|
||||
});
|
||||
symbol: 'GOOGL'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 89.12
|
||||
}
|
||||
];
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
});
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
@ -88,7 +105,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('103.10483'),
|
||||
currentValueInBaseCurrency: new Big('103.10483'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27.33'),
|
||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||
@ -108,6 +125,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('89.12'),
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1'),
|
||||
firstBuyDate: '2023-01-03',
|
||||
grossPerformance: new Big('27.33'),
|
||||
@ -131,7 +150,8 @@ describe('PortfolioCalculator', () => {
|
||||
tags: undefined,
|
||||
timeWeightedInvestment: new Big('89.12'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||
transactionCount: 1
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('103.10483')
|
||||
}
|
||||
],
|
||||
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,10 +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 { 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';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { Big } from 'big.js';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
@ -18,9 +22,10 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -28,30 +33,31 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it('with no orders', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: []
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities: [],
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: new Date()
|
||||
});
|
||||
const start = subDays(new Date(Date.now()), 10);
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
new Date()
|
||||
);
|
||||
const chartData = await portfolioCalculator.getChartData({ start });
|
||||
|
||||
const currentPositions =
|
||||
await portfolioCalculator.getCurrentPositions(start);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
@ -63,7 +69,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big(0),
|
||||
currentValueInBaseCurrency: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
@ -74,7 +80,8 @@ describe('PortfolioCalculator', () => {
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
netPerformanceWithCurrencyEffect: new Big(0),
|
||||
positions: [],
|
||||
totalInvestment: new Big(0)
|
||||
totalInvestment: new Big(0),
|
||||
totalInvestmentWithCurrencyEffect: new Big(0)
|
||||
});
|
||||
|
||||
expect(investments).toEqual([]);
|
@ -1,10 +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 { 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';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
@ -18,9 +26,10 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -28,42 +37,53 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-03-07'),
|
||||
fee: 1.3,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
date: '2022-03-07',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.3),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(75.8)
|
||||
symbol: 'NOVN.SW'
|
||||
},
|
||||
{
|
||||
type: 'BUY',
|
||||
unitPrice: 75.8
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-04-08'),
|
||||
fee: 2.95,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
date: '2022-04-08',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(2.95),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(1),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(85.73)
|
||||
}
|
||||
]
|
||||
symbol: 'NOVN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 85.73
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
@ -86,7 +106,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('87.8'),
|
||||
currentValueInBaseCurrency: new Big('87.8'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||
@ -106,6 +126,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('75.80'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('4.25'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('21.93'),
|
||||
@ -130,7 +152,8 @@ describe('PortfolioCalculator', () => {
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'145.10285714285714285714'
|
||||
),
|
||||
transactionCount: 2
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('87.8')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('75.80'),
|
@ -1,10 +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 { 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';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
@ -18,9 +26,10 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -28,41 +37,53 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with NOVN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-03-07'),
|
||||
fee: 0,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
date: '2022-03-07',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(75.8)
|
||||
symbol: 'NOVN.SW'
|
||||
},
|
||||
{
|
||||
type: 'BUY',
|
||||
unitPrice: 75.8
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-04-08'),
|
||||
fee: 0,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
date: '2022-04-08',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(85.73)
|
||||
}
|
||||
]
|
||||
});
|
||||
symbol: 'NOVN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 85.73
|
||||
}
|
||||
];
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
});
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
@ -112,7 +133,7 @@ describe('PortfolioCalculator', () => {
|
||||
});
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('0'),
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
@ -132,6 +153,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('19.86'),
|
||||
@ -154,7 +177,8 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'NOVN.SW',
|
||||
timeWeightedInvestment: new Big('151.6'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
|
||||
transactionCount: 2
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: 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
|
||||
};
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
|
||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
@ -42,6 +43,17 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
|
||||
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':
|
||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||
return { marketPrice: 87.8 };
|
||||
|
@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
@ -106,7 +107,9 @@ describe('CurrentRateService', () => {
|
||||
|
||||
currentRateService = new CurrentRateService(
|
||||
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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
@ -6,7 +7,10 @@ import {
|
||||
ResponseError,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten, isEmpty, uniqBy } from 'lodash';
|
||||
|
||||
@ -18,16 +22,19 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
export class CurrentRateService {
|
||||
public constructor(
|
||||
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({
|
||||
dataGatheringItems,
|
||||
dateQuery
|
||||
}: GetValuesParams): Promise<GetValuesObject> {
|
||||
const dataProviderInfos: DataProviderInfo[] = [];
|
||||
|
||||
const includeToday =
|
||||
const includesToday =
|
||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||
@ -36,10 +43,10 @@ export class CurrentRateService {
|
||||
const quoteErrors: ResponseError['errors'] = [];
|
||||
const today = resetHours(new Date());
|
||||
|
||||
if (includeToday) {
|
||||
if (includesToday) {
|
||||
promises.push(
|
||||
this.dataProviderService
|
||||
.getQuotes({ items: dataGatheringItems })
|
||||
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
|
||||
.then((dataResultProvider) => {
|
||||
const result: GetValueObject[] = [];
|
||||
|
||||
@ -116,11 +123,17 @@ export class CurrentRateService {
|
||||
});
|
||||
|
||||
if (!value) {
|
||||
// Fallback to unit price of latest activity
|
||||
const latestActivity = await this.orderService.getLatestOrder({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
value = {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: today,
|
||||
marketPrice: 0
|
||||
marketPrice: latestActivity?.unitPrice ?? 0
|
||||
};
|
||||
|
||||
response.values.push(value);
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
export interface CurrentPositions extends ResponseError {
|
||||
positions: TimelinePosition[];
|
||||
currentValueInBaseCurrency: Big;
|
||||
grossPerformance: Big;
|
||||
grossPerformanceWithCurrencyEffect: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
@ -13,6 +14,7 @@ export interface CurrentPositions extends ResponseError {
|
||||
netPerformanceWithCurrencyEffect: Big;
|
||||
netPerformancePercentage: Big;
|
||||
netPerformancePercentageWithCurrencyEffect: Big;
|
||||
currentValue: Big;
|
||||
positions: TimelinePosition[];
|
||||
totalInvestment: Big;
|
||||
totalInvestmentWithCurrencyEffect: Big;
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
import { PortfolioOrder } from './portfolio-order.interface';
|
||||
|
||||
export interface PortfolioOrderItem extends PortfolioOrder {
|
||||
feeInBaseCurrency?: Big;
|
||||
feeInBaseCurrencyWithCurrencyEffect?: Big;
|
||||
itemType?: '' | 'start' | 'end';
|
||||
itemType?: 'end' | 'start';
|
||||
unitPriceInBaseCurrency?: Big;
|
||||
unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
|
||||
}
|
@ -1,15 +1,12 @@
|
||||
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
currency: string;
|
||||
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
|
||||
date: string;
|
||||
dataSource: DataSource;
|
||||
fee: Big;
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
tags?: Tag[];
|
||||
type: TypeOfOrder;
|
||||
SymbolProfile: Pick<
|
||||
Activity['SymbolProfile'],
|
||||
'currency' | 'dataSource' | 'name' | 'symbol'
|
||||
>;
|
||||
unitPrice: Big;
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
EnhancedSymbolProfile,
|
||||
HistoricalDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Tag } from '@prisma/client';
|
||||
|
||||
import { Account, Tag } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
accounts: Account[];
|
||||
averagePrice: number;
|
||||
dataProviderInfo: DataProviderInfo;
|
||||
dividendInBaseCurrency: number;
|
||||
@ -25,7 +27,7 @@ export interface PortfolioPositionDetail {
|
||||
netPerformancePercent: number;
|
||||
netPerformancePercentWithCurrencyEffect: number;
|
||||
netPerformanceWithCurrencyEffect: number;
|
||||
orders: OrderWithAccount[];
|
||||
orders: Activity[];
|
||||
quantity: number;
|
||||
SymbolProfile: EnhancedSymbolProfile;
|
||||
tags: Tag[];
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { DataSource, Tag } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
export interface TransactionPointSymbol {
|
||||
averagePrice: Big;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
dividend: Big;
|
||||
fee: Big;
|
||||
firstBuyDate: string;
|
||||
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 { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
} 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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
HEADER_KEY_IMPERSONATION
|
||||
@ -18,6 +21,7 @@ import {
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
PortfolioHoldingsResponse,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
@ -28,6 +32,7 @@ import type {
|
||||
GroupBy,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -42,7 +47,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
@ -56,6 +61,8 @@ export class PortfolioController {
|
||||
private readonly apiService: ApiService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
@ -70,8 +77,11 @@ export class PortfolioController {
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withLiabilities') withLiabilitiesParam = 'false'
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
const withLiabilities = withLiabilitiesParam === 'true';
|
||||
|
||||
let hasDetails = true;
|
||||
let hasError = false;
|
||||
const hasReadRestrictedAccessPermission =
|
||||
@ -90,21 +100,15 @@ export class PortfolioController {
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasErrors,
|
||||
holdings,
|
||||
platforms,
|
||||
summary,
|
||||
totalValueInBaseCurrency
|
||||
} = await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
const { accounts, hasErrors, holdings, platforms, summary } =
|
||||
await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
withLiabilities,
|
||||
userId: this.request.user.id,
|
||||
withSummary: true
|
||||
});
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
hasError = true;
|
||||
@ -117,27 +121,23 @@ export class PortfolioController {
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const totalInvestment = Object.values(holdings)
|
||||
.map((portfolioPosition) => {
|
||||
return portfolioPosition.investment;
|
||||
.map(({ investment }) => {
|
||||
return investment;
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
const totalValue = Object.values(holdings)
|
||||
.map((portfolioPosition) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
portfolioPosition.currency,
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
);
|
||||
.filter(({ assetClass, assetSubClass }) => {
|
||||
return assetClass !== 'CASH' && assetSubClass !== 'CASH';
|
||||
})
|
||||
.map(({ valueInBaseCurrency }) => {
|
||||
return valueInBaseCurrency;
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
portfolioPosition.grossPerformance = null;
|
||||
portfolioPosition.investment =
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
portfolioPosition.netPerformance = null;
|
||||
portfolioPosition.quantity = null;
|
||||
portfolioPosition.valueInPercentage =
|
||||
portfolioPosition.valueInBaseCurrency / totalValue;
|
||||
}
|
||||
@ -163,19 +163,21 @@ export class PortfolioController {
|
||||
'currentGrossPerformanceWithCurrencyEffect',
|
||||
'currentNetPerformance',
|
||||
'currentNetPerformanceWithCurrencyEffect',
|
||||
'currentNetWorth',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'dividendInBaseCurrency',
|
||||
'emergencyFund',
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'filteredValueInBaseCurrency',
|
||||
'fireWealth',
|
||||
'interest',
|
||||
'items',
|
||||
'liabilities',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalInvestment',
|
||||
'totalSell'
|
||||
'totalSell',
|
||||
'totalValueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
@ -202,12 +204,9 @@ export class PortfolioController {
|
||||
|
||||
return {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasError,
|
||||
holdings,
|
||||
platforms,
|
||||
totalValueInBaseCurrency,
|
||||
summary: portfolioSummary
|
||||
};
|
||||
}
|
||||
@ -234,11 +233,24 @@ export class PortfolioController {
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let dividends = await this.portfolioService.getDividends({
|
||||
dateRange,
|
||||
const impersonationUserId =
|
||||
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,
|
||||
groupBy,
|
||||
impersonationId
|
||||
startDate,
|
||||
userCurrency,
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
types: ['DIVIDEND']
|
||||
});
|
||||
|
||||
let dividends = await this.portfolioService.getDividends({
|
||||
activities,
|
||||
groupBy
|
||||
});
|
||||
|
||||
if (
|
||||
@ -268,6 +280,35 @@ export class PortfolioController {
|
||||
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')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getInvestments(
|
||||
@ -345,8 +386,12 @@ export class PortfolioController {
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withExcludedAccounts') withExcludedAccounts = false
|
||||
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false',
|
||||
@Query('withItems') withItemsParam = 'false'
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const withExcludedAccounts = withExcludedAccountsParam === 'true';
|
||||
const withItems = withItemsParam === 'true';
|
||||
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
@ -364,6 +409,7 @@ export class PortfolioController {
|
||||
filters,
|
||||
impersonationId,
|
||||
withExcludedAccounts,
|
||||
withItems,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
@ -376,6 +422,7 @@ export class PortfolioController {
|
||||
({
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformanceInPercentageWithCurrencyEffect,
|
||||
netWorth,
|
||||
totalInvestment,
|
||||
value
|
||||
@ -383,6 +430,7 @@ export class PortfolioController {
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformanceInPercentageWithCurrencyEffect,
|
||||
netWorthInPercentage:
|
||||
performanceInformation.performance.currentNetWorth === 0
|
||||
? 0
|
||||
@ -428,6 +476,10 @@ export class PortfolioController {
|
||||
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
||||
}
|
||||
);
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentNetPerformance', 'currentNetPerformancePercent']
|
||||
);
|
||||
}
|
||||
|
||||
return performanceInformation;
|
||||
@ -482,7 +534,6 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
dateRange: 'max',
|
||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||
impersonationId: access.userId,
|
||||
userId: user.id
|
||||
@ -514,7 +565,8 @@ export class PortfolioController {
|
||||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
name: portfolioPosition.name,
|
||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||
netPerformancePercentWithCurrencyEffect:
|
||||
portfolioPosition.netPerformancePercentWithCurrencyEffect,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
symbol: portfolioPosition.symbol,
|
||||
url: portfolioPosition.url,
|
||||
|
@ -12,8 +12,10 @@ import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impe
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
@ -40,6 +42,7 @@ import { RulesService } from './rules.service';
|
||||
AccountBalanceService,
|
||||
AccountService,
|
||||
CurrentRateService,
|
||||
PortfolioCalculatorFactory,
|
||||
PortfolioService,
|
||||
RulesService
|
||||
]
|
||||
|
@ -1,17 +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 { CurrentRateService } from './current-rate.service';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
describe('PortfolioService', () => {
|
||||
let portfolioService: PortfolioService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
beforeAll(async () => {
|
||||
portfolioService = new PortfolioService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
@ -20,16 +22,9 @@ describe('PortfolioCalculator', () => {
|
||||
});
|
||||
|
||||
describe('annualized performance percentage', () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'USD',
|
||||
orders: []
|
||||
});
|
||||
|
||||
it('Get annualized performance', async () => {
|
||||
expect(
|
||||
portfolioCalculator
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||
netPerformancePercent: new Big(0)
|
||||
@ -38,7 +33,7 @@ describe('PortfolioCalculator', () => {
|
||||
).toEqual(0);
|
||||
|
||||
expect(
|
||||
portfolioCalculator
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 0,
|
||||
netPerformancePercent: new Big(0)
|
||||
@ -50,7 +45,7 @@ describe('PortfolioCalculator', () => {
|
||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||
*/
|
||||
expect(
|
||||
portfolioCalculator
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 65, // < 1 year
|
||||
netPerformancePercent: new Big(0.1025)
|
||||
@ -59,7 +54,7 @@ describe('PortfolioCalculator', () => {
|
||||
).toBeCloseTo(0.729705);
|
||||
|
||||
expect(
|
||||
portfolioCalculator
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 365, // 1 year
|
||||
netPerformancePercent: new Big(0.05)
|
||||
@ -71,7 +66,7 @@ describe('PortfolioCalculator', () => {
|
||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||
*/
|
||||
expect(
|
||||
portfolioCalculator
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 575, // > 1 year
|
||||
netPerformancePercent: new Big(0.2374)
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user