Compare commits
142 Commits
Author | SHA1 | Date | |
---|---|---|---|
d9c07456cd | |||
0a53df4293 | |||
4416ba0c88 | |||
486de968a2 | |||
a5833566a8 | |||
261f5844dd | |||
2173c418a7 | |||
4efd5cefd8 | |||
d735e4db75 | |||
e10707fde4 | |||
ac953df809 | |||
bb4ee50738 | |||
4f41bac328 | |||
cd07802400 | |||
b692b7432c | |||
a4efbc0131 | |||
55b0fe232c | |||
46432edce9 | |||
990028316e | |||
37871fbabc | |||
0fdeef7953 | |||
cdbe6eedeb | |||
a6dde8ad43 | |||
39bd4a349b | |||
ab59eb5c92 | |||
1132dc9bdd | |||
2d70b18593 | |||
b6ea7d23fa | |||
95382581f1 | |||
551b83a6e3 | |||
895c4fe299 | |||
ccb1bf881e | |||
7788b5a987 | |||
47fd029e0c | |||
458ee159e1 | |||
dfacbed66d | |||
22d63c6102 | |||
212aa6a63b | |||
bed9ae916c | |||
b6ad362850 | |||
ab86dd5318 | |||
dba73d80a3 | |||
92fb05320a | |||
73d62bb51f | |||
127b7d4f25 | |||
e79d607ab8 | |||
5f7d083f7c | |||
15857118fe | |||
ff91ed21df | |||
9241c04d5a | |||
5d4e2fba8c | |||
6c57609db8 | |||
b31bbbe2d1 | |||
7d308917dd | |||
4e7d93db13 | |||
45340b581f | |||
6f8fe45fc2 | |||
34d9ceb009 | |||
2b97bbd05d | |||
3bf7ac76a0 | |||
71892e67b2 | |||
6e2885ed20 | |||
07c0e5a612 | |||
719bbe156e | |||
b51255a543 | |||
50dbbf0569 | |||
ca2e748c56 | |||
5c480109d5 | |||
6152ff4b44 | |||
c6641fde36 | |||
4ae7e9fcbe | |||
c10ae431a2 | |||
883e30e451 | |||
f1f4f6247d | |||
82fe1de1a7 | |||
371c999fbc | |||
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 |
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>
|
||||
|
256
CHANGELOG.md
256
CHANGELOG.md
@ -5,7 +5,261 @@ 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.63.1 - 2024-03-11
|
||||
## 2.78.0 - 2024-05-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a form validation against the DTO in the create or update access dialog
|
||||
- Added a form validation against the DTO in the asset profile details dialog of the admin control
|
||||
- Added a form validation against the DTO in the platform management of the admin control panel
|
||||
- Added a form validation against the DTO in the tag management of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Set the performance column of the holdings table to stick at the end
|
||||
- Skipped the caching in the portfolio calculator if there are active filters (experimental)
|
||||
- Improved the `INACTIVE` user role
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
|
||||
- Fixed a division by zero error in the dividend yield calculation (experimental)
|
||||
|
||||
## 2.77.1 - 2024-04-27
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the content of the _Self-Hosting_ section by the custom asset instructions on the Frequently Asked Questions (FAQ) page
|
||||
- Added the caching to the portfolio calculator (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the `@ghostfolio/ui` components to control flow
|
||||
- Updated the browserslist database
|
||||
- Upgraded `prisma` from version `5.12.1` to `5.13.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the form submit in the asset profile details dialog of the admin control due to the `url` validation
|
||||
- Fixed the historical market data gathering for asset profiles with `MANUAL` data source
|
||||
|
||||
## 2.76.0 - 2024-04-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `CASH` to `LIQUIDITY` in the asset class enum
|
||||
|
||||
## 2.75.1 - 2024-04-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added `accountId` and `date` as a unique constraint to the `AccountBalance` database schema
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the chart in the account detail dialog
|
||||
- Improved the account balance management
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with `totalValueInBaseCurrency` in the value redaction interceptor for the impersonation mode
|
||||
|
||||
## 2.74.0 - 2024-04-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added the date range support to the portfolio holdings page
|
||||
- Added support to create an account balance
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the date range support in the activities table on the portfolio activities page (experimental)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `angular` from version `17.3.3` to `17.3.5`
|
||||
- Upgraded `Nx` from version `18.2.3` to `18.3.3`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed gaps in the portfolio performance charts by considering `BUY` and `SELL` activities
|
||||
|
||||
## 2.73.0 - 2024-04-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added a form validation against the DTO in the create or update account dialog
|
||||
- Added a form validation against the DTO in the create or update activity dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the dividend calculations into the portfolio calculator
|
||||
- Moved the fee calculations into the portfolio calculator
|
||||
- Moved the interest calculations into the portfolio calculator
|
||||
- Moved the liability calculations into the portfolio calculator
|
||||
- Moved the (wealth) item calculations into the portfolio calculator
|
||||
- Let queue jobs for asset profile data gathering fail by throwing an error
|
||||
- Let queue jobs for historical market data gathering fail by throwing an error
|
||||
- Upgraded `yahoo-finance2` from version `2.11.1` to `2.11.2`
|
||||
|
||||
## 2.72.0 - 2024-04-13
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to immediately execute a queue job from the admin control panel
|
||||
- Added a priority column to the queue jobs view in the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Adapted the priorities of queue jobs
|
||||
- Upgraded `angular` from version `17.2.4` to `17.3.3`
|
||||
- Upgraded `Nx` from version `18.1.2` to `18.2.3`
|
||||
- Upgraded `prisma` from version `5.11.0` to `5.12.1`
|
||||
- Upgraded `yahoo-finance2` from version `2.11.0` to `2.11.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the public page
|
||||
|
||||
## 2.71.0 - 2024-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added the dividend yield to the position detail dialog (experimental)
|
||||
- Added support to override the asset class of an asset profile in the asset profile details dialog of the admin control
|
||||
- Added support to override the asset sub class of an asset profile in the asset profile details dialog of the admin control
|
||||
- Added support to override the url of an asset profile in the asset profile details dialog of the admin control
|
||||
- Added the asset profile icon to the asset profile details dialog of the admin control
|
||||
- Added the platform icon to the create or update platform dialog of the admin control
|
||||
- Extended the rules in the _X-ray_ section by a `key`
|
||||
- Added `currency` to the `Order` database schema as a preparation to set a custom currency
|
||||
- Extended the content of the _Self-Hosting_ section by the data providers on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the calculation of allocations by market
|
||||
- Improved the url validation in the create and update platform endpoint
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the missing tags in the portfolio calculations
|
||||
|
||||
## 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
|
||||
|
||||
|
@ -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" ]
|
||||
|
44
README.md
44
README.md
@ -7,14 +7,12 @@
|
||||
**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)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
|
||||
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
|
||||
|
||||
</div>
|
||||
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||
@ -87,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Name | Default Value | Description |
|
||||
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | | The _CoinGecko_ Pro API |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `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 |
|
||||
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
| Name | Type | Default Value | Description |
|
||||
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | string (`optional`) | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
|
||||
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | string | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | number | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -144,7 +142,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 [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).
|
||||
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), Home Assistant, [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
|
||||
|
||||
@ -154,7 +152,7 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
|
||||
- [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
|
||||
|
||||
@ -206,7 +204,7 @@ Set the header for each request as follows:
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
||||
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
|
||||
|
||||
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||
|
||||
|
@ -83,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,3 +1,4 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
@ -5,6 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Body,
|
||||
Post,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
@ -17,14 +20,44 @@ import { AccountBalance } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccountBalanceService } from './account-balance.service';
|
||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||
|
||||
@Controller('account-balance')
|
||||
export class AccountBalanceController {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly accountService: AccountService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.createAccountBalance)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createAccountBalance(
|
||||
@Body() data: CreateAccountBalanceDto
|
||||
): Promise<AccountBalance> {
|
||||
const account = await this.accountService.account({
|
||||
id_userId: {
|
||||
id: data.accountId,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId: account.id,
|
||||
balance: data.balance,
|
||||
date: data.date,
|
||||
userId: account.userId
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.deleteAccountBalance)
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@ -32,10 +65,11 @@ export class AccountBalanceController {
|
||||
@Param('id') id: string
|
||||
): Promise<AccountBalance> {
|
||||
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||
id
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
|
||||
if (!accountBalance) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -43,7 +77,8 @@ export class AccountBalanceController {
|
||||
}
|
||||
|
||||
return this.accountBalanceService.deleteAccountBalance({
|
||||
id
|
||||
id: accountBalance.id,
|
||||
userId: accountBalance.userId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
@ -10,6 +11,6 @@ import { AccountBalanceService } from './account-balance.service';
|
||||
controllers: [AccountBalanceController],
|
||||
exports: [AccountBalanceService],
|
||||
imports: [ExchangeRateDataModule, PrismaModule],
|
||||
providers: [AccountBalanceService]
|
||||
providers: [AccountBalanceService, AccountService]
|
||||
})
|
||||
export class AccountBalanceModule {}
|
||||
|
@ -1,14 +1,21 @@
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { AccountBalance, Prisma } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccountBalanceService {
|
||||
public constructor(
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
@ -24,20 +31,63 @@ export class AccountBalanceService {
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccountBalance(
|
||||
data: Prisma.AccountBalanceCreateInput
|
||||
): Promise<AccountBalance> {
|
||||
return this.prismaService.accountBalance.create({
|
||||
data
|
||||
public async createOrUpdateAccountBalance({
|
||||
accountId,
|
||||
balance,
|
||||
date,
|
||||
userId
|
||||
}: CreateAccountBalanceDto & {
|
||||
userId: string;
|
||||
}): Promise<AccountBalance> {
|
||||
const accountBalance = await this.prismaService.accountBalance.upsert({
|
||||
create: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
}
|
||||
},
|
||||
date: resetHours(parseISO(date)),
|
||||
value: balance
|
||||
},
|
||||
update: {
|
||||
value: balance
|
||||
},
|
||||
where: {
|
||||
accountId_date: {
|
||||
accountId,
|
||||
date: resetHours(parseISO(date))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId
|
||||
})
|
||||
);
|
||||
|
||||
return accountBalance;
|
||||
}
|
||||
|
||||
public async deleteAccountBalance(
|
||||
where: Prisma.AccountBalanceWhereUniqueInput
|
||||
): Promise<AccountBalance> {
|
||||
return this.prismaService.accountBalance.delete({
|
||||
const accountBalance = await this.prismaService.accountBalance.delete({
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: <string>where.userId
|
||||
})
|
||||
);
|
||||
|
||||
return accountBalance;
|
||||
}
|
||||
|
||||
public async getAccountBalances({
|
||||
|
@ -0,0 +1,12 @@
|
||||
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateAccountBalanceDto {
|
||||
@IsUUID()
|
||||
accountId: string;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
}
|
@ -1,11 +1,15 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { CashDetails } from './interfaces/cash-details.interface';
|
||||
@ -14,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
||||
export class AccountService {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
@ -85,17 +90,20 @@ export class AccountService {
|
||||
data
|
||||
});
|
||||
|
||||
await this.prismaService.accountBalance.create({
|
||||
data: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: account.id, userId: aUserId }
|
||||
}
|
||||
},
|
||||
value: data.balance
|
||||
}
|
||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId: account.id,
|
||||
balance: data.balance,
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
userId: aUserId
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: account.userId
|
||||
})
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
@ -103,9 +111,18 @@ export class AccountService {
|
||||
where: Prisma.AccountWhereUniqueInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
return this.prismaService.account.delete({
|
||||
const account = await this.prismaService.account.delete({
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: account.userId
|
||||
})
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||
@ -196,21 +213,26 @@ export class AccountService {
|
||||
): Promise<Account> {
|
||||
const { data, where } = params;
|
||||
|
||||
await this.prismaService.accountBalance.create({
|
||||
data: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: where.id_userId
|
||||
}
|
||||
},
|
||||
value: <number>data.balance
|
||||
}
|
||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId: <string>data.id,
|
||||
balance: <number>data.balance,
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
userId: aUserId
|
||||
});
|
||||
|
||||
return this.prismaService.account.update({
|
||||
const account = await this.prismaService.account.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: account.userId
|
||||
})
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async updateAccountBalance({
|
||||
@ -242,17 +264,11 @@ export class AccountService {
|
||||
);
|
||||
|
||||
if (amountInCurrencyOfAccount) {
|
||||
await this.accountBalanceService.createAccountBalance({
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
}
|
||||
},
|
||||
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId,
|
||||
userId,
|
||||
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
|
||||
date: date.toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -7,13 +7,12 @@ import { ManualService } from '@ghostfolio/api/services/data-provider/manual/man
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
getAssetProfileIdentifier,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
@ -94,7 +93,8 @@ export class AdminController {
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
}
|
||||
};
|
||||
})
|
||||
@ -119,7 +119,8 @@ export class AdminController {
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
}
|
||||
};
|
||||
})
|
||||
@ -141,7 +142,8 @@ export class AdminController {
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -339,6 +341,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);
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Prisma,
|
||||
@ -71,7 +72,7 @@ export class AdminService {
|
||||
);
|
||||
}
|
||||
|
||||
return await this.symbolProfileService.add(
|
||||
return this.symbolProfileService.add(
|
||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||
);
|
||||
} catch (error) {
|
||||
@ -212,6 +213,7 @@ export class AdminService {
|
||||
countries: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
id: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
@ -235,6 +237,7 @@ export class AdminService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
Order,
|
||||
sectors,
|
||||
@ -257,6 +260,7 @@ export class AdminService {
|
||||
currency,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
@ -329,21 +333,39 @@ export class AdminService {
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping
|
||||
symbolMapping,
|
||||
url
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
await this.symbolProfileService.updateSymbolProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping
|
||||
});
|
||||
const symbolProfileOverrides = {
|
||||
assetClass: assetClass as AssetClass,
|
||||
assetSubClass: assetSubClass as AssetSubClass,
|
||||
name: name as string,
|
||||
url: url as string
|
||||
};
|
||||
|
||||
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
|
||||
{
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping,
|
||||
...(dataSource === 'MANUAL'
|
||||
? { assetClass, assetSubClass, name, url }
|
||||
: {
|
||||
SymbolProfileOverrides: {
|
||||
upsert: {
|
||||
create: symbolProfileOverrides,
|
||||
update: symbolProfileOverrides
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
@ -394,9 +416,10 @@ export class AdminService {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
assetClass: 'CASH',
|
||||
assetClass: AssetClass.LIQUIDITY,
|
||||
countriesCount: 0,
|
||||
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||
id: undefined,
|
||||
name: symbol,
|
||||
sectorsCount: 0
|
||||
};
|
||||
|
@ -46,4 +46,11 @@ export class QueueController {
|
||||
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||
return this.queueService.deleteJob(id);
|
||||
}
|
||||
|
||||
@Get('job/:id/execute')
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async executeJob(@Param('id') id: string): Promise<void> {
|
||||
return this.queueService.executeJob(id);
|
||||
}
|
||||
}
|
||||
|
@ -32,6 +32,10 @@ export class QueueService {
|
||||
}
|
||||
}
|
||||
|
||||
public async executeJob(aId: string) {
|
||||
return (await this.dataGatheringQueue.getJob(aId))?.promote();
|
||||
}
|
||||
|
||||
public async getJobs({
|
||||
limit = 1000,
|
||||
status = QUEUE_JOB_STATUS_LIST
|
||||
@ -54,6 +58,7 @@ export class QueueService {
|
||||
finishedOn: job.finishedOn,
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
opts: job.opts,
|
||||
stacktrace: job.stacktrace,
|
||||
state: await job.getState(),
|
||||
timestamp: job.timestamp
|
||||
|
@ -5,7 +5,8 @@ import {
|
||||
IsISO4217CurrencyCode,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString
|
||||
IsString,
|
||||
IsUrl
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateAssetProfileDto {
|
||||
@ -46,4 +47,11 @@ export class UpdateAssetProfileDto {
|
||||
symbolMapping?: {
|
||||
[dataProvider: string]: string;
|
||||
};
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl({
|
||||
protocols: ['https'],
|
||||
require_protocol: true
|
||||
})
|
||||
url?: string;
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { EventsModule } from '@ghostfolio/api/events/events.module';
|
||||
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';
|
||||
@ -14,6 +15,7 @@ import {
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
@ -44,6 +46,7 @@ import { TagModule } from './tag/tag.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
@Module({
|
||||
controllers: [AppController],
|
||||
imports: [
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
@ -64,6 +67,8 @@ import { UserModule } from './user/user.module';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
EventsModule,
|
||||
ExchangeRateModule,
|
||||
ExchangeRateDataModule,
|
||||
ExportModule,
|
||||
@ -109,7 +114,6 @@ import { UserModule } from './user/user.module';
|
||||
TwitterBotModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [CronService]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
@ -2,10 +2,12 @@ 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 { hasRole } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
|
||||
if (user) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (hasRole(user, 'INACTIVE')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
const country =
|
||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||
|
||||
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
|
||||
return user;
|
||||
} else {
|
||||
throw '';
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||
StatusCodes.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new UnauthorizedException('unauthorized', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,7 @@ 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,
|
||||
@ -19,6 +20,7 @@ import {
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
@ -106,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
|
||||
|
@ -13,7 +13,8 @@ import {
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
calculateBenchmarkTrend,
|
||||
parseDate
|
||||
parseDate,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Benchmark,
|
||||
@ -26,8 +27,14 @@ 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';
|
||||
|
||||
@ -110,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) {
|
||||
@ -163,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
|
||||
);
|
||||
}
|
||||
|
||||
@ -206,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: {
|
||||
@ -230,7 +252,7 @@ export class BenchmarkService {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: {
|
||||
gte: startDate
|
||||
in: dates
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -264,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)
|
||||
@ -297,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 =
|
||||
@ -314,7 +326,7 @@ export class BenchmarkService {
|
||||
: 1;
|
||||
|
||||
marketData.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
date: format(endDate, DATE_FORMAT),
|
||||
value:
|
||||
this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
|
@ -95,7 +95,10 @@ export class ExportService {
|
||||
: SymbolProfile.symbol
|
||||
};
|
||||
}
|
||||
)
|
||||
),
|
||||
user: {
|
||||
settings: { currency: userCurrency }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,10 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getAssetProfileIdentifier,
|
||||
@ -27,7 +31,7 @@ import {
|
||||
|
||||
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';
|
||||
@ -112,12 +116,13 @@ export class ImportService {
|
||||
accountId: Account?.id,
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
currency: undefined,
|
||||
createdAt: undefined,
|
||||
fee: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
isDraft: false,
|
||||
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||
SymbolProfile: assetProfile,
|
||||
symbolProfileId: assetProfile.id,
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: marketPrice,
|
||||
@ -261,6 +266,7 @@ export class ImportService {
|
||||
{
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
error,
|
||||
fee,
|
||||
@ -285,7 +291,6 @@ export class ImportService {
|
||||
assetSubClass,
|
||||
countries,
|
||||
createdAt,
|
||||
currency,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
@ -342,6 +347,7 @@ export class ImportService {
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
@ -357,7 +363,6 @@ export class ImportService {
|
||||
assetSubClass,
|
||||
countries,
|
||||
createdAt,
|
||||
currency,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
@ -371,6 +376,7 @@ export class ImportService {
|
||||
symbolMapping,
|
||||
updatedAt,
|
||||
url,
|
||||
currency: assetProfile.currency,
|
||||
comment: assetProfile.comment
|
||||
},
|
||||
Account: validatedAccount,
|
||||
@ -394,9 +400,9 @@ export class ImportService {
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
symbol,
|
||||
currency: assetProfile.currency
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
@ -410,6 +416,11 @@ export class ImportService {
|
||||
User: { connect: { id: user.id } },
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
if (order.SymbolProfile?.symbol) {
|
||||
// Update symbol that may have been assigned in createOrder()
|
||||
assetProfile.symbol = order.SymbolProfile.symbol;
|
||||
}
|
||||
}
|
||||
|
||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||
@ -420,14 +431,14 @@ export class ImportService {
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
currency,
|
||||
assetProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
// @ts-ignore
|
||||
SymbolProfile: assetProfile,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
currency,
|
||||
assetProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
@ -446,15 +457,16 @@ export class ImportService {
|
||||
});
|
||||
});
|
||||
|
||||
this.dataGatheringService.gatherSymbols(
|
||||
uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||
this.dataGatheringService.gatherSymbols({
|
||||
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||
return {
|
||||
date,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
};
|
||||
})
|
||||
);
|
||||
}),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
});
|
||||
}
|
||||
|
||||
return activities;
|
||||
@ -521,22 +533,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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -42,6 +42,10 @@ export class CreateOrderDto {
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsOptional()
|
||||
customCurrency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(DataSource, { each: true })
|
||||
dataSource?: DataSource;
|
||||
|
@ -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,14 +1,18 @@
|
||||
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';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
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,
|
||||
@ -56,15 +60,15 @@ export class OrderController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HasPermission(permissions.deleteOrder)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||
const order = await this.orderService.order({ id });
|
||||
const order = await this.orderService.order({
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
|
||||
!order ||
|
||||
order.userId !== this.request.user.id
|
||||
) {
|
||||
if (!order) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -84,12 +88,20 @@ export class OrderController {
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange?: DateRange,
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('take') take?: number
|
||||
): Promise<Activities> {
|
||||
let endDate: Date;
|
||||
let startDate: Date;
|
||||
|
||||
if (dateRange) {
|
||||
({ endDate, startDate } = getInterval(dateRange));
|
||||
}
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
@ -101,9 +113,11 @@ export class OrderController {
|
||||
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,
|
||||
@ -120,13 +134,22 @@ export class OrderController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||
const currency = data.currency;
|
||||
const customCurrency = data.customCurrency;
|
||||
|
||||
if (customCurrency) {
|
||||
data.currency = customCurrency;
|
||||
|
||||
delete data.customCurrency;
|
||||
}
|
||||
|
||||
const order = await this.orderService.createOrder({
|
||||
...data,
|
||||
date: parseISO(data.date),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency: data.currency,
|
||||
currency,
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
},
|
||||
@ -145,13 +168,16 @@ export class OrderController {
|
||||
if (data.dataSource && !order.isDraft) {
|
||||
// Gather symbol data in the background, if data source is set
|
||||
// (not MANUAL) and not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
date: order.date,
|
||||
symbol: data.symbol
|
||||
}
|
||||
]);
|
||||
this.dataGatheringService.gatherSymbols({
|
||||
dataGatheringItems: [
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
date: order.date,
|
||||
symbol: data.symbol
|
||||
}
|
||||
],
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
});
|
||||
}
|
||||
|
||||
return order;
|
||||
@ -176,8 +202,16 @@ export class OrderController {
|
||||
const date = parseISO(data.date);
|
||||
|
||||
const accountId = data.accountId;
|
||||
const customCurrency = data.customCurrency;
|
||||
|
||||
delete data.accountId;
|
||||
|
||||
if (customCurrency) {
|
||||
data.currency = customCurrency;
|
||||
|
||||
delete data.customCurrency;
|
||||
}
|
||||
|
||||
return this.orderService.updateOrder({
|
||||
data: {
|
||||
...data,
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
@ -12,6 +14,7 @@ import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
@ -21,9 +24,9 @@ import {
|
||||
Tag,
|
||||
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';
|
||||
@ -33,6 +36,7 @@ export class OrderService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
@ -65,7 +69,6 @@ export class OrderService {
|
||||
}
|
||||
|
||||
const accountId = data.accountId;
|
||||
let currency = data.currency;
|
||||
const tags = data.tags ?? [];
|
||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||
const userId = data.userId;
|
||||
@ -73,7 +76,6 @@ export class OrderService {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
const dataSource: DataSource = 'MANUAL';
|
||||
const id = uuidv4();
|
||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||
@ -81,7 +83,6 @@ export class OrderService {
|
||||
data.id = id;
|
||||
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
||||
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
||||
@ -103,7 +104,8 @@ export class OrderService {
|
||||
jobId: getAssetProfileIdentifier({
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
})
|
||||
}),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -116,7 +118,6 @@ export class OrderService {
|
||||
delete data.comment;
|
||||
}
|
||||
|
||||
delete data.currency;
|
||||
delete data.dataSource;
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
@ -139,7 +140,8 @@ export class OrderService {
|
||||
return { id };
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
include: { SymbolProfile: true }
|
||||
});
|
||||
|
||||
if (updateAccountBalance === true) {
|
||||
@ -148,19 +150,26 @@ export class OrderService {
|
||||
.plus(data.fee)
|
||||
.toNumber();
|
||||
|
||||
if (data.type === 'BUY') {
|
||||
if (['BUY', 'FEE'].includes(data.type)) {
|
||||
amount = new Big(amount).mul(-1).toNumber();
|
||||
}
|
||||
|
||||
await this.accountService.updateAccountBalance({
|
||||
accountId,
|
||||
amount,
|
||||
currency,
|
||||
userId,
|
||||
currency: data.SymbolProfile.connectOrCreate.create.currency,
|
||||
date: data.date as Date
|
||||
});
|
||||
}
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: order.userId
|
||||
})
|
||||
);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
@ -175,6 +184,13 @@ export class OrderService {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: order.userId
|
||||
})
|
||||
);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
@ -183,6 +199,13 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: <string>where.userId
|
||||
})
|
||||
);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
@ -198,22 +221,26 @@ export class OrderService {
|
||||
}
|
||||
|
||||
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?: ActivityType[];
|
||||
userCurrency: string;
|
||||
@ -225,6 +252,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,
|
||||
@ -320,7 +359,32 @@ export class OrderService {
|
||||
this.prismaService.order.count({ where })
|
||||
]);
|
||||
|
||||
const uniqueAssets = uniqBy(
|
||||
orders.map(({ SymbolProfile }) => {
|
||||
return {
|
||||
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 {
|
||||
@ -332,6 +396,7 @@ export class OrderService {
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
SymbolProfile: assetProfile,
|
||||
// TODO: Use exchange rate of date
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
@ -388,19 +453,22 @@ export class OrderService {
|
||||
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
||||
}
|
||||
]);
|
||||
this.dataGatheringService.gatherSymbols({
|
||||
dataGatheringItems: [
|
||||
{
|
||||
dataSource:
|
||||
data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
||||
}
|
||||
],
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
delete data.assetClass;
|
||||
delete data.assetSubClass;
|
||||
delete data.currency;
|
||||
delete data.dataSource;
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
@ -411,7 +479,7 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
return this.prismaService.order.update({
|
||||
const order = await this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
isDraft,
|
||||
@ -423,6 +491,15 @@ export class OrderService {
|
||||
},
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: order.userId
|
||||
})
|
||||
);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
private async orders(params: {
|
||||
|
@ -41,6 +41,10 @@ export class UpdateOrderDto {
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsOptional()
|
||||
customCurrency?: string;
|
||||
|
||||
@IsString()
|
||||
dataSource: DataSource;
|
||||
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { IsString, IsUrl } from 'class-validator';
|
||||
|
||||
export class CreatePlatformDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsUrl({
|
||||
protocols: ['https'],
|
||||
require_protocol: true
|
||||
})
|
||||
url: string;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { IsString, IsUrl } from 'class-validator';
|
||||
|
||||
export class UpdatePlatformDto {
|
||||
@IsString()
|
||||
@ -7,6 +7,9 @@ export class UpdatePlatformDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsUrl({
|
||||
protocols: ['https'],
|
||||
require_protocol: true
|
||||
})
|
||||
url: string;
|
||||
}
|
||||
|
@ -0,0 +1,33 @@
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
||||
protected calculateOverallPerformance(
|
||||
positions: TimelinePosition[]
|
||||
): PortfolioSnapshot {
|
||||
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,30 @@
|
||||
export const activityDummyData = {
|
||||
accountId: undefined,
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
createdAt: new Date(),
|
||||
currency: undefined,
|
||||
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
|
||||
};
|
||||
|
||||
export const userDummyData = {
|
||||
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||||
};
|
@ -0,0 +1,81 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
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 configurationService: ConfigurationService,
|
||||
private readonly currentRateService: CurrentRateService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {}
|
||||
|
||||
public createCalculator({
|
||||
accountBalanceItems = [],
|
||||
activities,
|
||||
calculationType,
|
||||
currency,
|
||||
dateRange = 'max',
|
||||
hasFilters,
|
||||
isExperimentalFeatures = false,
|
||||
userId
|
||||
}: {
|
||||
accountBalanceItems?: HistoricalDataItem[];
|
||||
activities: Activity[];
|
||||
calculationType: PerformanceCalculationType;
|
||||
currency: string;
|
||||
dateRange?: DateRange;
|
||||
hasFilters: boolean;
|
||||
isExperimentalFeatures?: boolean;
|
||||
userId: string;
|
||||
}): PortfolioCalculator {
|
||||
const useCache = !hasFilters && isExperimentalFeatures;
|
||||
|
||||
switch (calculationType) {
|
||||
case PerformanceCalculationType.MWR:
|
||||
return new MWRPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
dateRange,
|
||||
useCache,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
case PerformanceCalculationType.TWR:
|
||||
return new TWRPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
currentRateService: this.currentRateService,
|
||||
dateRange,
|
||||
useCache,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
default:
|
||||
throw new Error('Invalid calculation type');
|
||||
}
|
||||
}
|
||||
}
|
1100
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
Normal file
1100
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,22 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
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 {
|
||||
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -29,62 +54,84 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell in two activities', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-22',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.55),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(142.9)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.65),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(136.6)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(136.6)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const 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',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2021-11-22')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2021-11-22')
|
||||
);
|
||||
|
||||
@ -97,7 +144,7 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('-12.6'),
|
||||
@ -140,6 +187,7 @@ describe('PortfolioCalculator', () => {
|
||||
marketPriceInBaseCurrency: 148.9,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'BALN.SW',
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('285.80000000000000396627'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'285.80000000000000396627'
|
||||
@ -148,8 +196,12 @@ describe('PortfolioCalculator', () => {
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('3.2'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('0'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('0')
|
||||
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
@ -1,11 +1,22 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
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 {
|
||||
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-22',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.55),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(142.9)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.65),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(136.6)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const 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: 2,
|
||||
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',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2021-11-22')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2021-11-22')
|
||||
);
|
||||
|
||||
@ -86,7 +129,7 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('-12.6'),
|
||||
@ -129,14 +172,19 @@ describe('PortfolioCalculator', () => {
|
||||
marketPriceInBaseCurrency: 148.9,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'BALN.SW',
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('285.8'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('3.2'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('0'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('0')
|
||||
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
@ -1,11 +1,22 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
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 {
|
||||
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -29,40 +54,54 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.55),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(136.6)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-11-30'),
|
||||
fee: 1.55,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
name: 'Bâloise Holding AG',
|
||||
symbol: 'BALN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 136.6
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2021-11-30')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2021-11-30')
|
||||
);
|
||||
|
||||
@ -75,7 +114,7 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
currentValueInBaseCurrency: new Big('297.8'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('24.6'),
|
||||
@ -118,14 +157,19 @@ describe('PortfolioCalculator', () => {
|
||||
marketPriceInBaseCurrency: 148.9,
|
||||
quantity: new Big('2'),
|
||||
symbol: 'BALN.SW',
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('273.2'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('297.8')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('1.55'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('273.2'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('273.2')
|
||||
totalInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
@ -1,12 +1,23 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||
import { 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 {
|
||||
@ -17,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
@ -30,10 +50,15 @@ jest.mock(
|
||||
);
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -42,51 +67,69 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BTCUSD buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
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)
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
||||
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2015-01-01'),
|
||||
fee: 0,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
name: 'Bitcoin USD',
|
||||
symbol: 'BTCUSD'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 320.43
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2017-12-31'),
|
||||
fee: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
name: 'Bitcoin USD',
|
||||
symbol: 'BTCUSD'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 14156.4
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2015-01-01')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2015-01-01')
|
||||
);
|
||||
|
||||
@ -99,7 +142,7 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
currentValueInBaseCurrency: new Big('13298.425356'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27172.74'),
|
||||
@ -146,7 +189,7 @@ describe('PortfolioCalculator', () => {
|
||||
),
|
||||
quantity: new Big('1'),
|
||||
symbol: 'BTCUSD',
|
||||
tags: undefined,
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('640.56763686131386861314'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'636.79469348020066587024'
|
||||
@ -155,8 +198,12 @@ describe('PortfolioCalculator', () => {
|
||||
valueInBaseCurrency: new Big('13298.425356')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('0'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('320.43'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957')
|
||||
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
@ -0,0 +1,157 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
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;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('compute portfolio snapshot', () => {
|
||||
it.only('with fee activity', async () => {
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2021-09-01'),
|
||||
fee: 49,
|
||||
quantity: 0,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
dataSource: 'MANUAL',
|
||||
name: 'Account Opening Fee',
|
||||
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
|
||||
},
|
||||
type: 'FEE',
|
||||
unitPrice: 0
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'USD',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2021-11-30')
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('0'),
|
||||
grossPerformancePercentage: new Big('0'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||
grossPerformanceWithCurrencyEffect: new Big('0'),
|
||||
hasErrors: true,
|
||||
netPerformance: new Big('0'),
|
||||
netPerformancePercentage: new Big('0'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||
netPerformanceWithCurrencyEffect: new Big('0'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'USD',
|
||||
dataSource: 'MANUAL',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('49'),
|
||||
firstBuyDate: '2021-09-01',
|
||||
grossPerformance: null,
|
||||
grossPerformancePercentage: null,
|
||||
grossPerformancePercentageWithCurrencyEffect: null,
|
||||
grossPerformanceWithCurrencyEffect: null,
|
||||
investment: new Big('0'),
|
||||
investmentWithCurrencyEffect: new Big('0'),
|
||||
marketPrice: null,
|
||||
marketPriceInBaseCurrency: 0,
|
||||
netPerformance: null,
|
||||
netPerformancePercentage: null,
|
||||
netPerformancePercentageWithCurrencyEffect: null,
|
||||
netPerformanceWithCurrencyEffect: null,
|
||||
quantity: new Big('0'),
|
||||
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('0'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('49'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('0'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1,12 +1,23 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||
import { 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 {
|
||||
@ -17,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
@ -30,10 +50,15 @@ jest.mock(
|
||||
);
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -42,40 +67,54 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with GOOGL buy', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
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)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2023-01-03'),
|
||||
fee: 1,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
name: 'Alphabet Inc.',
|
||||
symbol: 'GOOGL'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 89.12
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2023-01-03')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2023-01-03')
|
||||
);
|
||||
|
||||
@ -88,7 +127,7 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
currentValueInBaseCurrency: new Big('103.10483'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27.33'),
|
||||
@ -131,15 +170,19 @@ describe('PortfolioCalculator', () => {
|
||||
marketPriceInBaseCurrency: 103.10483,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'GOOGL',
|
||||
tags: undefined,
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('89.12'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('103.10483')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('1'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('89.12'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('82.329056')
|
||||
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
@ -0,0 +1,157 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
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;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('compute portfolio snapshot', () => {
|
||||
it.only('with item activity', async () => {
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-01-31').getTime());
|
||||
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-01-01'),
|
||||
fee: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
dataSource: 'MANUAL',
|
||||
name: 'Penthouse Apartment',
|
||||
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
|
||||
},
|
||||
type: 'ITEM',
|
||||
unitPrice: 500000
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'USD',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2022-01-01')
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('0'),
|
||||
grossPerformancePercentage: new Big('0'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||
grossPerformanceWithCurrencyEffect: new Big('0'),
|
||||
hasErrors: true,
|
||||
netPerformance: new Big('0'),
|
||||
netPerformancePercentage: new Big('0'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||
netPerformanceWithCurrencyEffect: new Big('0'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('500000'),
|
||||
currency: 'USD',
|
||||
dataSource: 'MANUAL',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2022-01-01',
|
||||
grossPerformance: null,
|
||||
grossPerformancePercentage: null,
|
||||
grossPerformancePercentageWithCurrencyEffect: null,
|
||||
grossPerformanceWithCurrencyEffect: null,
|
||||
investment: new Big('0'),
|
||||
investmentWithCurrencyEffect: new Big('0'),
|
||||
marketPrice: null,
|
||||
marketPriceInBaseCurrency: 500000,
|
||||
netPerformance: null,
|
||||
netPerformancePercentage: null,
|
||||
netPerformancePercentageWithCurrencyEffect: null,
|
||||
netPerformanceWithCurrencyEffect: null,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('0'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('0'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('0'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,108 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
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;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('compute portfolio snapshot', () => {
|
||||
it.only('with liability activity', async () => {
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-01-31').getTime());
|
||||
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2023-01-01'), // Date in future
|
||||
fee: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'USD',
|
||||
dataSource: 'MANUAL',
|
||||
name: 'Loan',
|
||||
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
|
||||
},
|
||||
type: 'LIABILITY',
|
||||
unitPrice: 3000
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'USD',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
const liabilitiesInBaseCurrency =
|
||||
await portfolioCalculator.getLiabilitiesInBaseCurrency();
|
||||
|
||||
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,165 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||
import { 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/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
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 configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with MSFT buy', async () => {
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||
|
||||
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',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2023-07-10')
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(portfolioSnapshot).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: [],
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('19'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('298.58'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('298.58'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,124 @@
|
||||
import { userDummyData } 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
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 { subDays } from 'date-fns';
|
||||
|
||||
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/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it('with no orders', async () => {
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities: [],
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const start = subDays(new Date(Date.now()), 10);
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({ start });
|
||||
|
||||
const portfolioSnapshot =
|
||||
await portfolioCalculator.computeSnapshot(start);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
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: [],
|
||||
totalFeesWithCurrencyEffect: new Big('0'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big(0),
|
||||
totalInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{
|
||||
date: '2021-12-01',
|
||||
investment: 0
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,11 +1,22 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
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 {
|
||||
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2022-03-07',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.3),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(75.8)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2022-04-08',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(2.95),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(1),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(85.73)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-03-07'),
|
||||
fee: 1.3,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
name: 'Novartis AG',
|
||||
symbol: 'NOVN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 75.8
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-04-08'),
|
||||
fee: 2.95,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
name: 'Novartis AG',
|
||||
symbol: 'NOVN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 85.73
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2022-03-07')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
@ -86,7 +129,7 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
currentValueInBaseCurrency: new Big('87.8'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('21.93'),
|
||||
@ -129,6 +172,7 @@ describe('PortfolioCalculator', () => {
|
||||
marketPriceInBaseCurrency: 87.8,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'NOVN.SW',
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('145.10285714285714285714'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'145.10285714285714285714'
|
||||
@ -137,8 +181,12 @@ describe('PortfolioCalculator', () => {
|
||||
valueInBaseCurrency: new Big('87.8')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('4.25'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('75.80'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('75.80')
|
||||
totalInvestmentWithCurrencyEffect: new Big('75.80'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
@ -1,11 +1,22 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
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 {
|
||||
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with NOVN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
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)
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-03-07'),
|
||||
fee: 0,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
name: 'Novartis AG',
|
||||
symbol: 'NOVN.SW'
|
||||
},
|
||||
type: 'BUY',
|
||||
unitPrice: 75.8
|
||||
},
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-04-08'),
|
||||
fee: 0,
|
||||
quantity: 2,
|
||||
SymbolProfile: {
|
||||
...symbolProfileDummyData,
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
name: 'Novartis AG',
|
||||
symbol: 'NOVN.SW'
|
||||
},
|
||||
type: 'SELL',
|
||||
unitPrice: 85.73
|
||||
}
|
||||
];
|
||||
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2022-03-07')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
@ -93,6 +136,8 @@ describe('PortfolioCalculator', () => {
|
||||
netPerformanceInPercentage: 0,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 0,
|
||||
netPerformanceWithCurrencyEffect: 0,
|
||||
netWorth: 151.6,
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 151.6,
|
||||
totalInvestmentValueWithCurrencyEffect: 151.6,
|
||||
value: 151.6,
|
||||
@ -106,13 +151,15 @@ describe('PortfolioCalculator', () => {
|
||||
netPerformanceInPercentage: 13.100263852242744,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
|
||||
netPerformanceWithCurrencyEffect: 19.86,
|
||||
netWorth: 0,
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 0,
|
||||
totalInvestmentValueWithCurrencyEffect: 0,
|
||||
value: 0,
|
||||
valueWithCurrencyEffect: 0
|
||||
});
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('19.86'),
|
||||
@ -155,14 +202,19 @@ describe('PortfolioCalculator', () => {
|
||||
marketPriceInBaseCurrency: 87.8,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'NOVN.SW',
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('151.6'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('0'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('0'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('0')
|
||||
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
@ -0,0 +1,37 @@
|
||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
test.skip('Skip empty test', () => 1);
|
||||
});
|
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
function mockGetValue(symbol: string, date: Date) {
|
||||
switch (symbol) {
|
||||
case '55196015-1365-4560-aa60-8751ae6d18f8':
|
||||
if (isSameDay(parseDate('2022-01-31'), date)) {
|
||||
return { marketPrice: 3000 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'BALN.SW':
|
||||
if (isSameDay(parseDate('2021-11-12'), date)) {
|
||||
return { marketPrice: 146 };
|
||||
|
@ -34,7 +34,7 @@ export class CurrentRateService {
|
||||
}: 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));
|
||||
@ -43,7 +43,7 @@ export class CurrentRateService {
|
||||
const quoteErrors: ResponseError['errors'] = [];
|
||||
const today = resetHours(new Date());
|
||||
|
||||
if (includeToday) {
|
||||
if (includesToday) {
|
||||
promises.push(
|
||||
this.dataProviderService
|
||||
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
|
||||
|
@ -1,19 +0,0 @@
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface CurrentPositions extends ResponseError {
|
||||
currentValueInBaseCurrency: Big;
|
||||
grossPerformance: Big;
|
||||
grossPerformanceWithCurrencyEffect: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
grossPerformancePercentageWithCurrencyEffect: Big;
|
||||
netAnnualizedPerformance?: Big;
|
||||
netAnnualizedPerformanceWithCurrencyEffect?: Big;
|
||||
netPerformance: Big;
|
||||
netPerformanceWithCurrencyEffect: Big;
|
||||
netPerformancePercentage: Big;
|
||||
netPerformancePercentageWithCurrencyEffect: Big;
|
||||
positions: TimelinePosition[];
|
||||
totalInvestment: Big;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
import { PortfolioOrder } from './portfolio-order.interface';
|
||||
|
@ -1,15 +1,12 @@
|
||||
import { DataSource, Tag, Type as ActivityType } 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: ActivityType;
|
||||
SymbolProfile: Pick<
|
||||
Activity['SymbolProfile'],
|
||||
'currency' | 'dataSource' | 'name' | 'symbol'
|
||||
>;
|
||||
unitPrice: Big;
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
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 { Account, Tag } from '@prisma/client';
|
||||
|
||||
@ -12,6 +12,8 @@ export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
dataProviderInfo: DataProviderInfo;
|
||||
dividendInBaseCurrency: number;
|
||||
dividendYieldPercent: number;
|
||||
dividendYieldPercentWithCurrencyEffect: number;
|
||||
feeInBaseCurrency: number;
|
||||
firstBuyDate: string;
|
||||
grossPerformance: number;
|
||||
@ -27,16 +29,10 @@ export interface PortfolioPositionDetail {
|
||||
netPerformancePercent: number;
|
||||
netPerformancePercentWithCurrencyEffect: number;
|
||||
netPerformanceWithCurrencyEffect: number;
|
||||
orders: OrderWithAccount[];
|
||||
orders: Activity[];
|
||||
quantity: number;
|
||||
SymbolProfile: EnhancedSymbolProfile;
|
||||
tags: Tag[];
|
||||
transactionCount: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface HistoricalDataContainer {
|
||||
isAllTimeHigh: boolean;
|
||||
isAllTimeLow: boolean;
|
||||
items: HistoricalDataItem[];
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { DataSource, Tag } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
export interface TransactionPointSymbol {
|
||||
averagePrice: Big;
|
||||
|
@ -1,6 +1,12 @@
|
||||
import { Big } from 'big.js';
|
||||
|
||||
import { TransactionPointSymbol } from './transaction-point-symbol.interface';
|
||||
|
||||
export interface TransactionPoint {
|
||||
date: string;
|
||||
fees: Big;
|
||||
interest: Big;
|
||||
items: TransactionPointSymbol[];
|
||||
liabilities: Big;
|
||||
valuables: Big;
|
||||
}
|
||||
|
@ -1,118 +0,0 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with MSFT buy', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'USD',
|
||||
orders: [
|
||||
{
|
||||
currency: 'USD',
|
||||
date: '2021-09-16',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(19),
|
||||
name: 'Microsoft Inc.',
|
||||
quantity: new Big(1),
|
||||
symbol: 'MSFT',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(298.58)
|
||||
},
|
||||
{
|
||||
currency: 'USD',
|
||||
date: '2021-11-16',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Microsoft Inc.',
|
||||
quantity: new Big(1),
|
||||
symbol: 'MSFT',
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: new Big(0.62)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
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,86 +0,0 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
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';
|
||||
|
||||
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;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it('with no orders', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: []
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: new Date()
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
new Date()
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
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)
|
||||
});
|
||||
|
||||
expect(investments).toEqual([]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
@ -6,6 +6,7 @@ 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';
|
||||
@ -46,7 +47,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import Big from 'big.js';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
@ -77,9 +79,9 @@ export class PortfolioController {
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withLiabilities') withLiabilitiesParam = 'false'
|
||||
@Query('withMarkets') withMarketsParam = 'false'
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
const withLiabilities = withLiabilitiesParam === 'true';
|
||||
const withMarkets = withMarketsParam === 'true';
|
||||
|
||||
let hasDetails = true;
|
||||
let hasError = false;
|
||||
@ -104,7 +106,7 @@ export class PortfolioController {
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
withLiabilities,
|
||||
withMarkets,
|
||||
userId: this.request.user.id,
|
||||
withSummary: true
|
||||
});
|
||||
@ -127,14 +129,19 @@ export class PortfolioController {
|
||||
|
||||
const totalValue = Object.values(holdings)
|
||||
.filter(({ assetClass, assetSubClass }) => {
|
||||
return assetClass !== 'CASH' && assetSubClass !== 'CASH';
|
||||
return (
|
||||
assetClass !== AssetClass.LIQUIDITY &&
|
||||
assetSubClass !== AssetSubClass.CASH
|
||||
);
|
||||
})
|
||||
.map(({ valueInBaseCurrency }) => {
|
||||
return valueInBaseCurrency;
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
.reduce((a, b) => {
|
||||
return a + b;
|
||||
}, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
for (const [, portfolioPosition] of Object.entries(holdings)) {
|
||||
portfolioPosition.investment =
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
portfolioPosition.valueInPercentage =
|
||||
@ -184,11 +191,11 @@ export class PortfolioController {
|
||||
holdings[symbol] = {
|
||||
...portfolioPosition,
|
||||
assetClass:
|
||||
hasDetails || portfolioPosition.assetClass === 'CASH'
|
||||
hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
|
||||
? portfolioPosition.assetClass
|
||||
: undefined,
|
||||
assetSubClass:
|
||||
hasDetails || portfolioPosition.assetSubClass === 'CASH'
|
||||
hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
|
||||
? portfolioPosition.assetSubClass
|
||||
: undefined,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
@ -236,8 +243,12 @@ export class PortfolioController {
|
||||
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,
|
||||
startDate,
|
||||
userCurrency,
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
types: ['DIVIDEND']
|
||||
@ -245,7 +256,6 @@ export class PortfolioController {
|
||||
|
||||
let dividends = await this.portfolioService.getDividends({
|
||||
activities,
|
||||
dateRange,
|
||||
groupBy
|
||||
});
|
||||
|
||||
@ -284,17 +294,21 @@ export class PortfolioController {
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('holdingType') filterByHoldingType?: string,
|
||||
@Query('query') filterBySearchQuery?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioHoldingsResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByHoldingType,
|
||||
filterBySearchQuery,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
@ -380,11 +394,9 @@ export class PortfolioController {
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false',
|
||||
@Query('withItems') withItemsParam = 'false'
|
||||
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const withExcludedAccounts = withExcludedAccountsParam === 'true';
|
||||
const withItems = withItemsParam === 'true';
|
||||
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
@ -403,7 +415,6 @@ export class PortfolioController {
|
||||
filters,
|
||||
impersonationId,
|
||||
withExcludedAccounts,
|
||||
withItems,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
@ -416,6 +427,7 @@ export class PortfolioController {
|
||||
({
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformanceInPercentageWithCurrencyEffect,
|
||||
netWorth,
|
||||
totalInvestment,
|
||||
value
|
||||
@ -423,6 +435,7 @@ export class PortfolioController {
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformanceInPercentageWithCurrencyEffect,
|
||||
netWorthInPercentage:
|
||||
performanceInformation.performance.currentNetWorth === 0
|
||||
? 0
|
||||
@ -528,7 +541,8 @@ export class PortfolioController {
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||
impersonationId: access.userId,
|
||||
userId: user.id
|
||||
userId: user.id,
|
||||
withMarkets: true
|
||||
});
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
|
@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
@ -15,6 +16,7 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym
|
||||
|
||||
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';
|
||||
@ -34,6 +36,7 @@ import { RulesService } from './rules.service';
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
],
|
||||
@ -41,6 +44,7 @@ import { RulesService } from './rules.service';
|
||||
AccountBalanceService,
|
||||
AccountService,
|
||||
CurrentRateService,
|
||||
PortfolioCalculatorFactory,
|
||||
PortfolioService,
|
||||
RulesService
|
||||
]
|
||||
|
@ -1,18 +1,19 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
import Big from 'big.js';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
describe('PortfolioService', () => {
|
||||
let portfolioService: PortfolioService;
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
beforeAll(async () => {
|
||||
portfolioService = new PortfolioService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
@ -21,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)
|
||||
@ -39,7 +33,7 @@ describe('PortfolioCalculator', () => {
|
||||
).toEqual(0);
|
||||
|
||||
expect(
|
||||
portfolioCalculator
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 0,
|
||||
netPerformancePercent: new Big(0)
|
||||
@ -51,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)
|
||||
@ -60,7 +54,7 @@ describe('PortfolioCalculator', () => {
|
||||
).toBeCloseTo(0.729705);
|
||||
|
||||
expect(
|
||||
portfolioCalculator
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 365, // 1 year
|
||||
netPerformancePercent: new Big(0.05)
|
||||
@ -72,7 +66,7 @@ describe('PortfolioCalculator', () => {
|
||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||
*/
|
||||
expect(
|
||||
portfolioCalculator
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 575, // > 1 year
|
||||
netPerformancePercent: new Big(0.2374)
|
File diff suppressed because it is too large
Load Diff
@ -17,8 +17,16 @@ export class RulesService {
|
||||
return rule.getSettings(aUserSettings)?.isActive;
|
||||
})
|
||||
.map((rule) => {
|
||||
const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings));
|
||||
return { ...evaluationResult, name: rule.getName() };
|
||||
const { evaluation, value } = rule.evaluate(
|
||||
rule.getSettings(aUserSettings)
|
||||
);
|
||||
|
||||
return {
|
||||
evaluation,
|
||||
value,
|
||||
key: rule.getKey(),
|
||||
name: rule.getName()
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
13
apps/api/src/app/redis-cache/redis-cache.service.mock.ts
Normal file
13
apps/api/src/app/redis-cache/redis-cache.service.mock.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { RedisCacheService } from './redis-cache.service';
|
||||
|
||||
export const RedisCacheServiceMock = {
|
||||
get: (key: string): Promise<string> => {
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
getPortfolioSnapshotKey: (userId: string): string => {
|
||||
return `portfolio-snapshot-${userId}`;
|
||||
},
|
||||
set: (key: string, value: string, ttlInSeconds?: number): Promise<string> => {
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
};
|
@ -21,7 +21,11 @@ export class RedisCacheService {
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string> {
|
||||
return await this.cache.get(key);
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
public getPortfolioSnapshotKey({ userId }: { userId: string }) {
|
||||
return `portfolio-snapshot-${userId}`;
|
||||
}
|
||||
|
||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||
@ -29,15 +33,15 @@ export class RedisCacheService {
|
||||
}
|
||||
|
||||
public async remove(key: string) {
|
||||
await this.cache.del(key);
|
||||
return this.cache.del(key);
|
||||
}
|
||||
|
||||
public async reset() {
|
||||
await this.cache.reset();
|
||||
return this.cache.reset();
|
||||
}
|
||||
|
||||
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||
await this.cache.set(
|
||||
return this.cache.set(
|
||||
key,
|
||||
value,
|
||||
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||
|
@ -116,7 +116,7 @@ export class SubscriptionController {
|
||||
@Body() { couponId, priceId }: { couponId: string; priceId: string }
|
||||
) {
|
||||
try {
|
||||
return await this.subscriptionService.createCheckoutSession({
|
||||
return this.subscriptionService.createCheckoutSession({
|
||||
couponId,
|
||||
priceId,
|
||||
user: this.request.user
|
||||
|
@ -74,11 +74,21 @@ export class SymbolService {
|
||||
date = new Date(),
|
||||
symbol
|
||||
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[{ dataSource, symbol }],
|
||||
date,
|
||||
date
|
||||
);
|
||||
let historicalData: {
|
||||
[symbol: string]: {
|
||||
[date: string]: IDataProviderHistoricalResponse;
|
||||
};
|
||||
} = {
|
||||
[symbol]: {}
|
||||
};
|
||||
|
||||
try {
|
||||
historicalData = await this.dataProviderService.getHistoricalRaw({
|
||||
dataGatheringItems: [{ dataSource, symbol }],
|
||||
from: date,
|
||||
to: date
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
marketPrice:
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
IsOptional,
|
||||
IsString
|
||||
} from 'class-validator';
|
||||
import { eachYearOfInterval, format } from 'date-fns';
|
||||
|
||||
export class UpdateUserSettingDto {
|
||||
@IsNumber()
|
||||
@ -32,7 +33,20 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
colorScheme?: ColorScheme;
|
||||
|
||||
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd'])
|
||||
@IsIn(<DateRange[]>[
|
||||
'1d',
|
||||
'1y',
|
||||
'5y',
|
||||
'max',
|
||||
'mtd',
|
||||
'wtd',
|
||||
'ytd',
|
||||
...eachYearOfInterval({ end: new Date(), start: new Date(0) }).map(
|
||||
(date) => {
|
||||
return format(date, 'yyyy');
|
||||
}
|
||||
)
|
||||
])
|
||||
@IsOptional()
|
||||
dateRange?: DateRange;
|
||||
|
||||
|
@ -2,11 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
hasPermission,
|
||||
hasRole,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
@ -63,13 +59,6 @@ export class UserController {
|
||||
public async getUser(
|
||||
@Headers('accept-language') acceptLanguage: string
|
||||
): Promise<User> {
|
||||
if (hasRole(this.request.user, 'INACTIVE')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
return this.userService.getUser(
|
||||
this.request.user,
|
||||
acceptLanguage?.split(',')?.[0]
|
||||
@ -135,7 +124,7 @@ export class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSetting({
|
||||
return this.userService.updateUserSetting({
|
||||
userSettings,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
@ -25,6 +26,7 @@ import {
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Prisma, Role, User } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { sortBy, without } from 'lodash';
|
||||
@ -37,6 +39,7 @@ export class UserService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
@ -51,13 +54,22 @@ export class UserService {
|
||||
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
||||
aLocale = locale
|
||||
): Promise<IUser> {
|
||||
const access = await this.prismaService.access.findMany({
|
||||
include: {
|
||||
User: true
|
||||
},
|
||||
orderBy: { alias: 'asc' },
|
||||
where: { GranteeUser: { id } }
|
||||
});
|
||||
let [access, firstActivity, tags] = await Promise.all([
|
||||
this.prismaService.access.findMany({
|
||||
include: {
|
||||
User: true
|
||||
},
|
||||
orderBy: { alias: 'asc' },
|
||||
where: { GranteeUser: { id } }
|
||||
}),
|
||||
this.prismaService.order.findFirst({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
where: { userId: id }
|
||||
}),
|
||||
this.tagService.getByUser(id)
|
||||
]);
|
||||
|
||||
let systemMessage: SystemMessage;
|
||||
|
||||
@ -69,8 +81,6 @@ export class UserService {
|
||||
systemMessage = systemMessageProperty;
|
||||
}
|
||||
|
||||
let tags = await this.tagService.getByUser(id);
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
subscription.type === 'Basic'
|
||||
@ -91,6 +101,7 @@ export class UserService {
|
||||
};
|
||||
}),
|
||||
accounts: Account,
|
||||
dateOfFirstActivity: firstActivity?.date ?? new Date(),
|
||||
settings: {
|
||||
...(<UserSettings>Settings.settings),
|
||||
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
|
||||
@ -429,11 +440,9 @@ export class UserService {
|
||||
userId: string;
|
||||
userSettings: UserSettings;
|
||||
}) {
|
||||
const settings = userSettings as unknown as Prisma.JsonObject;
|
||||
|
||||
await this.prismaService.settings.upsert({
|
||||
const { settings } = await this.prismaService.settings.upsert({
|
||||
create: {
|
||||
settings,
|
||||
settings: userSettings as unknown as Prisma.JsonObject,
|
||||
User: {
|
||||
connect: {
|
||||
id: userId
|
||||
@ -441,25 +450,33 @@ export class UserService {
|
||||
}
|
||||
},
|
||||
update: {
|
||||
settings
|
||||
settings: userSettings as unknown as Prisma.JsonObject
|
||||
},
|
||||
where: {
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId
|
||||
})
|
||||
);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
private getRandomString(length: number) {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result.push(
|
||||
characters.charAt(Math.floor(Math.random() * characters.length))
|
||||
);
|
||||
const randomByte = bytes[i];
|
||||
result.push(characters[randomByte % characters.length]);
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@
|
||||
"LUNA2": "Terra",
|
||||
"SGB1": "Songbird",
|
||||
"SMURFCAT": "Real Smurf Cat",
|
||||
"TON11419": "Toncoin",
|
||||
"UNI1": "Uniswap",
|
||||
"UNI7083": "Uniswap",
|
||||
"UST": "TerraUSD"
|
||||
|
@ -1232,10 +1232,12 @@
|
||||
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1296,4 +1298,10 @@
|
||||
<loc>https://ghostfol.io/tr</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/zh</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
</urlset>
|
||||
|
11
apps/api/src/events/events.module.ts
Normal file
11
apps/api/src/events/events.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PortfolioChangedListener } from './portfolio-changed.listener';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
providers: [PortfolioChangedListener]
|
||||
})
|
||||
export class EventsModule {}
|
15
apps/api/src/events/portfolio-changed.event.ts
Normal file
15
apps/api/src/events/portfolio-changed.event.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export class PortfolioChangedEvent {
|
||||
private userId: string;
|
||||
|
||||
public constructor({ userId }: { userId: string }) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public static getName() {
|
||||
return 'portfolio.changed';
|
||||
}
|
||||
|
||||
public getUserId() {
|
||||
return this.userId;
|
||||
}
|
||||
}
|
25
apps/api/src/events/portfolio-changed.listener.ts
Normal file
25
apps/api/src/events/portfolio-changed.listener.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
|
||||
import { PortfolioChangedEvent } from './portfolio-changed.event';
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioChangedListener {
|
||||
public constructor(private readonly redisCacheService: RedisCacheService) {}
|
||||
|
||||
@OnEvent(PortfolioChangedEvent.getName())
|
||||
handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
|
||||
Logger.log(
|
||||
`Portfolio of user with id ${event.getUserId()} has changed`,
|
||||
'PortfolioChangedListener'
|
||||
);
|
||||
|
||||
this.redisCacheService.remove(
|
||||
this.redisCacheService.getPortfolioSnapshotKey({
|
||||
userId: event.getUserId()
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
import { cloneDeep, isArray, isObject } from 'lodash';
|
||||
|
||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||
|
@ -1,14 +1,25 @@
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
|
||||
import { Type as ActivityType } from '@prisma/client';
|
||||
import {
|
||||
endOfDay,
|
||||
max,
|
||||
subDays,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
subYears,
|
||||
endOfYear
|
||||
} from 'date-fns';
|
||||
|
||||
export function getFactor(activityType: ActivityType) {
|
||||
let factor: number;
|
||||
|
||||
switch (activityType) {
|
||||
case 'BUY':
|
||||
case 'ITEM':
|
||||
factor = 1;
|
||||
break;
|
||||
case 'LIABILITY':
|
||||
case 'SELL':
|
||||
factor = -1;
|
||||
break;
|
||||
@ -19,3 +30,61 @@ export function getFactor(activityType: ActivityType) {
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
export function getInterval(
|
||||
aDateRange: DateRange,
|
||||
portfolioStart = new Date(0)
|
||||
) {
|
||||
let endDate = endOfDay(new Date(Date.now()));
|
||||
let startDate = portfolioStart;
|
||||
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
startDate = max([
|
||||
startDate,
|
||||
subDays(resetHours(new Date(Date.now())), 1)
|
||||
]);
|
||||
break;
|
||||
case 'mtd':
|
||||
startDate = max([
|
||||
startDate,
|
||||
subDays(startOfMonth(resetHours(new Date(Date.now()))), 1)
|
||||
]);
|
||||
break;
|
||||
case 'wtd':
|
||||
startDate = max([
|
||||
startDate,
|
||||
subDays(
|
||||
startOfWeek(resetHours(new Date(Date.now())), { weekStartsOn: 1 }),
|
||||
1
|
||||
)
|
||||
]);
|
||||
break;
|
||||
case 'ytd':
|
||||
startDate = max([
|
||||
startDate,
|
||||
subDays(startOfYear(resetHours(new Date(Date.now()))), 1)
|
||||
]);
|
||||
break;
|
||||
case '1y':
|
||||
startDate = max([
|
||||
startDate,
|
||||
subYears(resetHours(new Date(Date.now())), 1)
|
||||
]);
|
||||
break;
|
||||
case '5y':
|
||||
startDate = max([
|
||||
startDate,
|
||||
subYears(resetHours(new Date(Date.now())), 5)
|
||||
]);
|
||||
break;
|
||||
case 'max':
|
||||
break;
|
||||
default:
|
||||
// '2024', '2023', '2022', etc.
|
||||
endDate = endOfYear(new Date(aDateRange));
|
||||
startDate = max([startDate, new Date(aDateRange)]);
|
||||
}
|
||||
|
||||
return { endDate, startDate };
|
||||
}
|
||||
|
@ -57,6 +57,7 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
'quantity',
|
||||
'symbolMapping',
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
|
@ -1,25 +1,34 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { groupBy } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||
import { RuleInterface } from './interfaces/rule.interface';
|
||||
|
||||
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
|
||||
private key: string;
|
||||
private name: string;
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
{
|
||||
key,
|
||||
name
|
||||
}: {
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
) {
|
||||
this.key = key;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public getKey() {
|
||||
return this.key;
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: AccountClusterRiskCurrentInvestment.name,
|
||||
name: 'Investment'
|
||||
});
|
||||
|
||||
@ -35,7 +36,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let maxItem: (typeof accounts)[0];
|
||||
let totalInvestment = 0;
|
||||
|
||||
for (const account of Object.values(accounts)) {
|
||||
@ -52,7 +53,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
}
|
||||
}
|
||||
|
||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
|
||||
|
||||
if (maxInvestmentRatio > ruleSettings.threshold) {
|
||||
return {
|
||||
|
@ -11,6 +11,7 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: AccountClusterRiskSingleAccount.name,
|
||||
name: 'Single Account'
|
||||
});
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||
private positions: TimelinePosition[];
|
||||
@ -11,6 +12,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name,
|
||||
name: 'Investment: Base Currency'
|
||||
});
|
||||
|
||||
@ -43,7 +45,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
|
||||
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
|
||||
|
||||
if (maxItem.groupKey !== ruleSettings.baseCurrency) {
|
||||
if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
|
||||
return {
|
||||
evaluation: `The major part of your current investment is not in your base currency (${(
|
||||
baseCurrencyValueRatio * 100
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
private positions: TimelinePosition[];
|
||||
@ -11,6 +12,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: CurrencyClusterRiskCurrentInvestment.name,
|
||||
name: 'Investment'
|
||||
});
|
||||
|
||||
@ -37,7 +39,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
}
|
||||
});
|
||||
|
||||
const maxValueRatio = maxItem.value / totalValue;
|
||||
const maxValueRatio = maxItem?.value / totalValue || 0;
|
||||
|
||||
if (maxValueRatio > ruleSettings.threshold) {
|
||||
return {
|
||||
@ -52,7 +54,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
return {
|
||||
evaluation: `The major part of your current investment is in ${
|
||||
maxItem.groupKey
|
||||
maxItem?.groupKey ?? ruleSettings.baseCurrency
|
||||
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
}%`,
|
||||
|
@ -11,6 +11,7 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
||||
emergencyFund: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: EmergencyFundSetup.name,
|
||||
name: 'Emergency Fund: Set up'
|
||||
});
|
||||
|
||||
|
@ -13,6 +13,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
fees: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: FeeRatioInitialInvestment.name,
|
||||
name: 'Fee Ratio'
|
||||
});
|
||||
|
||||
|
@ -10,18 +10,21 @@ export class ApiService {
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByAssetSubClasses,
|
||||
filterByHoldingType,
|
||||
filterBySearchQuery,
|
||||
filterByTags
|
||||
}: {
|
||||
filterByAccounts?: string;
|
||||
filterByAssetClasses?: string;
|
||||
filterByAssetSubClasses?: string;
|
||||
filterByHoldingType?: string;
|
||||
filterBySearchQuery?: string;
|
||||
filterByTags?: string;
|
||||
}): Filter[] {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||
const holdingType = filterByHoldingType;
|
||||
const searchQuery = filterBySearchQuery?.toLowerCase();
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
@ -52,6 +55,13 @@ export class ApiService {
|
||||
})
|
||||
];
|
||||
|
||||
if (holdingType) {
|
||||
filters.push({
|
||||
id: holdingType,
|
||||
type: 'HOLDING_TYPE'
|
||||
});
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
filters.push({
|
||||
id: searchQuery,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
PROPERTY_IS_DATA_GATHERING_ENABLED
|
||||
@ -56,7 +57,8 @@ export class CronService {
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
|
||||
}
|
||||
};
|
||||
})
|
||||
|
@ -37,7 +37,17 @@ export class DataGatheringProcessor {
|
||||
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
|
||||
public async gatherAssetProfile(job: Job<UniqueAsset>) {
|
||||
try {
|
||||
Logger.log(
|
||||
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,
|
||||
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
|
||||
);
|
||||
|
||||
await this.dataGatheringService.gatherAssetProfiles([job.data]);
|
||||
|
||||
Logger.log(
|
||||
`Asset profile data gathering has been completed for ${job.data.symbol} (${job.data.dataSource})`,
|
||||
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
error,
|
||||
@ -62,11 +72,11 @@ export class DataGatheringProcessor {
|
||||
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
|
||||
);
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[{ dataSource, symbol }],
|
||||
currentDate,
|
||||
new Date()
|
||||
);
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw({
|
||||
dataGatheringItems: [{ dataSource, symbol }],
|
||||
from: currentDate,
|
||||
to: new Date()
|
||||
});
|
||||
|
||||
const data: Prisma.MarketDataUpdateInput[] = [];
|
||||
let lastMarketPrice: number;
|
||||
|
@ -8,6 +8,8 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||
PROPERTY_BENCHMARKS
|
||||
@ -61,24 +63,35 @@ export class DataGatheringService {
|
||||
|
||||
public async gather7Days() {
|
||||
const dataGatheringItems = await this.getSymbols7D();
|
||||
await this.gatherSymbols(dataGatheringItems);
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems,
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
|
||||
});
|
||||
}
|
||||
|
||||
public async gatherMax() {
|
||||
const dataGatheringItems = await this.getSymbolsMax();
|
||||
await this.gatherSymbols(dataGatheringItems);
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems,
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
|
||||
});
|
||||
}
|
||||
|
||||
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
|
||||
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
|
||||
return (
|
||||
dataGatheringItem.dataSource === dataSource &&
|
||||
dataGatheringItem.symbol === symbol
|
||||
);
|
||||
const dataGatheringItems = (await this.getSymbolsMax()).filter(
|
||||
(dataGatheringItem) => {
|
||||
return (
|
||||
dataGatheringItem.dataSource === dataSource &&
|
||||
dataGatheringItem.symbol === symbol
|
||||
);
|
||||
}
|
||||
);
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems,
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
});
|
||||
await this.gatherSymbols(symbols);
|
||||
}
|
||||
|
||||
public async gatherSymbolForDate({
|
||||
@ -91,11 +104,11 @@ export class DataGatheringService {
|
||||
symbol: string;
|
||||
}) {
|
||||
try {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[{ dataSource, symbol }],
|
||||
date,
|
||||
date
|
||||
);
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw({
|
||||
dataGatheringItems: [{ dataSource, symbol }],
|
||||
from: date,
|
||||
to: date
|
||||
});
|
||||
|
||||
const marketPrice =
|
||||
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
|
||||
@ -217,22 +230,23 @@ export class DataGatheringService {
|
||||
error,
|
||||
'DataGatheringService'
|
||||
);
|
||||
|
||||
if (uniqueAssets.length === 1) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log(
|
||||
`Asset profile data gathering has been completed for ${uniqueAssets
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return `${symbol} (${dataSource})`;
|
||||
})
|
||||
.join(',')}.`,
|
||||
'DataGatheringService'
|
||||
);
|
||||
}
|
||||
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
public async gatherSymbols({
|
||||
dataGatheringItems,
|
||||
priority
|
||||
}: {
|
||||
dataGatheringItems: IDataGatheringItem[];
|
||||
priority: number;
|
||||
}) {
|
||||
await this.addJobsToQueue(
|
||||
aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => {
|
||||
dataGatheringItems.map(({ dataSource, date, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
@ -242,6 +256,7 @@ export class DataGatheringService {
|
||||
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
opts: {
|
||||
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||
priority,
|
||||
jobId: `${getAssetProfileIdentifier({
|
||||
dataSource,
|
||||
symbol
|
||||
|
@ -59,7 +59,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
const response: Partial<SymbolProfile> = {
|
||||
symbol,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetClass: AssetClass.LIQUIDITY,
|
||||
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
dataSource: this.getName()
|
||||
@ -243,7 +243,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
return {
|
||||
name,
|
||||
symbol,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetClass: AssetClass.LIQUIDITY,
|
||||
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
dataProviderInfo: this.getDataProviderInfo(),
|
||||
|
@ -266,7 +266,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||
|
||||
switch (quoteType?.toLowerCase()) {
|
||||
case 'cryptocurrency':
|
||||
assetClass = AssetClass.CASH;
|
||||
assetClass = AssetClass.LIQUIDITY;
|
||||
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||
break;
|
||||
case 'equity':
|
||||
|
@ -20,7 +20,7 @@ import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
import { eachDayOfInterval, format, isValid } from 'date-fns';
|
||||
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
|
||||
import ms from 'ms';
|
||||
@ -204,13 +204,14 @@ export class DataProviderService {
|
||||
});
|
||||
|
||||
try {
|
||||
const queryRaw = `SELECT *
|
||||
FROM "MarketData"
|
||||
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
|
||||
AND "symbol" IN ('${symbols.join(
|
||||
`','`
|
||||
)}') ${granularityQuery} ${rangeQuery}
|
||||
ORDER BY date;`;
|
||||
const queryRaw = `
|
||||
SELECT *
|
||||
FROM "MarketData"
|
||||
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
|
||||
AND "symbol" IN ('${symbols.join(
|
||||
`','`
|
||||
)}') ${granularityQuery} ${rangeQuery}
|
||||
ORDER BY date;`;
|
||||
|
||||
const marketDataByGranularity: MarketData[] =
|
||||
await this.prismaService.$queryRawUnsafe(queryRaw);
|
||||
@ -232,15 +233,17 @@ export class DataProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
public async getHistoricalRaw(
|
||||
aDataGatheringItems: UniqueAsset[],
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
public async getHistoricalRaw({
|
||||
dataGatheringItems,
|
||||
from,
|
||||
to
|
||||
}: {
|
||||
dataGatheringItems: UniqueAsset[];
|
||||
from: Date;
|
||||
to: Date;
|
||||
}): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
let dataGatheringItems = aDataGatheringItems;
|
||||
|
||||
for (const { currency, rootCurrency } of DERIVED_CURRENCIES) {
|
||||
if (
|
||||
this.hasCurrency({
|
||||
@ -329,6 +332,8 @@ export class DataProviderService {
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'DataProviderService');
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
@ -394,7 +399,8 @@ export class DataProviderService {
|
||||
numberOfItemsInCache > 1 ? 's' : ''
|
||||
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
|
||||
3
|
||||
)} seconds`
|
||||
)} seconds`,
|
||||
'DataProviderService'
|
||||
);
|
||||
}
|
||||
|
||||
@ -500,7 +506,8 @@ export class DataProviderService {
|
||||
} from ${dataSource} in ${(
|
||||
(performance.now() - startTimeDataSource) /
|
||||
1000
|
||||
).toFixed(3)} seconds`
|
||||
).toFixed(3)} seconds`,
|
||||
'DataProviderService'
|
||||
);
|
||||
|
||||
try {
|
||||
@ -530,14 +537,15 @@ export class DataProviderService {
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
Logger.debug('------------------------------------------------');
|
||||
Logger.debug('--------------------------------------------------------');
|
||||
Logger.debug(
|
||||
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
|
||||
(performance.now() - startTimeTotal) /
|
||||
1000
|
||||
).toFixed(3)} seconds`
|
||||
).toFixed(3)} seconds`,
|
||||
'DataProviderService'
|
||||
);
|
||||
Logger.debug('================================================');
|
||||
Logger.debug('========================================================');
|
||||
|
||||
return response;
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
REPLACE_NAME_PARTS
|
||||
@ -35,7 +36,8 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
private readonly URL = 'https://eodhistoricaldata.com/api';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {
|
||||
this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA');
|
||||
}
|
||||
@ -230,42 +232,54 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
? [realTimeResponse]
|
||||
: realTimeResponse;
|
||||
|
||||
response = quotes.reduce(
|
||||
async (
|
||||
result: { [symbol: string]: IDataProviderResponse },
|
||||
{ close, code, timestamp }
|
||||
) => {
|
||||
let currency: string;
|
||||
|
||||
if (symbols.length === 1) {
|
||||
const { items } = await this.search({ query: symbols[0] });
|
||||
|
||||
if (items.length === 1) {
|
||||
currency = items[0].currency;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNumber(close)) {
|
||||
result[this.convertFromEodSymbol(code)] = {
|
||||
currency,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: close,
|
||||
marketState: isToday(new Date(timestamp * 1000))
|
||||
? 'open'
|
||||
: 'closed'
|
||||
};
|
||||
} else {
|
||||
Logger.error(
|
||||
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`,
|
||||
'EodHistoricalDataService'
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
{}
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
symbols.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
for (const { close, code, timestamp } of quotes) {
|
||||
let currency: string;
|
||||
|
||||
if (code.endsWith('.FOREX')) {
|
||||
currency = this.convertFromEodSymbol(code)?.replace(
|
||||
DEFAULT_CURRENCY,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
if (!currency) {
|
||||
currency = symbolProfiles.find(({ symbol }) => {
|
||||
return symbol === code;
|
||||
})?.currency;
|
||||
}
|
||||
|
||||
if (!currency) {
|
||||
const { items } = await this.search({ query: code });
|
||||
|
||||
if (items.length === 1) {
|
||||
currency = items[0].currency;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNumber(close)) {
|
||||
response[this.convertFromEodSymbol(code)] = {
|
||||
currency,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: close,
|
||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||
};
|
||||
} else {
|
||||
Logger.error(
|
||||
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`,
|
||||
'EodHistoricalDataService'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
@ -454,7 +468,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
assetSubClass = AssetSubClass.STOCK;
|
||||
break;
|
||||
case 'currency':
|
||||
assetClass = AssetClass.CASH;
|
||||
assetClass = AssetClass.LIQUIDITY;
|
||||
|
||||
if (Exchange?.toLowerCase() === 'cc') {
|
||||
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||
|
@ -91,7 +91,7 @@ export class ManualService implements DataProviderInterface {
|
||||
headers = {},
|
||||
selector,
|
||||
url
|
||||
} = symbolProfile.scraperConfiguration ?? {};
|
||||
} = symbolProfile?.scraperConfiguration ?? {};
|
||||
|
||||
if (defaultMarketPrice) {
|
||||
const historical: {
|
||||
|
@ -449,13 +449,16 @@ export class ExchangeRateDataService {
|
||||
factors[format(date, DATE_FORMAT)] = factor;
|
||||
}
|
||||
} catch {
|
||||
Logger.error(
|
||||
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom} and ${DEFAULT_CURRENCY}${currencyTo}.`,
|
||||
'ExchangeRateDataService'
|
||||
);
|
||||
let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`;
|
||||
|
||||
if (DEFAULT_CURRENCY !== currencyTo) {
|
||||
errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`;
|
||||
}
|
||||
|
||||
Logger.error(`${errorMessage}.`, 'ExchangeRateDataService');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -97,7 +97,9 @@ export class SymbolProfileService {
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping
|
||||
symbolMapping,
|
||||
SymbolProfileOverrides,
|
||||
url
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
return this.prismaService.symbolProfile.update({
|
||||
data: {
|
||||
@ -109,7 +111,9 @@ export class SymbolProfileService {
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbolMapping
|
||||
symbolMapping,
|
||||
SymbolProfileOverrides,
|
||||
url
|
||||
},
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
});
|
||||
|
@ -72,6 +72,10 @@
|
||||
"baseHref": "/tr/",
|
||||
"localize": ["tr"]
|
||||
},
|
||||
"development-zh": {
|
||||
"baseHref": "/zh/",
|
||||
"localize": ["zh"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
@ -190,6 +194,9 @@
|
||||
"development-tr": {
|
||||
"buildTarget": "client:build:development-tr"
|
||||
},
|
||||
"development-zh": {
|
||||
"buildTarget": "client:build:development-zh"
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "client:build:production"
|
||||
}
|
||||
@ -209,7 +216,8 @@
|
||||
"messages.nl.xlf",
|
||||
"messages.pl.xlf",
|
||||
"messages.pt.xlf",
|
||||
"messages.tr.xlf"
|
||||
"messages.tr.xlf",
|
||||
"messages.zh.xlf"
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -260,6 +268,10 @@
|
||||
"tr": {
|
||||
"baseHref": "/tr/",
|
||||
"translation": "apps/client/src/locales/messages.tr.xlf"
|
||||
},
|
||||
"zh": {
|
||||
"baseHref": "/zh/",
|
||||
"translation": "apps/client/src/locales/messages.zh.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
|
@ -163,6 +163,11 @@
|
||||
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
|
||||
</li>
|
||||
-->
|
||||
<!--
|
||||
<li>
|
||||
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
|
||||
</li>
|
||||
-->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,5 +1,3 @@
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100svh;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { getCssVariable } from '@ghostfolio/common/helper';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { ColorScheme } from '@ghostfolio/common/types';
|
||||
@ -187,20 +188,28 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
? userPreferredColorScheme === 'DARK'
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
this.toggleThemeStyleClass(isDarkTheme);
|
||||
this.toggleTheme(isDarkTheme);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
|
||||
if (!this.user?.settings.colorScheme) {
|
||||
this.toggleThemeStyleClass(event.matches);
|
||||
this.toggleTheme(event.matches);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toggleThemeStyleClass(isDarkTheme: boolean) {
|
||||
private toggleTheme(isDarkTheme: boolean) {
|
||||
const themeColor = getCssVariable(
|
||||
isDarkTheme ? '--dark-background' : '--light-background'
|
||||
);
|
||||
|
||||
if (isDarkTheme) {
|
||||
this.document.body.classList.add('is-dark-theme');
|
||||
} else {
|
||||
this.document.body.classList.remove('is-dark-theme');
|
||||
}
|
||||
|
||||
this.document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
.setAttribute('content', themeColor);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||
import { GfLogoComponent } from '@ghostfolio/ui/logo';
|
||||
|
||||
import { Platform } from '@angular/cdk/platform';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
@ -43,7 +43,7 @@ export function NgxStripeFactory(): string {
|
||||
BrowserAnimationsModule,
|
||||
BrowserModule,
|
||||
GfHeaderModule,
|
||||
GfLogoModule,
|
||||
GfLogoComponent,
|
||||
GfSubscriptionInterstitialDialogModule,
|
||||
HttpClientModule,
|
||||
MarkdownModule.forRoot(),
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user