Compare commits

..

106 Commits

Author SHA1 Message Date
e79d607ab8 Release 2.73.0 (#3287) 2024-04-17 17:42:28 +02:00
5f7d083f7c Feature/upgrade yahoo finance2 to version 2.11.2 (#3286)
* Upgrade yahoo-finance2 to version 2.11.2

* Update changelog
2024-04-17 17:40:58 +02:00
15857118fe Feature/let data gathering queue jobs fail by throwing errors (#3281)
* Let data gathering queue jobs fail by throwing errors

* Update changelog
2024-04-17 17:35:51 +02:00
ff91ed21df Upgrade @types/lodash to version 4.17.0 (#3227) 2024-04-15 19:25:46 +02:00
9241c04d5a Feature/add form validation against DTO for activity and account (#3230)
* Add form validation against DTO for activity and account

* Update changelog
2024-04-14 19:52:41 +02:00
5d4e2fba8c Feature/move wealth item and liability calculations to portfolio calculator (#3272)
* Move (wealth) item calculations to portfolio calculator

* Move liability calculations to portfolio calculator

* Update changelog
2024-04-14 08:12:32 +02:00
6c57609db8 Feature/move dividend fee and interest calculation to portfolio calculator (#3267)
* Move dividend, feee and interest calculation to portfolio calculator

* Update changelog
2024-04-13 11:07:18 +02:00
b31bbbe2d1 Release 2.72.0 (#3270) 2024-04-13 09:31:07 +02:00
7d308917dd Feature/upgrade yahoo finance2 to version 2.11.1 (#3254)
* Upgrade yahoo-finance2 to version 2.11.1

* Update changelog
2024-04-13 09:28:38 +02:00
4e7d93db13 Feature/adapt priorities of data gathering jobs (#3262)
* Adapt priorities of data gathering jobs

* Update changelog
2024-04-13 09:28:14 +02:00
45340b581f Bugfix/fix public page by including markets data (#3263)
* Include markets data

* Update changelog
2024-04-12 15:13:34 +02:00
6f8fe45fc2 Update OSS Friends (#3258) 2024-04-11 19:14:56 +02:00
34d9ceb009 Feature/add support to immediately execute queue job (#3259)
* Add support to immediately execute queue job

* Update changelog
2024-04-11 19:14:03 +02:00
2b97bbd05d Move getChart() to portfolio calculator (#3255) 2024-04-09 13:44:23 +02:00
3bf7ac76a0 Feature/upgrade nx to version 18.2.3 (#3256)
* Upgrade Nx to version 17.3.3

* Update changelog
2024-04-08 08:07:00 +02:00
71892e67b2 Feature/upgrade prisma to version 5.12.1 (#3253)
* Upgrade prisma to version 5.12.1

* Update changelog
2024-04-07 20:27:38 +02:00
6e2885ed20 Release 2.71.0 (#3252) 2024-04-07 11:32:55 +02:00
07c0e5a612 Feature/add currency to order database schema (#3251)
* Add currency to Order database schema

* Update changelog
2024-04-07 11:30:32 +02:00
719bbe156e Feature/optimize calculation of allocations by market (#3249)
* Optimize calculation of allocations by market

* Update changelog
2024-04-07 09:26:30 +02:00
b51255a543 Feature/add key to x ray rule (#3248)
* Add key

* Update changelog
2024-04-07 09:25:14 +02:00
50dbbf0569 Feature/refactor symbol icon module to asset profile icon component (#3245)
* Refactor symbol icon module to asset profile icon component (standalone)
2024-04-06 21:15:19 +02:00
ca2e748c56 Bugfix/add missing tags in portfolio calculator (#3243)
* Add missing tags

* Update changelog
2024-04-06 20:03:16 +02:00
5c480109d5 Feature/add quotes in README.md for API auth documentation (#3246) 2024-04-06 20:00:56 +02:00
6152ff4b44 Remove condition (#3242) 2024-04-06 12:50:35 +02:00
c6641fde36 Feature/add icon to create or update platform dialog (#3241)
* Add platform icon

* Update changelog
2024-04-06 09:11:15 +02:00
4ae7e9fcbe Feature/add icon to asset profile dialog (#3240)
* Add asset profile icon

* Update changelog
2024-04-06 08:42:06 +02:00
c10ae431a2 Feature/extend faq by data providers (#3239)
* Add data providers

* Update changelog
2024-04-06 08:40:44 +02:00
883e30e451 Feature/improve language localization for de 20240403 (#3236)
* Update translations

* Update changelog
2024-04-05 19:38:10 +02:00
f1f4f6247d Feature/validate url in create and update platform dto (#3235)
* Validate url

* Update changelog
2024-04-04 09:08:37 +02:00
82fe1de1a7 Feature/add support to override asset (sub) class and url in admin control panel (#3218)
* Add support to override asset (sub) class and url in admin control panel

* Update changelog
2024-04-03 20:24:38 +02:00
371c999fbc Feature/Add dividend yield to position detail dialog (#2636)
* Add dividend yield to position detail dialog

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-04-03 19:47:53 +02:00
26b9660e11 Release 2.70.0 (#3234) 2024-04-02 20:29:47 +02:00
ca7717f9c5 Bugfix/Enable tini in docker compose files instead of adding it to the Dockerfile (#3232)
* Enable tini in docker compose files instead of adding it to the Dockerfile

* Update changelog
2024-04-02 20:26:14 +02:00
6f3cce1c5f Feature/disable option to update cash balance if date is not today (#3229)
* Disable option to update cash balance if date is not today

* Update changelog
2024-04-02 20:17:16 +02:00
efdc9b387f Eliminate ghostfolio-style.scss (#3228) 2024-04-01 13:24:00 +02:00
d7b579e3e8 Feature/refactor getAnnualizedPerformancePercent to portfolio calculator (#3226)
* Move getAnnualizedPerformancePercent() to portfolio calculator
2024-04-01 10:42:15 +02:00
b8533050b0 Bugfix/fix duplicated tags in position detail dialog (#3224)
* Fix duplicated tags

* Update changelog
2024-04-01 09:22:35 +02:00
1b81409b35 Add OpenAlternative logo (#3225) 2024-04-01 09:02:10 +02:00
8cd6c34ed8 Feature/introduce portfolio calculator factory (#3214)
* Introduce portfolio calculator factory

* Update changelog
2024-03-31 20:07:58 +02:00
0c68474802 Extract locales (#3223) 2024-03-31 11:41:44 +02:00
34997f91db Feature/setup webpack bundle analyzer (#3222)
* Set up  Webpack Bundle Analyzer

* Update changelog
2024-03-31 11:38:09 +02:00
084467ee9a Feature/reverse order of specific years in date range selector of assistant (#3221)
* Reverse order

* Update changelog
2024-03-31 11:24:50 +02:00
af47889d65 Add Chinese translations (#3215)
* Add Chinese translations
2024-03-31 11:24:32 +02:00
51203ec96e Feature/setup chinese (#3220)
* Set up chinese

* Update changelog
2024-03-31 10:46:30 +02:00
a2277dea2c Release 2.69.0 (#3212) 2024-03-30 13:43:43 +01:00
debd233c32 Feature/set up Tini to avoid zombie processes (#3195)
* Set up Tini to avoid zombie processes

* Update changelog
2024-03-30 13:42:02 +01:00
f1eeee0525 Feature/extend date range support by specific years (#3190)
* Extend date range support by specific years

* Support date range in benchmark endpoint

* Support date range in activities endpoint

* Update changelog
2024-03-30 13:06:42 +01:00
5ffc39c32f Feature/improve usability to delete asset profile (#3208)
* Disable delete button for benchmarks

* Update changelog
2024-03-30 11:23:59 +01:00
a668a66e84 Feature/add missing dates to edit historical market data in asset profile details dialog (#3206)
* Add missing dates to edit historical market data in asset profile details dialog

* Update changelog
2024-03-30 08:50:06 +01:00
0581b8b9ec Release 2.68.0 (#3205) 2024-03-29 17:54:17 +01:00
63a61fb492 Move portfolio calculator (#3204)
* Move portfolio calculator
2024-03-29 17:47:44 +01:00
5788c6474e Refactor portfolio calculator (#3203)
* Refactor portfolio calculator

* Consume Activity[]
* Change computeTransactionPoints() to private
* Eliminate getTransactionPoints()

* Update changelog
2024-03-29 17:34:22 +01:00
5529fdc0ee Move transaction points to constructor (#3202) 2024-03-29 13:50:24 +01:00
88a9b518f6 Bugfix/fix issue with overridden names in activities table (#3200)
* Fix issue with overridden names

* Update changelog
2024-03-29 09:50:04 +01:00
98de2355c4 Feature/Support overriding name of asset profile dialog (#3199)
* Support overriding name of asset profile dialog

* Update changelog
2024-03-29 09:35:41 +01:00
b41eb60348 Fix chart tooltip of benchmark comparator (#3167)
* Fix chart tooltip of benchmark comparator

* Update changelog
2024-03-28 18:23:56 +01:00
0edebe30e1 Feature/Add user account currency to export (#3194)
* Add user account currency to export

* Update changelog
2024-03-27 18:08:34 +01:00
e3abe4feee Release 2.67.0 (#3197) 2024-03-26 17:45:32 +01:00
50391e199a Feature/improve generation of random strings (#3196)
* Replace Math.random() with crypto.randomBytes()

* Update changelog
2024-03-26 17:43:42 +01:00
a33f8d5bed Upgrade @types/big.js to version 6.2.2 (#3191)
* Upgrade @types/big.js to version 6.2.2

* Refactor imports
2024-03-25 08:34:30 +01:00
636be8441e Feature/upgrade zone.js to version 0.14.4 (#3184)
* Upgrade zone.js to version 0.14.4

* Update changelog
2024-03-24 10:30:06 +01:00
654dc2ba32 Feature/upgrade yahoo finance2 to version 2.11.0 (#3183)
* Upgrade yahoo-finance2 to version 2.11.0

* Update changelog
2024-03-24 09:50:45 +01:00
458ef169f4 Feature/add support for toncoin cryptocurrency (#3189)
* Add Toincoin

* Update changelog
2024-03-24 09:50:00 +01:00
5bb01bb03c Feature/upgrade ionicons to version 7.3.0 (#3182)
* Upgrade ionicons to version 7.3.0

* Update changelog
2024-03-24 09:09:09 +01:00
43e9528d8c Release 2.66.3 (#3188) 2024-03-23 20:56:22 +01:00
522c54c9b4 Release 2.66.2 (#3187) 2024-03-23 20:40:09 +01:00
0004ced4e1 Release 2.66.1 (#3186) 2024-03-23 20:26:10 +01:00
274c60e961 Release 2.66.0 (#3185) 2024-03-23 19:55:17 +01:00
754e98099c Feature/Set up tini (#3168)
* Set up tini to avoid zombie processes and perform signal forwarding in docker image

* Update changelog
2024-03-23 19:53:27 +01:00
87bf8df1c3 Bugfix/missing performance chart in presenter view (#3181)
* Fix missing performance chart in presenter view

* Update changelog
2024-03-23 19:41:28 +01:00
3f7d6b25c7 Feature/extend faq by backup strategy (#3180)
* Add backup strategy

* Update changelog
2024-03-23 19:10:23 +01:00
8a062e03ab Feature/add benchmark name to tooltip of benchmark comparator (#3177)
* Add benchmark name to chart tooltip

* Update changelog
2024-03-23 09:38:35 +01:00
a70f45cbf3 Feature/add index for data source symbol to market data table (#3179)
* Add index

* Update changelog
2024-03-23 09:23:59 +01:00
f268264c46 Feature/upgrade nx to version 18.1.2 (#3174)
* Upgrade angular and Nx dependencies

* Update changelog
2024-03-23 09:09:10 +01:00
bbe5d70720 Rename Twitter to X (#3009) 2024-03-21 15:10:35 +01:00
f1d2a52cba Release 2.65.0 (#3170) 2024-03-19 20:13:22 +01:00
87cc887865 Feature/Set meta theme color dynamically to respect appearance (#3129)
* Set meta theme color dynamically to respect appearance (dark mode)

* Update changelog
2024-03-19 20:11:34 +01:00
61ecd15f1d Feature/change edit button to a in admin market data page (#3164)
* Change edit button to a

* Update changelog
2024-03-19 19:55:08 +01:00
eb853f05ae Feature/add support to delete asset profile from dialog (#3165)
* Add support to delete asset profile from dialog

* Update changelog
2024-03-19 19:27:16 +01:00
6285417903 Feature/change grant private access with permissions to general availability (#3169)
* Change grant private access with permissions from experimental to general availability

* Update changelog
2024-03-19 19:25:43 +01:00
ca674a654e Feature/add symbol and isin to position detail dialog (#3163)
* Add symbol and ISIN

* Update translations

* Update changelog
2024-03-18 13:58:46 +01:00
2729c5651f Release 2.64.0 (#3161) 2024-03-16 19:00:50 +01:00
7e28e42995 Feature/exclude fees from holdings (#3160) 2024-03-16 18:59:23 +01:00
e21563d903 Feature/increase timeout to load benchmarks (#3158)
* Increase request timeout

* Update changelog
2024-03-16 18:15:34 +01:00
3ede69650c Feature/upgrade prisma to version 5.11.0 (#3159)
* Upgrade prisma to version 5.11.0

* Update changelog
2024-03-16 16:51:07 +01:00
c289793c6d Feature/switch between active and closed holdings (#3146)
* Switch between active and closed holdings on the portfolio holdings page

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-03-16 14:20:58 +01:00
a90c067da0 Clean up (#3157) 2024-03-16 13:40:23 +01:00
38c2baf943 Feature/improve exception handling of current investments in various rules (#3156)
* Improve exception handling

* Update changelog
2024-03-16 12:29:21 +01:00
82c78cad6b Format (#3155) 2024-03-16 12:28:58 +01:00
bffe6060bd Pass portfolio calculator to getChart() (#3153) 2024-03-16 10:05:16 +01:00
841bd5c33f Bugfix/fix dividend accumulation in symbol metrics (#3152)
* Fix total dividend calculation

* Update changelog
2024-03-16 10:04:57 +01:00
3b895afc9e Enable account balance update for fee and interest activities (#3145)
* Enable account balance update for fee and interest activities

* Update changelog
2024-03-15 18:56:17 +01:00
00c2ede85e Feature/improve usability of platform and tag management (#3144)
* Improve usability

* Update changelog
2024-03-15 08:37:41 +01:00
8420cb830c Feature/add product roadmap to faq (#3143)
* Add product roadmap

* Update changelog
2024-03-14 14:09:20 +01:00
a0ddd1f9b9 Fix date conversion in import of historical market data (#3117)
* Fix date conversion in import of historical market data

* Update changelog
2024-03-13 20:44:33 +01:00
40d93066ff Introduce .env.dev (#3120) 2024-03-13 20:21:54 +01:00
671e4e316b Release 2.63.2 (#3138) 2024-03-12 20:50:32 +01:00
473136e9aa Release 2.63.1 (#3135)
* Release 2.63.1
2024-03-11 21:35:43 +01:00
9a3db91982 Release 2.63.0 (#3134) 2024-03-11 20:19:23 +01:00
d23cb5f190 Feature/upgrade simplewebauthn dependencies to version 9.0 (#3130)
* Upgrade @simplewebauthn/browser and @simplewebauthn/server to version 9.0

* Update changelog
2024-03-11 20:17:47 +01:00
7a364472c8 Bugfix/fix liability issue in allocations (#3133)
* Remove liabilities from allocations calculation

* Update changelog
2024-03-11 20:16:56 +01:00
59c064e3c8 Feature/upgrade yahoo finance2 to version 2.10.0 (#3127)
* Upgrade yahoo-finance2 to version 2.10.0

* Update changelog
2024-03-11 20:15:55 +01:00
e792924606 Update OSS friends (#3132) 2024-03-11 20:15:32 +01:00
d32dd5e860 Feature/upgrade countries list to version 3.1.0 (#3131)
* Upgrade countries-list to version 3.1.0

* Update changelog
2024-03-11 19:16:20 +01:00
bb86f85203 Feature/add available home server systems to faq (#3126)
* Add available home server systems

* Update changelog

* Add CasaOS to README.md
2024-03-10 09:50:43 +01:00
0bca8897d6 Fix average price calculation by only considering BUY transactions (#3125)
* Fix average price calculation by only considering buy transactions

* Update changelog
2024-03-10 09:35:47 +01:00
223 changed files with 23068 additions and 5115 deletions

25
.env.dev Normal file
View 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

View File

@ -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>

View File

@ -5,6 +5,202 @@ 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.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 accounts currency
- Added support to override the name of an asset profile in the asset profile details dialog of the admin control
### Changed
- Optimized the portfolio calculations
### Fixed
- Fixed the chart tooltip of the benchmark comparator
- Fixed an issue with names in the activities table on the portfolio activities page while using symbol profile overrides
## 2.67.0 - 2024-03-26
### Added
- Added support for the cryptocurrency _Toncoin_ (`TON11419-USD`)
### Changed
- Replaced `Math.random()` with `crypto.randomBytes()` for generating cryptographically secure random strings
- Upgraded `ionicons` from version `7.1.0` to `7.3.0`
- Upgraded `yahoo-finance2` from version `2.10.0` to `2.11.0`
- Upgraded `zone.js` from version `0.14.3` to `0.14.4`
## 2.66.3 - 2024-03-23
### Added
- Extended the content of the _SaaS_ and _Self-Hosting_ sections by the backup strategy on the Frequently Asked Questions (FAQ) page
- Added an index for `dataSource` / `symbol` to the market data database table
### Changed
- Improved the chart tooltip of the benchmark comparator by adding the benchmark name
- Upgraded `angular` from version `17.1.3` to `17.2.4`
- Upgraded `Nx` from version `18.0.4` to `18.1.2`
### Fixed
- Fixed the missing portfolio performance chart in the _Presenter View_ / _Zen Mode_
## 2.65.0 - 2024-03-19
### Added
- Added the symbol and ISIN number to the position detail dialog
- Added support to delete an asset profile in the asset profile details dialog of the admin control
### Changed
- Moved the support to grant private access with permissions from experimental to general availability
- Set the meta theme color dynamically to respect the appearance (dark mode)
- Improved the usability to edit market data in the admin control panel
## 2.64.0 - 2024-03-16
### Added
- Added a toggle to switch between active and closed holdings on the portfolio holdings page
- Added support to update the cash balance of an account when adding a fee activity
- Added support to update the cash balance of an account when adding an interest activity
- Extended the content of the _General_ section by the product roadmap on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the usability of the platform management in the admin control panel
- Improved the usability of the tag management in the admin control panel
- Improved the exception handling of various rules in the _X-ray_ section
- Increased the timeout to load benchmarks
- Upgraded `prisma` from version `5.10.2` to `5.11.0`
### Fixed
- Fixed an issue in the dividend calculation of the portfolio holdings
- Fixed the date conversion of the import of historical market data in the admin control panel
## 2.63.2 - 2024-03-12
### Added
- Extended the content of the _Self-Hosting_ section by available home server systems on the Frequently Asked Questions (FAQ) page
- Added support for the cryptocurrency _Real Smurf Cat_ (`SMURFCAT-USD`)
### Changed
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `8.3` to `9.0`
- Upgraded `countries-list` from version `2.6.1` to `3.1.0`
- Upgraded `yahoo-finance2` from version `2.9.1` to `2.10.0`
### Fixed
- Fixed an issue in the performance calculation caused by multiple `SELL` activities on the same day
- Fixed an issue in the calculation on the allocations page caused by liabilities
- Fixed an issue with the currency in the request to get quotes from _EOD Historical Data_
## 2.62.0 - 2024-03-09
### Changed

View File

@ -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" ]

View File

@ -7,7 +7,7 @@
**Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
@ -144,7 +144,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development
@ -154,7 +154,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
- [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup
@ -206,7 +206,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>`.

View File

@ -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 } }

View File

@ -5,7 +5,7 @@ import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js';
import { Big } from 'big.js';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';

View File

@ -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);
}
}

View File

@ -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([
{
@ -397,6 +419,7 @@ export class AdminService {
assetClass: 'CASH',
countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
id: undefined,
name: symbol,
sectorsCount: 0
};

View File

@ -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);
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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

View File

@ -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,

View File

@ -95,7 +95,10 @@ export class ExportService {
: SymbolProfile.symbol
};
}
)
),
user: {
settings: { currency: userCurrency }
}
};
}
}

View File

@ -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: {
@ -420,14 +426,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 +452,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 +528,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
}
};
}

View File

@ -42,6 +42,10 @@ export class CreateOrderDto {
@IsISO4217CurrencyCode()
currency: string;
@IsISO4217CurrencyCode()
@IsOptional()
customCurrency?: string;
@IsOptional()
@IsEnum(DataSource, { each: true })
dataSource?: DataSource;

View File

@ -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;

View File

@ -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,
@ -84,6 +88,7 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@ -96,14 +101,18 @@ export class OrderController {
filterByTags
});
const { endDate, startDate } = getInterval(dateRange);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { activities, count } = await this.orderService.getOrders({
endDate,
filters,
sortColumn,
sortDirection,
startDate,
userCurrency,
includeDrafts: true,
skip: isNaN(skip) ? undefined : skip,
@ -120,13 +129,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 +163,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 +197,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,

View File

@ -4,6 +4,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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';
@ -21,11 +22,12 @@ 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 { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
@Injectable()
@ -65,7 +67,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 +74,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 +81,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 +102,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 +116,6 @@ export class OrderService {
delete data.comment;
}
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.tags;
@ -148,15 +147,15 @@ 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
});
}
@ -198,22 +197,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 +228,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 +335,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 +372,7 @@ export class OrderService {
order.SymbolProfile.currency,
userCurrency
),
SymbolProfile: assetProfile,
// TODO: Use exchange rate of date
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
@ -388,19 +429,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;

View File

@ -41,6 +41,10 @@ export class UpdateOrderDto {
@IsISO4217CurrencyCode()
currency: string;
@IsISO4217CurrencyCode()
@IsOptional()
customCurrency?: string;
@IsString()
dataSource: DataSource;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,37 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
import {
SymbolMetrics,
TimelinePosition,
UniqueAsset
} from '@ghostfolio/common/interfaces';
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.');
}
}

View File

@ -0,0 +1,26 @@
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
};

View File

@ -0,0 +1,56 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DateRange } 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 currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService
) {}
public createCalculator({
activities,
calculationType,
currency,
dateRange = 'max'
}: {
activities: Activity[];
calculationType: PerformanceCalculationType;
currency: string;
dateRange?: DateRange;
}): PortfolioCalculator {
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
activities,
currency,
dateRange,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService
});
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
exchangeRateDataService: this.exchangeRateDataService
});
default:
throw new Error('Invalid calculation type');
}
}
}

View File

@ -0,0 +1,928 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import {
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getSum,
parseDate,
resetHours
} from '@ghostfolio/common/helper';
import {
DataProviderInfo,
HistoricalDataItem,
InvestmentItem,
ResponseError,
SymbolMetrics,
TimelinePosition,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { DateRange, GroupBy } from '@ghostfolio/common/types';
import { Big } from 'big.js';
import {
differenceInDays,
eachDayOfInterval,
endOfDay,
format,
isBefore,
isSameDay,
max,
subDays
} from 'date-fns';
import { last, uniq, uniqBy } from 'lodash';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
protected orders: PortfolioOrder[];
private currency: string;
private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService;
private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>;
private startDate: Date;
private transactionPoints: TransactionPoint[];
public constructor({
activities,
currency,
currentRateService,
dateRange,
exchangeRateDataService
}: {
activities: Activity[];
currency: string;
currentRateService: CurrentRateService;
dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService;
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.exchangeRateDataService = exchangeRateDataService;
this.orders = activities.map(
({ date, fee, quantity, SymbolProfile, tags = [], type, unitPrice }) => {
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
);
this.orders.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
const { endDate, startDate } = getInterval(dateRange);
this.endDate = endDate;
this.startDate = startDate;
this.computeTransactionPoints();
this.snapshotPromise = this.initialize();
}
protected abstract calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot;
public async computeSnapshot(
start: Date,
end?: Date
): Promise<PortfolioSnapshot> {
const lastTransactionPoint = last(this.transactionPoints);
let endDate = end;
if (!endDate) {
endDate = new Date(Date.now());
if (lastTransactionPoint) {
endDate = max([endDate, parseDate(lastTransactionPoint.date)]);
}
}
const transactionPoints = this.transactionPoints?.filter(({ date }) => {
return isBefore(parseDate(date), endDate);
});
if (!transactionPoints.length) {
return {
currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
positions: [],
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
}
const currencies: { [symbol: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
let dates: Date[] = [];
let firstIndex = transactionPoints.length;
let firstTransactionPoint: TransactionPoint = null;
let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0);
let totalValuablesWithCurrencyEffect = new Big(0);
dates.push(resetHours(start));
for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1
].items) {
dataGatheringItems.push({
dataSource,
symbol
});
currencies[symbol] = currency;
}
for (let i = 0; i < transactionPoints.length; i++) {
if (
!isBefore(parseDate(transactionPoints[i].date), start) &&
firstTransactionPoint === null
) {
firstTransactionPoint = transactionPoints[i];
firstIndex = i;
}
if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(transactionPoints[i].date)));
}
}
dates.push(resetHours(endDate));
// Add dates of last week for fallback
dates.push(subDays(resetHours(new Date()), 7));
dates.push(subDays(resetHours(new Date()), 6));
dates.push(subDays(resetHours(new Date()), 5));
dates.push(subDays(resetHours(new Date()), 4));
dates.push(subDays(resetHours(new Date()), 3));
dates.push(subDays(resetHours(new Date()), 2));
dates.push(subDays(resetHours(new Date()), 1));
dates.push(resetHours(new Date()));
dates = uniq(
dates.map((date) => {
return date.getTime();
})
)
.map((timestamp) => {
return new Date(timestamp);
})
.sort((a, b) => {
return a.getTime() - b.getTime();
});
let exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)),
endDate: endOfDay(endDate),
startDate: this.getStartDate(),
targetCurrency: this.currency
});
const {
dataProviderInfos,
errors: currentRateErrors,
values: marketSymbols
} = await this.currentRateService.getValues({
dataGatheringItems,
dateQuery: {
in: dates
}
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
const endDateString = format(endDate, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
}
const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
endDateString
]
);
const {
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
hasErrors,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
totalDividend,
totalDividendInBaseCurrency,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency,
totalValuablesInBaseCurrency
} = this.getSymbolMetrics({
marketSymbolMap,
start,
dataSource: item.dataSource,
end: endDate,
exchangeRates:
exchangeRatesByCurrency[`${item.currency}${this.currency}`],
symbol: item.symbol
});
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
positions.push({
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
averagePrice: item.averagePrice,
currency: item.currency,
dataSource: item.dataSource,
fee: item.fee,
firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
: null,
grossPerformancePercentageWithCurrencyEffect: !hasErrors
? grossPerformancePercentageWithCurrencyEffect ?? null
: null,
grossPerformanceWithCurrencyEffect: !hasErrors
? grossPerformanceWithCurrencyEffect ?? null
: null,
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice:
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
marketPriceInBaseCurrency:
marketPriceInBaseCurrency?.toNumber() ?? null,
netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null
: null,
netPerformancePercentageWithCurrencyEffect: !hasErrors
? netPerformancePercentageWithCurrencyEffect ?? null
: null,
netPerformanceWithCurrencyEffect: !hasErrors
? netPerformanceWithCurrencyEffect ?? null
: null,
quantity: item.quantity,
symbol: item.symbol,
tags: item.tags,
transactionCount: item.transactionCount,
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul(
item.quantity
)
});
totalInterestWithCurrencyEffect = totalInterestWithCurrencyEffect.plus(
totalInterestInBaseCurrency
);
totalLiabilitiesWithCurrencyEffect =
totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency);
totalValuablesWithCurrencyEffect = totalValuablesWithCurrencyEffect.plus(
totalValuablesInBaseCurrency
);
if (
(hasErrors ||
currentRateErrors.find(({ dataSource, symbol }) => {
return dataSource === item.dataSource && symbol === item.symbol;
})) &&
item.investment.gt(0)
) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
}
}
const overall = this.calculateOverallPerformance(positions);
return {
...overall,
errors,
positions,
totalInterestWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect,
totalValuablesWithCurrencyEffect,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
};
}
public async getChart({
dateRange = 'max',
withDataDecimation = true
}: {
dateRange?: DateRange;
withDataDecimation?: boolean;
}): Promise<HistoricalDataItem[]> {
if (this.getTransactionPoints().length === 0) {
return [];
}
const { endDate, startDate } = getInterval(dateRange, this.getStartDate());
const daysInMarket = differenceInDays(endDate, startDate) + 1;
const step = withDataDecimation
? Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS))
: 1;
return this.getChartData({
step,
end: endDate,
start: startDate
});
}
public async getChartData({
end = new Date(Date.now()),
start,
step = 1
}: {
end?: Date;
start: Date;
step?: number;
}): Promise<HistoricalDataItem[]> {
const symbols: { [symbol: string]: boolean } = {};
const transactionPointsBeforeEndDate =
this.transactionPoints?.filter((transactionPoint) => {
return isBefore(parseDate(transactionPoint.date), end);
}) ?? [];
const currencies: { [symbol: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
const firstIndex = transactionPointsBeforeEndDate.length;
let dates = eachDayOfInterval({ start, end }, { step }).map((date) => {
return resetHours(date);
});
const includesEndDate = isSameDay(last(dates), end);
if (!includesEndDate) {
dates.push(resetHours(end));
}
if (transactionPointsBeforeEndDate.length > 0) {
for (const {
currency,
dataSource,
symbol
} of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource,
symbol
});
currencies[symbol] = currency;
symbols[symbol] = true;
}
}
const { dataProviderInfos, values: marketSymbols } =
await this.currentRateService.getValues({
dataGatheringItems,
dateQuery: {
in: dates
}
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
let exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)),
endDate: endOfDay(end),
startDate: this.getStartDate(),
targetCurrency: this.currency
});
for (const marketSymbol of marketSymbols) {
const dateString = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[dateString]) {
marketSymbolMap[dateString] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[dateString][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
const accumulatedValuesByDate: {
[date: string]: {
investmentValueWithCurrencyEffect: Big;
totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big;
totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big;
totalNetPerformanceValue: Big;
totalNetPerformanceValueWithCurrencyEffect: Big;
totalTimeWeightedInvestmentValue: Big;
totalTimeWeightedInvestmentValueWithCurrencyEffect: Big;
};
} = {};
const valuesBySymbol: {
[symbol: string]: {
currentValues: { [date: string]: Big };
currentValuesWithCurrencyEffect: { [date: string]: Big };
investmentValuesAccumulated: { [date: string]: Big };
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big };
investmentValuesWithCurrencyEffect: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big };
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big };
timeWeightedInvestmentValues: { [date: string]: Big };
timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big };
};
} = {};
for (const symbol of Object.keys(symbols)) {
const {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
} = this.getSymbolMetrics({
end,
marketSymbolMap,
start,
step,
symbol,
dataSource: null,
exchangeRates:
exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`],
isChartMode: true
});
valuesBySymbol[symbol] = {
currentValues,
currentValuesWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
};
}
for (const currentDate of dates) {
const dateString = format(currentDate, DATE_FORMAT);
for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol];
const currentValue =
symbolValues.currentValues?.[dateString] ?? new Big(0);
const currentValueWithCurrencyEffect =
symbolValues.currentValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const investmentValueAccumulated =
symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0);
const investmentValueAccumulatedWithCurrencyEffect =
symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[
dateString
] ?? new Big(0);
const investmentValueWithCurrencyEffect =
symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
const netPerformanceValueWithCurrencyEffect =
symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const timeWeightedInvestmentValue =
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
const timeWeightedInvestmentValueWithCurrencyEffect =
symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[
dateString
] ?? new Big(0);
accumulatedValuesByDate[dateString] = {
investmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.investmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect),
totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
totalCurrentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalCurrentValueWithCurrencyEffect ?? new Big(0)
).add(currentValueWithCurrencyEffect),
totalInvestmentValue: (
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0)
).add(investmentValueAccumulated),
totalInvestmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueAccumulatedWithCurrencyEffect),
totalNetPerformanceValue: (
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0)
).add(netPerformanceValue),
totalNetPerformanceValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0)
).add(netPerformanceValueWithCurrencyEffect),
totalTimeWeightedInvestmentValue: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValue ?? new Big(0)
).add(timeWeightedInvestmentValue),
totalTimeWeightedInvestmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(timeWeightedInvestmentValueWithCurrencyEffect)
};
}
}
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
const {
investmentValueWithCurrencyEffect,
totalCurrentValue,
totalCurrentValueWithCurrencyEffect,
totalInvestmentValue,
totalInvestmentValueWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect,
totalTimeWeightedInvestmentValue,
totalTimeWeightedInvestmentValueWithCurrencyEffect
} = values;
const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0)
? 0
: totalNetPerformanceValue
.div(totalTimeWeightedInvestmentValue)
.mul(100)
.toNumber();
const netPerformanceInPercentageWithCurrencyEffect =
totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0)
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(totalTimeWeightedInvestmentValueWithCurrencyEffect)
.mul(100)
.toNumber();
return {
date,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
investmentValueWithCurrencyEffect:
investmentValueWithCurrencyEffect.toNumber(),
netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
totalInvestmentValueWithCurrencyEffect:
totalInvestmentValueWithCurrencyEffect.toNumber(),
value: totalCurrentValue.toNumber(),
valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber()
};
});
}
public getDataProviderInfos() {
return this.dataProviderInfos;
}
public async getDividendInBaseCurrency() {
await this.snapshotPromise;
return getSum(
this.snapshot.positions.map(({ dividendInBaseCurrency }) => {
return dividendInBaseCurrency;
})
);
}
public async getFeesInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalFeesWithCurrencyEffect;
}
public async getInterestInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalInterestWithCurrencyEffect;
}
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
}
return this.transactionPoints.map((transactionPoint) => {
return {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
investment.plus(transactionPointSymbol.investment),
new Big(0)
)
};
});
}
public getInvestmentsByGroup({
data,
groupBy
}: {
data: HistoricalDataItem[];
groupBy: GroupBy;
}): InvestmentItem[] {
const groupedData: { [dateGroup: string]: Big } = {};
for (const { date, investmentValueWithCurrencyEffect } of data) {
const dateGroup =
groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4);
groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus(
investmentValueWithCurrencyEffect
);
}
return Object.keys(groupedData).map((dateGroup) => ({
date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`,
investment: groupedData[dateGroup].toNumber()
}));
}
public async getLiabilitiesInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalLiabilitiesWithCurrencyEffect;
}
public async getSnapshot() {
await this.snapshotPromise;
return this.snapshot;
}
public getStartDate() {
return this.transactionPoints.length > 0
? parseDate(this.transactionPoints[0].date)
: new Date();
}
protected abstract getSymbolMetrics({
dataSource,
end,
exchangeRates,
isChartMode,
marketSymbolMap,
start,
step,
symbol
}: {
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics;
public getTransactionPoints() {
return this.transactionPoints;
}
public async getValuablesInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalValuablesWithCurrencyEffect;
}
private computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
for (const {
fee,
date,
quantity,
SymbolProfile,
tags,
type,
unitPrice
} of this.orders) {
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
const factor = getFactor(type);
if (oldAccumulatedSymbol) {
let investment = oldAccumulatedSymbol.investment;
const newQuantity = quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
if (type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(unitPrice)
);
} else if (type === 'SELL') {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
}
currentTransactionPointItem = {
investment,
averagePrice: newQuantity.gt(0)
? investment.div(newQuantity)
: new Big(0),
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
quantity: newQuantity,
symbol: SymbolProfile.symbol,
tags: oldAccumulatedSymbol.tags.concat(tags),
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
currentTransactionPointItem = {
fee,
tags,
averagePrice: unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
dividend: new Big(0),
firstBuyDate: date,
investment: unitPrice.mul(quantity).mul(factor),
quantity: quantity.mul(factor),
symbol: SymbolProfile.symbol,
transactionCount: 1
};
}
currentTransactionPointItem.tags = uniqBy(
currentTransactionPointItem.tags,
'id'
);
symbols[SymbolProfile.symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(({ symbol }) => {
return symbol !== SymbolProfile.symbol;
});
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => {
return a.symbol?.localeCompare(b.symbol);
});
let fees = new Big(0);
if (type === 'FEE') {
fees = fee;
}
let interest = new Big(0);
if (type === 'INTEREST') {
interest = quantity.mul(unitPrice);
}
let liabilities = new Big(0);
if (type === 'LIABILITY') {
liabilities = quantity.mul(unitPrice);
}
let valuables = new Big(0);
if (type === 'ITEM') {
valuables = quantity.mul(unitPrice);
}
if (lastDate !== date || lastTransactionPoint === null) {
lastTransactionPoint = {
date,
fees,
interest,
liabilities,
valuables,
items: newItems
};
this.transactionPoints.push(lastTransactionPoint);
} else {
lastTransactionPoint.fees = lastTransactionPoint.fees.plus(fees);
lastTransactionPoint.interest =
lastTransactionPoint.interest.plus(interest);
lastTransactionPoint.items = newItems;
lastTransactionPoint.liabilities =
lastTransactionPoint.liabilities.plus(liabilities);
lastTransactionPoint.valuables =
lastTransactionPoint.valuables.plus(valuables);
}
lastDate = date;
}
}
private async initialize() {
this.snapshot = await this.computeSnapshot(this.startDate, this.endDate);
}
}

View File

@ -0,0 +1,195 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => {
const 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'
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.80000000000000396627'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627'
),
transactionCount: 3,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
});
});
});

View File

@ -1,11 +1,18 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -19,6 +26,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
@ -29,51 +37,63 @@ describe('PortfolioCalculator', () => {
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
{
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'
});
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 +106,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore();
expect(currentPositions).toEqual({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
@ -129,14 +149,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([

View File

@ -1,11 +1,18 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -19,6 +26,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
@ -29,40 +37,48 @@ describe('PortfolioCalculator', () => {
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
{
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'
});
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 +91,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore();
expect(currentPositions).toEqual({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('297.8'),
errors: [],
grossPerformance: new Big('24.6'),
@ -118,14 +134,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([

View File

@ -1,12 +1,19 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -32,6 +39,7 @@ jest.mock(
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
@ -42,51 +50,63 @@ describe('PortfolioCalculator', () => {
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('get current positions', () => {
it.only('with BTCUSD buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
{
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'
});
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 +119,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore();
expect(currentPositions).toEqual({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('13298.425356'),
errors: [],
grossPerformance: new Big('27172.74'),
@ -146,7 +166,7 @@ describe('PortfolioCalculator', () => {
),
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: undefined,
tags: [],
timeWeightedInvestment: new Big('640.56763686131386861314'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79469348020066587024'
@ -155,8 +175,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([

View File

@ -0,0 +1,134 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('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'
});
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')
});
});
});
});

View File

@ -1,12 +1,19 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -32,6 +39,7 @@ jest.mock(
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
@ -42,40 +50,48 @@ describe('PortfolioCalculator', () => {
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('get current positions', () => {
it.only('with GOOGL buy', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
{
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'
});
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 +104,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore();
expect(currentPositions).toEqual({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('103.10483'),
errors: [],
grossPerformance: new Big('27.33'),
@ -131,15 +147,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([

View File

@ -0,0 +1,134 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('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'
});
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')
});
});
});
});

View File

@ -0,0 +1,134 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('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('2022-01-01'),
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'
});
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('3000'),
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: 3000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: '55196015-1365-4560-aa60-8751ae6d18f8',
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')
});
});
});
});

View File

@ -1,12 +1,19 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
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 {
@ -32,6 +39,7 @@ jest.mock(
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
@ -42,53 +50,65 @@ describe('PortfolioCalculator', () => {
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
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(
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-16'),
fee: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPrice: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPrice: 0.62
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD'
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-07-10')
);
spy.mockRestore();
expect(currentPositions).toMatchObject({
expect(portfolioSnapshot).toMatchObject({
errors: [],
hasErrors: false,
positions: [
@ -106,12 +126,16 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 331.83,
quantity: new Big('1'),
symbol: 'MSFT',
tags: undefined,
tags: [],
transactionCount: 2
}
],
totalFeesWithCurrencyEffect: new Big('19'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58')
totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
});
});

View File

@ -1,11 +1,14 @@
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
import { Big } from 'big.js';
import { subDays } from 'date-fns';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -19,6 +22,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
@ -29,30 +33,31 @@ describe('PortfolioCalculator', () => {
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('get current positions', () => {
it('with no orders', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: []
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const chartData = await portfolioCalculator.getChartData({
start: new Date()
const portfolioCalculator = factory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF'
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
new Date()
);
const start = subDays(new Date(Date.now()), 10);
const chartData = await portfolioCalculator.getChartData({ start });
const portfolioSnapshot =
await portfolioCalculator.computeSnapshot(start);
const investments = portfolioCalculator.getInvestments();
@ -63,7 +68,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore();
expect(currentPositions).toEqual({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
@ -75,7 +80,12 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
positions: [],
totalInvestment: 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')
});
expect(investments).toEqual([]);

View File

@ -1,11 +1,18 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -19,6 +26,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
@ -29,51 +37,63 @@ describe('PortfolioCalculator', () => {
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
{
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'
});
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 +106,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore();
expect(currentPositions).toEqual({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
@ -129,6 +149,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 +158,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([

View File

@ -1,11 +1,18 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -19,6 +26,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
@ -29,51 +37,63 @@ describe('PortfolioCalculator', () => {
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
{
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'
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07')
);
@ -112,7 +132,7 @@ describe('PortfolioCalculator', () => {
valueWithCurrencyEffect: 0
});
expect(currentPositions).toEqual({
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('19.86'),
@ -155,14 +175,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([

View File

@ -0,0 +1,27 @@
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
factory = new PortfolioCalculatorFactory(
currentRateService,
exchangeRateDataService
);
});
test.skip('Skip empty test', () => 1);
});

View File

@ -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 })

View File

@ -1,4 +1,4 @@
import Big from 'big.js';
import { Big } from 'big.js';
import { PortfolioOrder } from './portfolio-order.interface';

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -1,8 +1,8 @@
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
import { Big } from 'big.js';
export interface CurrentPositions extends ResponseError {
export interface PortfolioSnapshot extends ResponseError {
currentValueInBaseCurrency: Big;
grossPerformance: Big;
grossPerformanceWithCurrencyEffect: Big;
@ -15,5 +15,10 @@ export interface CurrentPositions extends ResponseError {
netPerformancePercentage: Big;
netPerformancePercentageWithCurrencyEffect: Big;
positions: TimelinePosition[];
totalFeesWithCurrencyEffect: Big;
totalInterestWithCurrencyEffect: Big;
totalInvestment: Big;
totalInvestmentWithCurrencyEffect: Big;
totalLiabilitiesWithCurrencyEffect: Big;
totalValuablesWithCurrencyEffect: Big;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import Big from 'big.js';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
@ -76,8 +77,11 @@ export class PortfolioController {
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
@Query('tags') filterByTags?: string,
@Query('withMarkets') withMarketsParam = 'false'
): Promise<PortfolioDetails & { hasError: boolean }> {
const withMarkets = withMarketsParam === 'true';
let hasDetails = true;
let hasError = false;
const hasReadRestrictedAccessPermission =
@ -101,8 +105,8 @@ export class PortfolioController {
dateRange,
filters,
impersonationId,
withMarkets,
userId: this.request.user.id,
withLiabilities: true,
withSummary: true
});
@ -233,8 +237,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']
@ -242,7 +250,6 @@ export class PortfolioController {
let dividends = await this.portfolioService.getDividends({
activities,
dateRange,
groupBy
});
@ -281,12 +288,14 @@ 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('tags') filterByTags?: string
): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByHoldingType,
filterBySearchQuery,
filterByTags
});
@ -377,11 +386,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({
@ -400,7 +407,6 @@ export class PortfolioController {
filters,
impersonationId,
withExcludedAccounts,
withItems,
userId: this.request.user.id
});
@ -413,6 +419,7 @@ export class PortfolioController {
({
date,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netWorth,
totalInvestment,
value
@ -420,6 +427,7 @@ export class PortfolioController {
return {
date,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netWorthInPercentage:
performanceInformation.performance.currentNetWorth === 0
? 0
@ -525,7 +533,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 = {

View File

@ -15,6 +15,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';
@ -41,6 +42,7 @@ import { RulesService } from './rules.service';
AccountBalanceService,
AccountService,
CurrentRateService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]

View File

@ -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

View File

@ -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()
};
});
}
}

View File

@ -21,7 +21,7 @@ export class RedisCacheService {
}
public async get(key: string): Promise<string> {
return await this.cache.get(key);
return this.cache.get(key);
}
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
@ -29,15 +29,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')

View File

@ -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

View File

@ -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:

View File

@ -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;

View File

@ -135,7 +135,7 @@ export class UserController {
}
}
return await this.userService.updateUserSetting({
return this.userService.updateUserSetting({
userSettings,
userId: this.request.user.id
});

View File

@ -51,13 +51,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 +78,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 +98,7 @@ export class UserService {
};
}),
accounts: Account,
dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: {
...(<UserSettings>Settings.settings),
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
@ -452,14 +460,15 @@ export class UserService {
}
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('');
}
}

View File

@ -4,6 +4,8 @@
"LUNA1": "Terra",
"LUNA2": "Terra",
"SGB1": "Songbird",
"SMURFCAT": "Real Smurf Cat",
"TON11419": "Toncoin",
"UNI1": "Uniswap",
"UNI7083": "Uniswap",
"UST": "TerraUSD"

View File

@ -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>

View File

@ -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 {

View File

@ -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 };
}

View File

@ -7,19 +7,27 @@ 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;
}

View File

@ -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 {

View File

@ -11,6 +11,7 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
key: AccountClusterRiskSingleAccount.name,
name: 'Single Account'
});

View File

@ -11,6 +11,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name,
name: 'Investment: Base Currency'
});
@ -43,7 +44,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

View File

@ -11,6 +11,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
key: CurrencyClusterRiskCurrentInvestment.name,
name: 'Investment'
});
@ -37,7 +38,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 +53,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
}%`,

View File

@ -11,6 +11,7 @@ export class EmergencyFundSetup extends Rule<Settings> {
emergencyFund: number
) {
super(exchangeRateDataService, {
key: EmergencyFundSetup.name,
name: 'Emergency Fund: Set up'
});

View File

@ -13,6 +13,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
fees: number
) {
super(exchangeRateDataService, {
key: FeeRatioInitialInvestment.name,
name: 'Fee Ratio'
});

View File

@ -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,

View File

@ -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
}
};
})

View File

@ -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;

View File

@ -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

View File

@ -5,12 +5,12 @@ import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import { countries } from 'countries-list';
import got from 'got';
@Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = {
'Russian Federation': 'Russia'
};
@ -131,20 +131,19 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.countries as unknown as Country[]).length === 0
) {
response.countries = [];
for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {}
)) {
let countryCode: string;
for (const [key, country] of Object.entries<any>(
TrackinsightDataEnhancerService.countries
)) {
for (const [code, country] of Object.entries(countries)) {
if (
country.name === name ||
country.name ===
TrackinsightDataEnhancerService.countriesMapping[name]
) {
countryCode = key;
countryCode = code;
break;
}
}

View File

@ -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;

View File

@ -241,37 +241,44 @@ export class EodHistoricalDataService implements DataProviderInterface {
})
);
response = quotes.reduce(
(
result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp }
) => {
const currency = symbolProfiles.find(({ symbol }) => {
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 (isNumber(close)) {
result[this.convertFromEodSymbol(code)] = {
currency:
currency ??
this.convertFromEodSymbol(code)?.replace(DEFAULT_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'
);
if (!currency) {
const { items } = await this.search({ query: code });
if (items.length === 1) {
currency = items[0].currency;
}
}
return result;
},
{}
);
if (isNumber(close)) {
response[this.convertFromEodSymbol(code)] = {
currency,
dataSource: this.getName(),
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
};
} else {
Logger.error(
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`,
'EodHistoricalDataService'
);
}
}
return response;
} catch (error) {

View File

@ -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 } }
});
@ -189,9 +193,8 @@ export class SymbolProfileService {
return {
code,
weight,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY
continent: continents[countries[code]?.continent] ?? UNKNOWN_KEY,
name: countries[code]?.name ?? UNKNOWN_KEY
};
});
}

View File

@ -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"

View File

@ -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>

View File

@ -1,5 +1,3 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
min-height: 100svh;

View File

@ -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);
}
}

View File

@ -1,5 +1,3 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;

View File

@ -1,3 +1,4 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
@ -21,7 +22,7 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import Big from 'big.js';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash';
import { Subject } from 'rxjs';
@ -41,7 +42,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public activities: OrderWithAccount[];
public balance: number;
public currency: string;
public dataSource: MatTableDataSource<OrderWithAccount>;
public dataSource: MatTableDataSource<Activity>;
public equity: number;
public hasPermissionToDeleteAccountBalance: boolean;
public historicalDataItems: HistoricalDataItem[];

View File

@ -34,7 +34,7 @@
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-symbol-icon
<gf-asset-profile-icon
*ngIf="element.Platform?.url"
class="d-inline d-sm-none mr-1"
[tooltip]="element.Platform?.name"
@ -73,7 +73,7 @@
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon
<gf-asset-profile-icon
*ngIf="element.Platform?.url"
class="mr-1"
[tooltip]="element.Platform?.name"

View File

@ -1,5 +1,3 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;

View File

@ -1,4 +1,4 @@
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfValueModule } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
@ -17,7 +17,7 @@ import { AccountsTableComponent } from './accounts-table.component';
exports: [AccountsTableComponent],
imports: [
CommonModule,
GfSymbolIconModule,
GfAssetProfileIconComponent,
GfValueModule,
MatButtonModule,
MatMenuModule,

View File

@ -1,6 +1,11 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces';
@ -24,6 +29,11 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-jobs.html'
})
export class AdminJobsComponent implements OnDestroy, OnInit {
public DATA_GATHERING_QUEUE_PRIORITY_LOW = DATA_GATHERING_QUEUE_PRIORITY_LOW;
public DATA_GATHERING_QUEUE_PRIORITY_HIGH =
DATA_GATHERING_QUEUE_PRIORITY_HIGH;
public DATA_GATHERING_QUEUE_PRIORITY_MEDIUM =
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM;
public defaultDateTimeFormat: string;
public filterForm: FormGroup;
public dataSource: MatTableDataSource<AdminJobs['jobs'][0]> =
@ -33,6 +43,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
'type',
'symbol',
'dataSource',
'priority',
'attempts',
'created',
'finished',
@ -98,6 +109,15 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
});
}
public onExecuteJob(aId: string) {
this.adminService
.executeJob(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchJobs();
});
}
public onViewData(aData: AdminJobs['jobs'][0]['data']) {
alert(JSON.stringify(aData, null, ' '));
}

View File

@ -58,6 +58,25 @@
</td>
</ng-container>
<ng-container matColumnDef="priority">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Priority</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
@if (element.opts.priority === DATA_GATHERING_QUEUE_PRIORITY_LOW) {
<ion-icon class="h6 mb-0" name="chevron-down-circle-outline" />
} @else if (
element.opts.priority === DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
) {
<ion-icon class="h6 mb-0" name="remove-circle-outline" />
} @else if (
element.opts.priority === DATA_GATHERING_QUEUE_PRIORITY_HIGH
) {
<ion-icon class="h6 mb-0" name="chevron-up-circle-outline" />
}
</td>
</ng-container>
<ng-container matColumnDef="attempts">
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
<ng-container i18n>Attempts</ng-container>
@ -90,24 +109,37 @@
<ng-container i18n>Status</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ion-icon *ngIf="element.state === 'active'" name="play-outline" />
<ion-icon
*ngIf="element.state === 'active'"
class="h6 mb-0"
name="play-outline"
/>
<ion-icon
*ngIf="element.state === 'completed'"
class="text-success"
class="h6 mb-0 text-success"
name="checkmark-circle-outline"
/>
<ion-icon
*ngIf="element.state === 'delayed'"
class="h6 mb-0"
name="time-outline"
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
/>
<ion-icon
*ngIf="element.state === 'failed'"
class="text-danger"
class="h6 mb-0 text-danger"
name="alert-circle-outline"
/>
<ion-icon *ngIf="element.state === 'paused'" name="pause-outline" />
<ion-icon *ngIf="element.state === 'waiting'" name="cafe-outline" />
<ion-icon
*ngIf="element.state === 'paused'"
class="h6 mb-0"
name="pause-outline"
/>
<ion-icon
*ngIf="element.state === 'waiting'"
class="h6 mb-0"
name="cafe-outline"
/>
</td>
</ng-container>
@ -147,6 +179,9 @@
>
<ng-container i18n>View Stacktrace</ng-container>
</button>
<button mat-menu-item (click)="onExecuteJob(element.id)">
<ng-container i18n>Execute Job</ng-container>
</button>
<button mat-menu-item (click)="onDeleteJob(element.id)">
<ng-container i18n>Delete Job</ng-container>
</button>

View File

@ -1,5 +1,3 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

View File

@ -9,11 +9,7 @@
[showYAxis]="true"
[symbol]="symbol"
/>
<div
*ngFor="let itemByMonth of marketDataByMonth | keyvalue"
class="d-flex"
[hidden]="!marketData.length > 0"
>
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 px-1">
<div

View File

@ -1,5 +1,3 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
font-size: 0.9rem;

View File

@ -19,15 +19,17 @@ import { MatDialog } from '@angular/material/dialog';
import { DataSource, MarketData } from '@prisma/client';
import {
addDays,
addMonths,
format,
isBefore,
isSameDay,
isToday,
isValid,
min,
parse,
parseISO
} from 'date-fns';
import { last } from 'lodash';
import { first, last } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
@ -135,6 +137,27 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
marketPrice: marketDataItem.marketPrice
};
}
if (this.dateOfFirstActivity) {
// Fill up missing months
const dates = Object.keys(this.marketDataByMonth).sort();
const startDate = min([
parseISO(this.dateOfFirstActivity),
parseISO(first(dates))
]);
const endDate = parseISO(last(dates));
let currentDate = startDate;
while (isBefore(currentDate, endDate)) {
const key = format(currentDate, 'yyyy-MM');
if (!this.marketDataByMonth[key]) {
this.marketDataByMonth[key] = {};
}
currentDate = addMonths(currentDate, 1);
}
}
}
public isDateOfInterest(aDateString: string) {
@ -155,15 +178,14 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
day: string;
yearMonth: string;
}) {
const date = parseISO(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: <MarketDataDetailDialogParams>{
date,
marketPrice,
currency: this.currency,
dataSource: this.dataSource,
dateString: `${yearMonth}-${day}`,
symbol: this.symbol,
user: this.user
},

View File

@ -5,7 +5,7 @@ import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams {
currency: string;
dataSource: DataSource;
date: Date;
dateString: string;
marketPrice: number;
symbol: string;
user: User;

View File

@ -45,7 +45,7 @@ export class MarketDataDetailDialog implements OnDestroy {
this.adminService
.fetchSymbolForDate({
dataSource: this.data.dataSource,
date: this.data.date,
dateString: this.data.dateString,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
@ -63,7 +63,7 @@ export class MarketDataDetailDialog implements OnDestroy {
marketData: {
marketData: [
{
date: this.data.date.toISOString(),
date: this.data.dateString,
marketPrice: this.data.marketPrice
}
]

View File

@ -9,7 +9,7 @@
matInput
name="date"
[matDatepicker]="date"
[(ngModel)]="data.date"
[(ngModel)]="data.dateString"
/>
<mat-datepicker-toggle class="mr-2" matSuffix [for]="date">
<ion-icon

View File

@ -1,6 +1,10 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import {
DEFAULT_PAGE_SIZE,
ghostfolioScraperApiSymbolPrefix
} from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
@ -20,12 +24,13 @@ import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { AssetSubClass, DataSource } from '@prisma/client';
import { AssetSubClass, DataSource, SymbolProfile } from '@prisma/client';
import { isUUID } from 'class-validator';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AdminMarketDataService } from './admin-market-data.service';
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
@ -78,6 +83,7 @@ export class AdminMarketDataComponent
type: <Filter['type']>'PRESET_ID'
}
]);
public benchmarks: Partial<SymbolProfile>[];
public currentDataSource: DataSource;
public currentSymbol: string;
public dataSource: MatTableDataSource<AdminMarketDataItem> =
@ -98,6 +104,7 @@ export class AdminMarketDataComponent
'actions'
];
public filters$ = new Subject<Filter[]>();
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public isLoading = false;
public isUUID = isUUID;
public placeholder = '';
@ -108,8 +115,10 @@ export class AdminMarketDataComponent
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminMarketDataService: AdminMarketDataService,
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
@ -169,6 +178,9 @@ export class AdminMarketDataComponent
}
public ngOnInit() {
const { benchmarks } = this.dataService.fetchInfo();
this.benchmarks = benchmarks;
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
@ -181,20 +193,7 @@ export class AdminMarketDataComponent
}
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
const confirmation = confirm(
$localize`Do you really want to delete this asset profile?`
);
if (confirmation) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
}
public onGather7Days() {
@ -293,7 +292,16 @@ export class AdminMarketDataComponent
.subscribe(({ count, marketData }) => {
this.totalItems = count;
this.dataSource = new MatTableDataSource(marketData);
this.dataSource = new MatTableDataSource(
marketData.map((marketDataItem) => {
return {
...marketDataItem,
isBenchmark: this.benchmarks.some(({ id }) => {
return id === marketDataItem.id;
})
};
})
);
this.dataSource.sort = this.sort;
this.isLoading = false;
@ -320,6 +328,7 @@ export class AdminMarketDataComponent
data: <AssetProfileDialogParams>{
dataSource,
symbol,
colorScheme: this.user?.settings.colorScheme,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},

View File

@ -161,23 +161,27 @@
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button
<a
mat-menu-item
(click)="
onOpenAssetProfileDialog({
dataSource: element.dataSource,
symbol: element.symbol
})
"
[queryParams]="{
assetProfileDialog: true,
dataSource: element.dataSource,
symbol: element.symbol
}"
[routerLink]="[]"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span>
</span>
</button>
</a>
<button
mat-menu-item
[disabled]="element.activitiesCount !== 0"
[disabled]="
element.activitiesCount !== 0 ||
element.isBenchmark ||
element.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
"
(click)="
onDeleteProfileData({
dataSource: element.dataSource,

View File

@ -12,6 +12,7 @@ import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminMarketDataComponent } from './admin-market-data.component';
import { AdminMarketDataService } from './admin-market-data.service';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
@ -31,6 +32,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
NgxSkeletonLoaderModule,
RouterModule
],
providers: [AdminMarketDataService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataModule {}

View File

@ -1,5 +1,3 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

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