Compare commits

..

141 Commits

Author SHA1 Message Date
d9c07456cd Release 2.78.0 (#3361) 2024-05-02 20:32:47 +02:00
0a53df4293 Feature/improve inactive user role (#3360)
* Improve inactive role

* Update changelog
2024-05-02 20:31:20 +02:00
4416ba0c88 Feature/set performance column of holdings table to stick at end (#3353)
* Set up stickyEnd in performance column

* Update changelog
2024-05-02 19:16:59 +02:00
486de968a2 Bugfix/fix division by zero error in dividend yield calculation (#3354)
* Handle division by zero

* Update changelog
2024-05-02 17:53:34 +02:00
a5833566a8 Feature/skip caching in portfolio calculator if active filters (#3348)
* Skip caching if active filters

* Update changelog
2024-05-02 17:52:39 +02:00
261f5844dd Add type column to README.md (#3295) 2024-04-30 14:08:14 +02:00
2173c418a7 Feature/validate forms using DTO for access, asset profile, tag and platform management (#3337)
* Validate forms using DTO for access, asset profile, tag and platform management

* Update changelog
2024-04-30 08:04:45 +02:00
4efd5cefd8 Bugfix/calculation of portfolio summary caused by future liabilities (#3342)
* Adapt date of future activities

* Update changelog
2024-04-29 20:12:12 +02:00
d735e4db75 Release 2.77.1 (#3340) 2024-04-27 19:25:23 +02:00
e10707fde4 Add missing guard to fix public page (#3339) 2024-04-27 19:24:08 +02:00
ac953df809 Release 2.77.0 (#3338) 2024-04-27 15:37:48 +02:00
bb4ee50738 Feature/update browserslist database 20240417 (#3288)
* Update the browserslist database

* Update changelog
2024-04-27 15:35:57 +02:00
4f41bac328 Feature/set up caching in portfolio calculator (#3335)
* Set up caching

* Update changelog
2024-04-27 15:35:28 +02:00
cd07802400 Bugfix/fix historical market data gathering for asset profiles with manual data source (#3336)
* Fix historical market data gathering for asset profiles with MANUAL data source

* Update changelog
2024-04-27 15:29:32 +02:00
b692b7432c Feature/set up event system for portfolio changes (#3333)
* Set up event system for portfolio changes

* Update changelog
2024-04-26 20:13:53 +02:00
a4efbc0131 Feature/migrate UI components to control flow (#3324)
* Migrate to control flow

* Update changelog
2024-04-26 17:40:00 +02:00
55b0fe232c Bugfix/reset form values to null if empty string (#3327)
* Reset form values to null if empty string

* Update changelog
2024-04-25 19:04:28 +02:00
46432edce9 Feature/extend faq by custom asset instructions (#3326)
* Add instructions for custom asset

* Update changelog
2024-04-24 20:35:39 +02:00
990028316e Refactor form controls to form getter (#3325) 2024-04-24 20:20:56 +02:00
37871fbabc Feature/upgrade prisma to version 5.13.0 (#3323)
* Upgrade prisma to version 5.13.0

* Update changelog
2024-04-24 20:20:09 +02:00
0fdeef7953 Release 2.76.0 (#3322) 2024-04-23 18:58:43 +02:00
cdbe6eedeb Feature/change cash to liquidity in asset class enum (#3321)
* Change CASH to LIQUIDITY in asset class enum

* Update changelog
2024-04-23 18:55:37 +02:00
a6dde8ad43 Release 2.75.1 (#3317) 2024-04-21 17:14:35 +02:00
39bd4a349b Feature/improve chart in account detail dialog (#3314)
* Improve net worth calculation in portfolio performance chart

* Improve account balance management

* Update changelog
2024-04-21 17:11:53 +02:00
ab59eb5c92 Release 2.75.0 (#3316) 2024-04-21 10:30:14 +02:00
1132dc9bdd Feature/add unique constraint to account balance database schema (#3315)
* Add accountId and date as unique constraint to AccountBalance schema

* Update changelog
2024-04-21 10:28:51 +02:00
2d70b18593 Remove links (#3306) 2024-04-21 10:08:22 +02:00
b6ea7d23fa Bugfix/add total value in base currency to redacted values (#3313)
* Add totalValueInBaseCurrency

* Update changelog
2024-04-21 08:33:38 +02:00
95382581f1 Release 2.74.0 (#3312) 2024-04-20 15:43:03 +02:00
551b83a6e3 Bugfix/fix gaps in portfolio performance charts (#3311)
* Fix gaps in charts

* Update changelog
2024-04-20 15:40:59 +02:00
895c4fe299 Improve style on mobile (#3310) 2024-04-20 12:54:23 +02:00
ccb1bf881e Feature/improve language localization for de 20240420 (#3309)
* Update translations

* Update changelog
2024-04-20 12:54:06 +02:00
7788b5a987 Improve messages (#3308) 2024-04-20 12:53:51 +02:00
47fd029e0c Clean up styles (#3305) 2024-04-20 12:15:21 +02:00
458ee159e1 Feature/upgrade nx to version 18.3.3 (#3307)
* Upgrade angular and Nx dependencies

* Update changelog
2024-04-20 12:14:50 +02:00
dfacbed66d Feature/add support to create account cash balances (#3260)
* Add support to create account cash balances

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-04-20 11:00:00 +02:00
22d63c6102 Improve logging (#3304)
* Improve logging
2024-04-20 10:49:18 +02:00
212aa6a63b Feature/add date range support to portfolio holdings page (#3303)
* Add date range support

* Update changelog
2024-04-20 09:51:12 +02:00
bed9ae916c Migrate UI components to standalone (#3302) 2024-04-19 20:57:47 +02:00
b6ad362850 Feature/remove date range support in activities table on portfolio activities page (#3301)
* Remove date range support

* Update changelog
2024-04-19 20:44:28 +02:00
ab86dd5318 Add Home Assistant (#3291) 2024-04-19 20:43:52 +02:00
dba73d80a3 Improve logging (#3300) 2024-04-19 20:31:38 +02:00
92fb05320a Migrate UI components to standalone (#3296) 2024-04-19 18:31:35 +02:00
73d62bb51f Migrate UI components to standalone (#3290) 2024-04-18 20:46:12 +02:00
127b7d4f25 Make pre-commit executable by default (#3283) 2024-04-18 18:39:20 +02:00
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
364 changed files with 26491 additions and 7407 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 # CACHE
REDIS_HOST=localhost REDIS_HOST=localhost
@ -10,6 +10,7 @@ POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD> POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING> ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING> JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

View File

@ -5,6 +5,260 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.78.0 - 2024-05-02
### Added
- Added a form validation against the DTO in the create or update access dialog
- Added a form validation against the DTO in the asset profile details dialog of the admin control
- Added a form validation against the DTO in the platform management of the admin control panel
- Added a form validation against the DTO in the tag management of the admin control panel
### Changed
- Set the performance column of the holdings table to stick at the end
- Skipped the caching in the portfolio calculator if there are active filters (experimental)
- Improved the `INACTIVE` user role
### Fixed
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
- Fixed a division by zero error in the dividend yield calculation (experimental)
## 2.77.1 - 2024-04-27
### Added
- Extended the content of the _Self-Hosting_ section by the custom asset instructions on the Frequently Asked Questions (FAQ) page
- Added the caching to the portfolio calculator (experimental)
### Changed
- Migrated the `@ghostfolio/ui` components to control flow
- Updated the browserslist database
- Upgraded `prisma` from version `5.12.1` to `5.13.0`
### Fixed
- Fixed the form submit in the asset profile details dialog of the admin control due to the `url` validation
- Fixed the historical market data gathering for asset profiles with `MANUAL` data source
## 2.76.0 - 2024-04-23
### Changed
- Changed `CASH` to `LIQUIDITY` in the asset class enum
## 2.75.1 - 2024-04-21
### Added
- Added `accountId` and `date` as a unique constraint to the `AccountBalance` database schema
### Changed
- Improved the chart in the account detail dialog
- Improved the account balance management
### Fixed
- Fixed an issue with `totalValueInBaseCurrency` in the value redaction interceptor for the impersonation mode
## 2.74.0 - 2024-04-20
### Added
- Added the date range support to the portfolio holdings page
- Added support to create an account balance
### Changed
- Removed the date range support in the activities table on the portfolio activities page (experimental)
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `17.3.3` to `17.3.5`
- Upgraded `Nx` from version `18.2.3` to `18.3.3`
### Fixed
- Fixed gaps in the portfolio performance charts by considering `BUY` and `SELL` activities
## 2.73.0 - 2024-04-17
### Added
- Added a form validation against the DTO in the create or update account dialog
- Added a form validation against the DTO in the create or update activity dialog
### Changed
- Moved the dividend calculations into the portfolio calculator
- Moved the fee calculations into the portfolio calculator
- Moved the interest calculations into the portfolio calculator
- Moved the liability calculations into the portfolio calculator
- Moved the (wealth) item calculations into the portfolio calculator
- Let queue jobs for asset profile data gathering fail by throwing an error
- Let queue jobs for historical market data gathering fail by throwing an error
- Upgraded `yahoo-finance2` from version `2.11.1` to `2.11.2`
## 2.72.0 - 2024-04-13
### Added
- Added support to immediately execute a queue job from the admin control panel
- Added a priority column to the queue jobs view in the admin control panel
### Changed
- Adapted the priorities of queue jobs
- Upgraded `angular` from version `17.2.4` to `17.3.3`
- Upgraded `Nx` from version `18.1.2` to `18.2.3`
- Upgraded `prisma` from version `5.11.0` to `5.12.1`
- Upgraded `yahoo-finance2` from version `2.11.0` to `2.11.1`
### Fixed
- Fixed an issue in the public page
## 2.71.0 - 2024-04-07
### Added
- Added the dividend yield to the position detail dialog (experimental)
- Added support to override the asset class of an asset profile in the asset profile details dialog of the admin control
- Added support to override the asset sub class of an asset profile in the asset profile details dialog of the admin control
- Added support to override the url of an asset profile in the asset profile details dialog of the admin control
- Added the asset profile icon to the asset profile details dialog of the admin control
- Added the platform icon to the create or update platform dialog of the admin control
- Extended the rules in the _X-ray_ section by a `key`
- Added `currency` to the `Order` database schema as a preparation to set a custom currency
- Extended the content of the _Self-Hosting_ section by the data providers on the Frequently Asked Questions (FAQ) page
### Changed
- Optimized the calculation of allocations by market
- Improved the url validation in the create and update platform endpoint
- Improved the language localization for German (`de`)
### Fixed
- Fixed the missing tags in the portfolio calculations
## 2.70.0 - 2024-04-02
### Added
- Set up the language localization for Chinese (`zh`)
- Added `init: true` to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`) to avoid zombie processes
- Set up _Webpack Bundle Analyzer_
### Changed
- Disabled the option to update the cash balance of an account if date is not today
- Improved the usability of the date range support by specific years (`2023`, `2022`, `2021`, etc.) in the assistant (experimental)
- Introduced a factory for the portfolio calculations to support different algorithms in future
### Fixed
- Fixed the duplicated tags in the position detail dialog
- Removed `Tini` from the docker image
## 2.69.0 - 2024-03-30
### Added
- Added the date range support in the activities table on the portfolio activities page (experimental)
- Extended the date range support by specific years (`2021`, `2022`, `2023`, etc.) in the assistant (experimental)
- Set up `Tini` to avoid zombie processes and perform signal forwarding in docker image
### Changed
- Improved the usability to delete an asset profile in the historical market data table and the asset profile details dialog of the admin control
### Fixed
- Added missing dates to edit historical market data in the asset profile details dialog of the admin control panel
## 2.68.0 - 2024-03-29
### Added
- Extended the export functionality by the user 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 ## 2.63.2 - 2024-03-12
### Added ### Added

View File

@ -57,6 +57,7 @@ RUN apt update && apt install -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
CMD [ "yarn", "start:production" ] CMD [ "/ghostfolio/entrypoint.sh" ]

View File

@ -7,14 +7,12 @@
**Open Source Wealth Management Software** **Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) | [**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_) [**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.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) [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
</div> </div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation. **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
@ -87,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Default Value | Description | | Name | Type | Default Value | Description |
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API | | `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | | `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on | | `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) | | `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on | | `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `0` | The database index of _Redis_ | | `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | | The host where _Redis_ is running | | `REDIS_HOST` | string | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ | | `REDIS_PASSWORD` | string | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running | | `REDIS_PORT` | number | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds | | `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose ### Run with Docker Compose
@ -144,7 +142,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
### Home Server Systems (Community) ### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), Home Assistant, [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development
@ -154,7 +152,7 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
- [Node.js](https://nodejs.org/en/download) (version 18+) - [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone) - Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`) - Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup ### Setup
@ -206,7 +204,7 @@ Set the header for each request as follows:
"Authorization": "Bearer eyJh..." "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>`. 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 { try {
return await this.accessService.createAccess({ return this.accessService.createAccess({
alias: data.alias || undefined, alias: data.alias || undefined,
GranteeUser: data.granteeUserId GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }

View File

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -5,6 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Body,
Post,
Delete, Delete,
HttpException, HttpException,
Inject, Inject,
@ -17,14 +20,44 @@ import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service'; import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance') @Controller('account-balance')
export class AccountBalanceController { export class AccountBalanceController {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@HasPermission(permissions.createAccountBalance)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccountBalance(
@Body() data: CreateAccountBalanceDto
): Promise<AccountBalance> {
const account = await this.accountService.account({
id_userId: {
id: data.accountId,
userId: this.request.user.id
}
});
if (!account) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance,
date: data.date,
userId: account.userId
});
}
@HasPermission(permissions.deleteAccountBalance) @HasPermission(permissions.deleteAccountBalance)
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -32,10 +65,11 @@ export class AccountBalanceController {
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalance> { ): Promise<AccountBalance> {
const accountBalance = await this.accountBalanceService.accountBalance({ const accountBalance = await this.accountBalanceService.accountBalance({
id id,
userId: this.request.user.id
}); });
if (!accountBalance || accountBalance.userId !== this.request.user.id) { if (!accountBalance) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -43,7 +77,8 @@ export class AccountBalanceController {
} }
return this.accountBalanceService.deleteAccountBalance({ return this.accountBalanceService.deleteAccountBalance({
id id: accountBalance.id,
userId: accountBalance.userId
}); });
} }
} }

View File

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -10,6 +11,6 @@ import { AccountBalanceService } from './account-balance.service';
controllers: [AccountBalanceController], controllers: [AccountBalanceController],
exports: [AccountBalanceService], exports: [AccountBalanceService],
imports: [ExchangeRateDataModule, PrismaModule], imports: [ExchangeRateDataModule, PrismaModule],
providers: [AccountBalanceService] providers: [AccountBalanceService, AccountService]
}) })
export class AccountBalanceModule {} export class AccountBalanceModule {}

View File

@ -1,14 +1,21 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable() @Injectable()
export class AccountBalanceService { export class AccountBalanceService {
public constructor( public constructor(
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -24,20 +31,63 @@ export class AccountBalanceService {
}); });
} }
public async createAccountBalance( public async createOrUpdateAccountBalance({
data: Prisma.AccountBalanceCreateInput accountId,
): Promise<AccountBalance> { balance,
return this.prismaService.accountBalance.create({ date,
data userId
}: CreateAccountBalanceDto & {
userId: string;
}): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.upsert({
create: {
Account: {
connect: {
id_userId: {
userId,
id: accountId
}
}
},
date: resetHours(parseISO(date)),
value: balance
},
update: {
value: balance
},
where: {
accountId_date: {
accountId,
date: resetHours(parseISO(date))
}
}
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
return accountBalance;
} }
public async deleteAccountBalance( public async deleteAccountBalance(
where: Prisma.AccountBalanceWhereUniqueInput where: Prisma.AccountBalanceWhereUniqueInput
): Promise<AccountBalance> { ): Promise<AccountBalance> {
return this.prismaService.accountBalance.delete({ const accountBalance = await this.prismaService.accountBalance.delete({
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
);
return accountBalance;
} }
public async getAccountBalances({ public async getAccountBalances({

View File

@ -0,0 +1,12 @@
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
export class CreateAccountBalanceDto {
@IsUUID()
accountId: string;
@IsNumber()
balance: number;
@IsISO8601()
date: string;
}

View File

@ -1,11 +1,15 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -14,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface';
export class AccountService { export class AccountService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -85,17 +90,20 @@ export class AccountService {
data data
}); });
await this.prismaService.accountBalance.create({ await this.accountBalanceService.createOrUpdateAccountBalance({
data: { accountId: account.id,
Account: { balance: data.balance,
connect: { date: format(new Date(), DATE_FORMAT),
id_userId: { id: account.id, userId: aUserId } userId: aUserId
}
},
value: data.balance
}
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account; return account;
} }
@ -103,9 +111,18 @@ export class AccountService {
where: Prisma.AccountWhereUniqueInput, where: Prisma.AccountWhereUniqueInput,
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
return this.prismaService.account.delete({ const account = await this.prismaService.account.delete({
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
} }
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<Account[]> {
@ -196,21 +213,26 @@ export class AccountService {
): Promise<Account> { ): Promise<Account> {
const { data, where } = params; const { data, where } = params;
await this.prismaService.accountBalance.create({ await this.accountBalanceService.createOrUpdateAccountBalance({
data: { accountId: <string>data.id,
Account: { balance: <number>data.balance,
connect: { date: format(new Date(), DATE_FORMAT),
id_userId: where.id_userId userId: aUserId
}
},
value: <number>data.balance
}
}); });
return this.prismaService.account.update({ const account = await this.prismaService.account.update({
data, data,
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
} }
public async updateAccountBalance({ public async updateAccountBalance({
@ -242,17 +264,11 @@ export class AccountService {
); );
if (amountInCurrencyOfAccount) { if (amountInCurrencyOfAccount) {
await this.accountBalanceService.createAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
date, accountId,
Account: { userId,
connect: { balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
id_userId: { date: date.toISOString()
userId,
id: accountId
}
}
},
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
}); });
} }
} }

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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -94,7 +93,8 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...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, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...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, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...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, @Param('key') key: string,
@Body() data: PropertyDto @Body() data: PropertyDto
) { ) {
return await this.adminService.putSetting(key, data.value); return this.adminService.putSetting(key, data.value);
} }
} }

View File

@ -25,6 +25,7 @@ import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { import {
AssetClass,
AssetSubClass, AssetSubClass,
DataSource, DataSource,
Prisma, Prisma,
@ -71,7 +72,7 @@ export class AdminService {
); );
} }
return await this.symbolProfileService.add( return this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
); );
} catch (error) { } catch (error) {
@ -212,6 +213,7 @@ export class AdminService {
countries: true, countries: true,
currency: true, currency: true,
dataSource: true, dataSource: true,
id: true,
name: true, name: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
@ -235,6 +237,7 @@ export class AdminService {
countries, countries,
currency, currency,
dataSource, dataSource,
id,
name, name,
Order, Order,
sectors, sectors,
@ -257,6 +260,7 @@ export class AdminService {
currency, currency,
countriesCount, countriesCount,
dataSource, dataSource,
id,
name, name,
symbol, symbol,
marketDataItemCount, marketDataItemCount,
@ -329,21 +333,39 @@ export class AdminService {
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol,
symbolMapping symbolMapping,
url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({ const symbolProfileOverrides = {
assetClass, assetClass: assetClass as AssetClass,
assetSubClass, assetSubClass: assetSubClass as AssetSubClass,
comment, name: name as string,
countries, url: url as string
currency, };
dataSource,
name, const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
scraperConfiguration, {
sectors, comment,
symbol, countries,
symbolMapping 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([ const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ {
@ -394,9 +416,10 @@ export class AdminService {
dataSource, dataSource,
marketDataItemCount, marketDataItemCount,
symbol, symbol,
assetClass: 'CASH', assetClass: AssetClass.LIQUIDITY,
countriesCount: 0, countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''), currency: symbol.replace(DEFAULT_CURRENCY, ''),
id: undefined,
name: symbol, name: symbol,
sectorsCount: 0 sectorsCount: 0
}; };

View File

@ -46,4 +46,11 @@ export class QueueController {
public async deleteJob(@Param('id') id: string): Promise<void> { public async deleteJob(@Param('id') id: string): Promise<void> {
return this.queueService.deleteJob(id); 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({ public async getJobs({
limit = 1000, limit = 1000,
status = QUEUE_JOB_STATUS_LIST status = QUEUE_JOB_STATUS_LIST
@ -54,6 +58,7 @@ export class QueueService {
finishedOn: job.finishedOn, finishedOn: job.finishedOn,
id: job.id, id: job.id,
name: job.name, name: job.name,
opts: job.opts,
stacktrace: job.stacktrace, stacktrace: job.stacktrace,
state: await job.getState(), state: await job.getState(),
timestamp: job.timestamp timestamp: job.timestamp

View File

@ -5,7 +5,8 @@ import {
IsISO4217CurrencyCode, IsISO4217CurrencyCode,
IsObject, IsObject,
IsOptional, IsOptional,
IsString IsString,
IsUrl
} from 'class-validator'; } from 'class-validator';
export class UpdateAssetProfileDto { export class UpdateAssetProfileDto {
@ -46,4 +47,11 @@ export class UpdateAssetProfileDto {
symbolMapping?: { symbolMapping?: {
[dataProvider: string]: string; [dataProvider: string]: string;
}; };
@IsOptional()
@IsUrl({
protocols: ['https'],
require_protocol: true
})
url?: string;
} }

View File

@ -1,3 +1,4 @@
import { EventsModule } from '@ghostfolio/api/events/events.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service'; import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -14,6 +15,7 @@ import {
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
@ -44,6 +46,7 @@ import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@Module({ @Module({
controllers: [AppController],
imports: [ imports: [
AdminModule, AdminModule,
AccessModule, AccessModule,
@ -64,6 +67,8 @@ import { UserModule } from './user/user.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(),
EventsModule,
ExchangeRateModule, ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
@ -109,7 +114,6 @@ import { UserModule } from './user/user.module';
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],
controllers: [AppController],
providers: [CronService] providers: [CronService]
}) })
export class AppModule {} export class AppModule {}

View File

@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import * as countriesAndTimezones from 'countries-and-timezones'; import * as countriesAndTimezones from 'countries-and-timezones';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable() @Injectable()
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (user) { if (user) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country = const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return user; return user;
} else { } else {
throw ''; throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
} catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
throw error;
} else {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
} }
} catch (err) {
throw new UnauthorizedException('unauthorized', err.message);
} }
} }
} }

View File

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import type { import type {
@ -8,7 +9,7 @@ import type {
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -19,6 +20,7 @@ import {
Inject, Inject,
Param, Param,
Post, Post,
Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
@ -106,13 +108,18 @@ export class BenchmarkController {
public async getBenchmarkMarketDataBySymbol( public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max'
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString); const { endDate, startDate } = getInterval(
dateRange,
new Date(startDateString)
);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({ return this.benchmarkService.getMarketDataBySymbol({
dataSource, dataSource,
endDate,
startDate, startDate,
symbol, symbol,
userCurrency userCurrency

View File

@ -13,7 +13,8 @@ import {
import { import {
DATE_FORMAT, DATE_FORMAT,
calculateBenchmarkTrend, calculateBenchmarkTrend,
parseDate parseDate,
resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
Benchmark, Benchmark,
@ -26,8 +27,14 @@ import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
import { format, isSameDay, subDays } from 'date-fns'; import {
differenceInDays,
eachDayOfInterval,
format,
isSameDay,
subDays
} from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash'; import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -110,7 +117,9 @@ export class BenchmarkService {
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) }),
requestTimeout: ms('30 seconds'),
useCache: false
}); });
for (const { dataSource, symbol } of benchmarkAssetProfiles) { for (const { dataSource, symbol } of benchmarkAssetProfiles) {
@ -163,7 +172,7 @@ export class BenchmarkService {
await this.redisCacheService.set( await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS, this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks), JSON.stringify(benchmarks),
ms('4 hours') / 1000 ms('2 hours') / 1000
); );
} }
@ -206,15 +215,28 @@ export class BenchmarkService {
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
dataSource, dataSource,
endDate = new Date(),
startDate, startDate,
symbol, symbol,
userCurrency userCurrency
}: { }: {
endDate?: Date;
startDate: Date; startDate: Date;
userCurrency: string; userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> { } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = []; const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1;
const dates = eachDayOfInterval(
{
start: startDate,
end: endDate
},
{ step: Math.round(days / Math.min(days, MAX_CHART_ITEMS)) }
).map((date) => {
return resetHours(date);
});
const [currentSymbolItem, marketDataItems] = await Promise.all([ const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({ this.symbolService.get({
dataGatheringItem: { dataGatheringItem: {
@ -230,7 +252,7 @@ export class BenchmarkService {
dataSource, dataSource,
symbol, symbol,
date: { date: {
gte: startDate in: dates
} }
} }
}) })
@ -264,17 +286,7 @@ export class BenchmarkService {
return { marketData }; return { marketData };
} }
const step = Math.round(
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
);
let i = 0;
for (let marketDataItem of marketDataItems) { for (let marketDataItem of marketDataItems) {
if (i % step !== 0) {
continue;
}
const exchangeRate = const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT) format(marketDataItem.date, DATE_FORMAT)
@ -297,15 +309,15 @@ export class BenchmarkService {
}); });
} }
const includesToday = isSameDay( const includesEndDate = isSameDay(
parseDate(last(marketData).date), parseDate(last(marketData).date),
new Date() endDate
); );
if (currentSymbolItem?.marketPrice && !includesToday) { if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate = const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(new Date(), DATE_FORMAT) format(endDate, DATE_FORMAT)
]; ];
const exchangeRateFactor = const exchangeRateFactor =
@ -314,7 +326,7 @@ export class BenchmarkService {
: 1; : 1;
marketData.push({ marketData.push({
date: format(new Date(), DATE_FORMAT), date: format(endDate, DATE_FORMAT),
value: value:
this.calculateChangeInPercentage( this.calculateChangeInPercentage(
marketPriceAtStartDate, marketPriceAtStartDate,

View File

@ -95,7 +95,10 @@ export class ExportService {
: SymbolProfile.symbol : 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.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 { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
@ -27,7 +31,7 @@ import {
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns'; import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -112,12 +116,13 @@ export class ImportService {
accountId: Account?.id, accountId: Account?.id,
accountUserId: undefined, accountUserId: undefined,
comment: undefined, comment: undefined,
currency: undefined,
createdAt: undefined, createdAt: undefined,
fee: 0, fee: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
isDraft: false, isDraft: false,
SymbolProfile: <SymbolProfile>(<unknown>assetProfile), SymbolProfile: assetProfile,
symbolProfileId: assetProfile.id, symbolProfileId: assetProfile.id,
type: 'DIVIDEND', type: 'DIVIDEND',
unitPrice: marketPrice, unitPrice: marketPrice,
@ -261,6 +266,7 @@ export class ImportService {
{ {
accountId, accountId,
comment, comment,
currency,
date, date,
error, error,
fee, fee,
@ -285,7 +291,6 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
currency,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
@ -342,6 +347,7 @@ export class ImportService {
if (isDryRun) { if (isDryRun) {
order = { order = {
comment, comment,
currency,
date, date,
fee, fee,
quantity, quantity,
@ -357,7 +363,6 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
currency,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
@ -371,6 +376,7 @@ export class ImportService {
symbolMapping, symbolMapping,
updatedAt, updatedAt,
url, url,
currency: assetProfile.currency,
comment: assetProfile.comment comment: assetProfile.comment
}, },
Account: validatedAccount, Account: validatedAccount,
@ -394,9 +400,9 @@ export class ImportService {
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency,
dataSource, dataSource,
symbol symbol,
currency: assetProfile.currency
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
@ -410,6 +416,11 @@ export class ImportService {
User: { connect: { id: user.id } }, User: { connect: { id: user.id } },
userId: user.id userId: user.id
}); });
if (order.SymbolProfile?.symbol) {
// Update symbol that may have been assigned in createOrder()
assetProfile.symbol = order.SymbolProfile.symbol;
}
} }
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
@ -420,14 +431,14 @@ export class ImportService {
value, value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee, fee,
currency, assetProfile.currency,
userCurrency userCurrency
), ),
// @ts-ignore // @ts-ignore
SymbolProfile: assetProfile, SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
currency, assetProfile.currency,
userCurrency userCurrency
) )
}); });
@ -446,15 +457,16 @@ export class ImportService {
}); });
}); });
this.dataGatheringService.gatherSymbols( this.dataGatheringService.gatherSymbols({
uniqueActivities.map(({ date, SymbolProfile }) => { dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
return { return {
date, date,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol symbol: SymbolProfile.symbol
}; };
}) }),
); priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
} }
return activities; return activities;
@ -521,22 +533,14 @@ export class ImportService {
currency, currency,
dataSource, dataSource,
symbol, symbol,
assetClass: null, activitiesCount: undefined,
assetSubClass: null, assetClass: undefined,
comment: null, assetSubClass: undefined,
countries: null, countries: undefined,
createdAt: undefined, createdAt: undefined,
figi: null,
figiComposite: null,
figiShareClass: null,
id: undefined, id: undefined,
isin: null, sectors: undefined,
name: null, updatedAt: undefined
scraperConfiguration: null,
sectors: null,
symbolMapping: null,
updatedAt: undefined,
url: null
} }
}; };
} }

View File

@ -42,6 +42,10 @@ export class CreateOrderDto {
@IsISO4217CurrencyCode() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsISO4217CurrencyCode()
@IsOptional()
customCurrency?: string;
@IsOptional() @IsOptional()
@IsEnum(DataSource, { each: true }) @IsEnum(DataSource, { each: true })
dataSource?: DataSource; 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 { export interface Activities {
activities: Activity[]; activities: Activity[];
count: number; count: number;
} }
export interface Activity extends OrderWithAccount { export interface Activity extends Order {
Account?: AccountWithPlatform;
error?: ActivityError; error?: ActivityError;
feeInBaseCurrency: number; feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile;
tags?: Tag[];
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
value: number; value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;

View File

@ -1,14 +1,18 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -56,15 +60,15 @@ export class OrderController {
} }
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id }); const order = await this.orderService.order({
id,
userId: this.request.user.id
});
if ( if (!order) {
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -84,12 +88,20 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange?: DateRange,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('take') take?: number @Query('take') take?: number
): Promise<Activities> { ): Promise<Activities> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getInterval(dateRange));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -101,9 +113,11 @@ export class OrderController {
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { activities, count } = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
endDate,
filters, filters,
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,
@ -120,13 +134,22 @@ export class OrderController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { 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({ const order = await this.orderService.createOrder({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency: data.currency, currency,
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
}, },
@ -145,13 +168,16 @@ export class OrderController {
if (data.dataSource && !order.isDraft) { if (data.dataSource && !order.isDraft) {
// Gather symbol data in the background, if data source is set // Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft // (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols({
{ dataGatheringItems: [
dataSource: data.dataSource, {
date: order.date, dataSource: data.dataSource,
symbol: data.symbol date: order.date,
} symbol: data.symbol
]); }
],
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
} }
return order; return order;
@ -176,8 +202,16 @@ export class OrderController {
const date = parseISO(data.date); const date = parseISO(data.date);
const accountId = data.accountId; const accountId = data.accountId;
const customCurrency = data.customCurrency;
delete data.accountId; delete data.accountId;
if (customCurrency) {
data.currency = customCurrency;
delete data.customCurrency;
}
return this.orderService.updateOrder({ return this.orderService.updateOrder({
data: { data: {
...data, ...data,

View File

@ -1,9 +1,11 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -12,6 +14,7 @@ import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -21,9 +24,9 @@ import {
Tag, Tag,
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Activities } from './interfaces/activities.interface'; import { Activities } from './interfaces/activities.interface';
@ -33,6 +36,7 @@ export class OrderService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -65,7 +69,6 @@ export class OrderService {
} }
const accountId = data.accountId; const accountId = data.accountId;
let currency = data.currency;
const tags = data.tags ?? []; const tags = data.tags ?? [];
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
@ -73,7 +76,6 @@ export class OrderService {
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const id = uuidv4(); const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol; const name = data.SymbolProfile.connectOrCreate.create.symbol;
@ -81,7 +83,6 @@ export class OrderService {
data.id = id; data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass; data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name; data.SymbolProfile.connectOrCreate.create.name = name;
data.SymbolProfile.connectOrCreate.create.symbol = id; data.SymbolProfile.connectOrCreate.create.symbol = id;
@ -103,7 +104,8 @@ export class OrderService {
jobId: getAssetProfileIdentifier({ jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
}) }),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
} }
}); });
} }
@ -116,7 +118,6 @@ export class OrderService {
delete data.comment; delete data.comment;
} }
delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
@ -139,7 +140,8 @@ export class OrderService {
return { id }; return { id };
}) })
} }
} },
include: { SymbolProfile: true }
}); });
if (updateAccountBalance === true) { if (updateAccountBalance === true) {
@ -148,19 +150,26 @@ export class OrderService {
.plus(data.fee) .plus(data.fee)
.toNumber(); .toNumber();
if (data.type === 'BUY') { if (['BUY', 'FEE'].includes(data.type)) {
amount = new Big(amount).mul(-1).toNumber(); amount = new Big(amount).mul(-1).toNumber();
} }
await this.accountService.updateAccountBalance({ await this.accountService.updateAccountBalance({
accountId, accountId,
amount, amount,
currency,
userId, userId,
currency: data.SymbolProfile.connectOrCreate.create.currency,
date: data.date as Date date: data.date as Date
}); });
} }
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order; return order;
} }
@ -175,6 +184,13 @@ export class OrderService {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order; return order;
} }
@ -183,6 +199,13 @@ export class OrderService {
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
);
return count; return count;
} }
@ -198,22 +221,26 @@ export class OrderService {
} }
public async getOrders({ public async getOrders({
endDate,
filters, filters,
includeDrafts = false, includeDrafts = false,
skip, skip,
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate,
take = Number.MAX_SAFE_INTEGER, take = Number.MAX_SAFE_INTEGER,
types, types,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
skip?: number; skip?: number;
sortColumn?: string; sortColumn?: string;
sortDirection?: Prisma.SortOrder; sortDirection?: Prisma.SortOrder;
startDate?: Date;
take?: number; take?: number;
types?: ActivityType[]; types?: ActivityType[];
userCurrency: string; userCurrency: string;
@ -225,6 +252,18 @@ export class OrderService {
]; ];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
if (endDate || startDate) {
where.AND = [];
if (endDate) {
where.AND.push({ date: { lte: endDate } });
}
if (startDate) {
where.AND.push({ date: { gt: startDate } });
}
}
const { const {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
@ -320,7 +359,32 @@ export class OrderService {
this.prismaService.order.count({ where }) 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 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(); const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return { return {
@ -332,6 +396,7 @@ export class OrderService {
order.SymbolProfile.currency, order.SymbolProfile.currency,
userCurrency userCurrency
), ),
SymbolProfile: assetProfile,
// TODO: Use exchange rate of date // TODO: Use exchange rate of date
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
@ -388,19 +453,22 @@ export class OrderService {
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols({
{ dataGatheringItems: [
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource, {
date: <Date>data.date, dataSource:
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol 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.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
@ -411,7 +479,7 @@ export class OrderService {
where where
}); });
return this.prismaService.order.update({ const order = await this.prismaService.order.update({
data: { data: {
...data, ...data,
isDraft, isDraft,
@ -423,6 +491,15 @@ export class OrderService {
}, },
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order;
} }
private async orders(params: { private async orders(params: {

View File

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

View File

@ -1,9 +1,12 @@
import { IsString } from 'class-validator'; import { IsString, IsUrl } from 'class-validator';
export class CreatePlatformDto { export class CreatePlatformDto {
@IsString() @IsString()
name: string; name: string;
@IsString() @IsUrl({
protocols: ['https'],
require_protocol: true
})
url: string; url: string;
} }

View File

@ -1,4 +1,4 @@
import { IsString } from 'class-validator'; import { IsString, IsUrl } from 'class-validator';
export class UpdatePlatformDto { export class UpdatePlatformDto {
@IsString() @IsString()
@ -7,6 +7,9 @@ export class UpdatePlatformDto {
@IsString() @IsString()
name: string; name: string;
@IsString() @IsUrl({
protocols: ['https'],
require_protocol: true
})
url: string; url: string;
} }

View File

@ -0,0 +1,33 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected getSymbolMetrics({
dataSource,
end,
exchangeRates,
isChartMode = false,
marketSymbolMap,
start,
step = 1,
symbol
}: {
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
throw new Error('Method not implemented.');
}
}

View File

@ -0,0 +1,30 @@
export const activityDummyData = {
accountId: undefined,
accountUserId: undefined,
comment: undefined,
createdAt: new Date(),
currency: undefined,
feeInBaseCurrency: undefined,
id: undefined,
isDraft: false,
symbolProfileId: undefined,
updatedAt: new Date(),
userId: undefined,
value: undefined,
valueInBaseCurrency: undefined
};
export const symbolProfileDummyData = {
activitiesCount: undefined,
assetClass: undefined,
assetSubClass: undefined,
countries: [],
createdAt: undefined,
id: undefined,
sectors: [],
updatedAt: undefined
};
export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
};

View File

@ -0,0 +1,81 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return
TWR = 'TWR' // Time-Weighted Rate of Return
}
@Injectable()
export class PortfolioCalculatorFactory {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService
) {}
public createCalculator({
accountBalanceItems = [],
activities,
calculationType,
currency,
dateRange = 'max',
hasFilters,
isExperimentalFeatures = false,
userId
}: {
accountBalanceItems?: HistoricalDataItem[];
activities: Activity[];
calculationType: PerformanceCalculationType;
currency: string;
dateRange?: DateRange;
hasFilters: boolean;
isExperimentalFeatures?: boolean;
userId: string;
}): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
default:
throw new Error('Invalid calculation type');
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,22 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -29,62 +54,84 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => { it.only('with BALN.SW buy and sell in two activities', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2021-11-22',
dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
type: 'BUY',
unitPrice: new Big(142.9)
},
{
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(1.65),
name: 'Bâloise Holding AG',
quantity: new Big(1),
symbol: 'BALN.SW',
type: 'SELL',
unitPrice: new Big(136.6)
},
{
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bâloise Holding AG',
quantity: new Big(1),
symbol: 'BALN.SW',
type: 'SELL',
unitPrice: new Big(136.6)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime()); .mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22') start: parseDate('2021-11-22')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22') parseDate('2021-11-22')
); );
@ -97,7 +144,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
@ -140,6 +187,7 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'BALN.SW', symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.80000000000000396627'), timeWeightedInvestment: new Big('285.80000000000000396627'),
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627' '285.80000000000000396627'
@ -148,8 +196,12 @@ describe('PortfolioCalculator', () => {
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: 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([ expect(investments).toEqual([

View File

@ -1,11 +1,22 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => { it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
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 const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime()); .mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22') start: parseDate('2021-11-22')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22') parseDate('2021-11-22')
); );
@ -86,7 +129,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
@ -129,14 +172,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'BALN.SW', symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.8'), timeWeightedInvestment: new Big('285.8'),
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
transactionCount: 2, transactionCount: 2,
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: 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([ expect(investments).toEqual([

View File

@ -1,11 +1,22 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -29,40 +54,54 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy', async () => { it.only('with BALN.SW buy', async () => {
const portfolioCalculator = new PortfolioCalculator({
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 const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime()); .mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 136.6
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-30') start: parseDate('2021-11-30')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30') parseDate('2021-11-30')
); );
@ -75,7 +114,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('297.8'), currentValueInBaseCurrency: new Big('297.8'),
errors: [], errors: [],
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
@ -118,14 +157,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('2'), quantity: new Big('2'),
symbol: 'BALN.SW', symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('273.2'), timeWeightedInvestment: new Big('273.2'),
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
transactionCount: 1, transactionCount: 1,
valueInBaseCurrency: new Big('297.8') valueInBaseCurrency: new Big('297.8')
} }
], ],
totalFeesWithCurrencyEffect: new Big('1.55'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('273.2'), 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([ expect(investments).toEqual([

View File

@ -1,12 +1,23 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -17,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock( jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => { () => {
@ -30,10 +50,15 @@ jest.mock(
); );
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -42,51 +67,69 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BTCUSD buy and sell partially', async () => { it.only('with BTCUSD buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({
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 const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime()); .mockImplementation(() => parseDate('2018-01-01').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2015-01-01'),
fee: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Bitcoin USD',
symbol: 'BTCUSD'
},
type: 'BUY',
unitPrice: 320.43
},
{
...activityDummyData,
date: new Date('2017-12-31'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Bitcoin USD',
symbol: 'BTCUSD'
},
type: 'SELL',
unitPrice: 14156.4
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2015-01-01') start: parseDate('2015-01-01')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2015-01-01') parseDate('2015-01-01')
); );
@ -99,7 +142,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('13298.425356'), currentValueInBaseCurrency: new Big('13298.425356'),
errors: [], errors: [],
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
@ -146,7 +189,7 @@ describe('PortfolioCalculator', () => {
), ),
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'BTCUSD', symbol: 'BTCUSD',
tags: undefined, tags: [],
timeWeightedInvestment: new Big('640.56763686131386861314'), timeWeightedInvestment: new Big('640.56763686131386861314'),
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79469348020066587024' '636.79469348020066587024'
@ -155,8 +198,12 @@ describe('PortfolioCalculator', () => {
valueInBaseCurrency: new Big('13298.425356') valueInBaseCurrency: new Big('13298.425356')
} }
], ],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('320.43'), 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([ expect(investments).toEqual([

View File

@ -0,0 +1,157 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('compute portfolio snapshot', () => {
it.only('with fee activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-01'),
fee: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Account Opening Fee',
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
},
type: 'FEE',
unitPrice: 0
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('0'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('49'),
firstBuyDate: '2021-09-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 0,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('49'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
});
});
});

View File

@ -1,12 +1,23 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -17,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock( jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => { () => {
@ -30,10 +50,15 @@ jest.mock(
); );
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -42,40 +67,54 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with GOOGL buy', async () => { 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 const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime()); .mockImplementation(() => parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-03'),
fee: 1,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Alphabet Inc.',
symbol: 'GOOGL'
},
type: 'BUY',
unitPrice: 89.12
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2023-01-03') start: parseDate('2023-01-03')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-01-03') parseDate('2023-01-03')
); );
@ -88,7 +127,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('103.10483'), currentValueInBaseCurrency: new Big('103.10483'),
errors: [], errors: [],
grossPerformance: new Big('27.33'), grossPerformance: new Big('27.33'),
@ -131,15 +170,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 103.10483, marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'GOOGL', symbol: 'GOOGL',
tags: undefined, tags: [],
timeWeightedInvestment: new Big('89.12'), timeWeightedInvestment: new Big('89.12'),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1, transactionCount: 1,
valueInBaseCurrency: new Big('103.10483') valueInBaseCurrency: new Big('103.10483')
} }
], ],
totalFeesWithCurrencyEffect: new Big('1'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12'), 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([ expect(investments).toEqual([

View File

@ -0,0 +1,157 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('compute portfolio snapshot', () => {
it.only('with item activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-01-01'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
},
type: 'ITEM',
unitPrice: 500000
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-01-01')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('500000'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 500000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
});
});
});

View File

@ -0,0 +1,108 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('compute portfolio snapshot', () => {
it.only('with liability activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-01'), // Date in future
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Loan',
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
},
type: 'LIABILITY',
unitPrice: 3000
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
spy.mockRestore();
const liabilitiesInBaseCurrency =
await portfolioCalculator.getLiabilitiesInBaseCurrency();
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
});
});
});

View File

@ -0,0 +1,165 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with MSFT buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-16'),
fee: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPrice: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPrice: 0.62
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-07-10')
);
spy.mockRestore();
expect(portfolioSnapshot).toMatchObject({
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('298.58'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83,
quantity: new Big('1'),
symbol: 'MSFT',
tags: [],
transactionCount: 2
}
],
totalFeesWithCurrencyEffect: new Big('19'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
});
});
});

View File

@ -0,0 +1,124 @@
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { subDays } from 'date-fns';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it('with no orders', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const portfolioCalculator = factory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const start = subDays(new Date(Date.now()), 10);
const chartData = await portfolioCalculator.getChartData({ start });
const portfolioSnapshot =
await portfolioCalculator.computeSnapshot(start);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
positions: [],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([
{
date: '2021-12-01',
investment: 0
}
]);
});
});
});

View File

@ -1,11 +1,22 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with NOVN.SW buy and sell partially', async () => { it.only('with NOVN.SW buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({
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 const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime()); .mockImplementation(() => parseDate('2022-04-11').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-03-07'),
fee: 1.3,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'BUY',
unitPrice: 75.8
},
{
...activityDummyData,
date: new Date('2022-04-08'),
fee: 2.95,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'SELL',
unitPrice: 85.73
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07') start: parseDate('2022-03-07')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07') parseDate('2022-03-07')
); );
@ -86,7 +129,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('87.8'), currentValueInBaseCurrency: new Big('87.8'),
errors: [], errors: [],
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
@ -129,6 +172,7 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 87.8, marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'NOVN.SW', symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('145.10285714285714285714'), timeWeightedInvestment: new Big('145.10285714285714285714'),
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'145.10285714285714285714' '145.10285714285714285714'
@ -137,8 +181,12 @@ describe('PortfolioCalculator', () => {
valueInBaseCurrency: new Big('87.8') valueInBaseCurrency: new Big('87.8')
} }
], ],
totalFeesWithCurrencyEffect: new Big('4.25'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('75.80'), 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([ expect(investments).toEqual([

View File

@ -1,11 +1,22 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import { Big } from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -16,11 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -29,51 +54,69 @@ describe('PortfolioCalculator', () => {
null, null,
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => { it.only('with NOVN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
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 const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime()); .mockImplementation(() => parseDate('2022-04-11').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-03-07'),
fee: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'BUY',
unitPrice: 75.8
},
{
...activityDummyData,
date: new Date('2022-04-08'),
fee: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'SELL',
unitPrice: 85.73
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({
start: parseDate('2022-03-07') start: parseDate('2022-03-07')
}); });
const currentPositions = await portfolioCalculator.getCurrentPositions( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-03-07') parseDate('2022-03-07')
); );
@ -93,6 +136,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
netWorth: 151.6,
totalAccountBalance: 0,
totalInvestment: 151.6, totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6, totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6, value: 151.6,
@ -106,13 +151,15 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 13.100263852242744, netPerformanceInPercentage: 13.100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744, netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
netPerformanceWithCurrencyEffect: 19.86, netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0, totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0, totalInvestmentValueWithCurrencyEffect: 0,
value: 0, value: 0,
valueWithCurrencyEffect: 0 valueWithCurrencyEffect: 0
}); });
expect(currentPositions).toEqual({ expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
@ -155,14 +202,19 @@ describe('PortfolioCalculator', () => {
marketPriceInBaseCurrency: 87.8, marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'NOVN.SW', symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'), timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2, transactionCount: 2,
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: 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([ expect(investments).toEqual([

View File

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

View File

@ -8,6 +8,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) { function mockGetValue(symbol: string, date: Date) {
switch (symbol) { switch (symbol) {
case '55196015-1365-4560-aa60-8751ae6d18f8':
if (isSameDay(parseDate('2022-01-31'), date)) {
return { marketPrice: 3000 };
}
return { marketPrice: 0 };
case 'BALN.SW': case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) { if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 }; return { marketPrice: 146 };

View File

@ -34,7 +34,7 @@ export class CurrentRateService {
}: GetValuesParams): Promise<GetValuesObject> { }: GetValuesParams): Promise<GetValuesObject> {
const dataProviderInfos: DataProviderInfo[] = []; const dataProviderInfos: DataProviderInfo[] = [];
const includeToday = const includesToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in)); (!dateQuery.in || this.containsToday(dateQuery.in));
@ -43,7 +43,7 @@ export class CurrentRateService {
const quoteErrors: ResponseError['errors'] = []; const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date()); const today = resetHours(new Date());
if (includeToday) { if (includesToday) {
promises.push( promises.push(
this.dataProviderService this.dataProviderService
.getQuotes({ items: dataGatheringItems, user: this.request?.user }) .getQuotes({ items: dataGatheringItems, user: this.request?.user })

View File

@ -1,19 +0,0 @@
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
export interface CurrentPositions extends ResponseError {
currentValueInBaseCurrency: Big;
grossPerformance: Big;
grossPerformanceWithCurrencyEffect: Big;
grossPerformancePercentage: Big;
grossPerformancePercentageWithCurrencyEffect: Big;
netAnnualizedPerformance?: Big;
netAnnualizedPerformanceWithCurrencyEffect?: Big;
netPerformance: Big;
netPerformanceWithCurrencyEffect: Big;
netPerformancePercentage: Big;
netPerformancePercentageWithCurrencyEffect: Big;
positions: TimelinePosition[];
totalInvestment: Big;
}

View File

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

View File

@ -1,15 +1,12 @@
import { DataSource, Tag, Type as ActivityType } from '@prisma/client'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import Big from 'big.js';
export interface PortfolioOrder { export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
currency: string;
date: string; date: string;
dataSource: DataSource;
fee: Big; fee: Big;
name: string;
quantity: Big; quantity: Big;
symbol: string; SymbolProfile: Pick<
tags?: Tag[]; Activity['SymbolProfile'],
type: ActivityType; 'currency' | 'dataSource' | 'name' | 'symbol'
>;
unitPrice: Big; unitPrice: Big;
} }

View File

@ -1,9 +1,9 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
DataProviderInfo, DataProviderInfo,
EnhancedSymbolProfile, EnhancedSymbolProfile,
HistoricalDataItem HistoricalDataItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Account, Tag } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
@ -12,6 +12,8 @@ export interface PortfolioPositionDetail {
averagePrice: number; averagePrice: number;
dataProviderInfo: DataProviderInfo; dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number; dividendInBaseCurrency: number;
dividendYieldPercent: number;
dividendYieldPercentWithCurrencyEffect: number;
feeInBaseCurrency: number; feeInBaseCurrency: number;
firstBuyDate: string; firstBuyDate: string;
grossPerformance: number; grossPerformance: number;
@ -27,16 +29,10 @@ export interface PortfolioPositionDetail {
netPerformancePercent: number; netPerformancePercent: number;
netPerformancePercentWithCurrencyEffect: number; netPerformancePercentWithCurrencyEffect: number;
netPerformanceWithCurrencyEffect: number; netPerformanceWithCurrencyEffect: number;
orders: OrderWithAccount[]; orders: Activity[];
quantity: number; quantity: number;
SymbolProfile: EnhancedSymbolProfile; SymbolProfile: EnhancedSymbolProfile;
tags: Tag[]; tags: Tag[];
transactionCount: number; transactionCount: number;
value: number; value: number;
} }
export interface HistoricalDataContainer {
isAllTimeHigh: boolean;
isAllTimeLow: boolean;
items: HistoricalDataItem[];
}

View File

@ -1,5 +1,5 @@
import { DataSource, Tag } from '@prisma/client'; import { DataSource, Tag } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
export interface TransactionPointSymbol { export interface TransactionPointSymbol {
averagePrice: Big; averagePrice: Big;

View File

@ -1,6 +1,12 @@
import { Big } from 'big.js';
import { TransactionPointSymbol } from './transaction-point-symbol.interface'; import { TransactionPointSymbol } from './transaction-point-symbol.interface';
export interface TransactionPoint { export interface TransactionPoint {
date: string; date: string;
fees: Big;
interest: Big;
items: TransactionPointSymbol[]; items: TransactionPointSymbol[];
liabilities: Big;
valuables: Big;
} }

View File

@ -1,118 +0,0 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
});
describe('get current positions', () => {
it.only('with MSFT buy', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'USD',
orders: [
{
currency: 'USD',
date: '2021-09-16',
dataSource: 'YAHOO',
fee: new Big(19),
name: 'Microsoft Inc.',
quantity: new Big(1),
symbol: 'MSFT',
type: 'BUY',
unitPrice: new Big(298.58)
},
{
currency: 'USD',
date: '2021-11-16',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Microsoft Inc.',
quantity: new Big(1),
symbol: 'MSFT',
type: 'DIVIDEND',
unitPrice: new Big(0.62)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2023-07-10')
);
spy.mockRestore();
expect(currentPositions).toMatchObject({
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('298.58'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83,
quantity: new Big('1'),
symbol: 'MSFT',
tags: undefined,
transactionCount: 2
}
],
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58')
});
});
});
});

View File

@ -1,86 +0,0 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
});
describe('get current positions', () => {
it('with no orders', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: []
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const chartData = await portfolioCalculator.getChartData({
start: new Date()
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
new Date()
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(currentPositions).toEqual({
currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
positions: [],
totalInvestment: new Big(0)
});
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]);
});
});
});

View File

@ -6,6 +6,7 @@ import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
@ -46,7 +47,8 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import Big from 'big.js'; import { AssetClass, AssetSubClass } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
@ -77,9 +79,9 @@ export class PortfolioController {
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withLiabilities') withLiabilitiesParam = 'false' @Query('withMarkets') withMarketsParam = 'false'
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
const withLiabilities = withLiabilitiesParam === 'true'; const withMarkets = withMarketsParam === 'true';
let hasDetails = true; let hasDetails = true;
let hasError = false; let hasError = false;
@ -104,7 +106,7 @@ export class PortfolioController {
dateRange, dateRange,
filters, filters,
impersonationId, impersonationId,
withLiabilities, withMarkets,
userId: this.request.user.id, userId: this.request.user.id,
withSummary: true withSummary: true
}); });
@ -127,14 +129,19 @@ export class PortfolioController {
const totalValue = Object.values(holdings) const totalValue = Object.values(holdings)
.filter(({ assetClass, assetSubClass }) => { .filter(({ assetClass, assetSubClass }) => {
return assetClass !== 'CASH' && assetSubClass !== 'CASH'; return (
assetClass !== AssetClass.LIQUIDITY &&
assetSubClass !== AssetSubClass.CASH
);
}) })
.map(({ valueInBaseCurrency }) => { .map(({ valueInBaseCurrency }) => {
return valueInBaseCurrency; return valueInBaseCurrency;
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => {
return a + b;
}, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.investment = portfolioPosition.investment =
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
portfolioPosition.valueInPercentage = portfolioPosition.valueInPercentage =
@ -184,11 +191,11 @@ export class PortfolioController {
holdings[symbol] = { holdings[symbol] = {
...portfolioPosition, ...portfolioPosition,
assetClass: assetClass:
hasDetails || portfolioPosition.assetClass === 'CASH' hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetClass ? portfolioPosition.assetClass
: undefined, : undefined,
assetSubClass: assetSubClass:
hasDetails || portfolioPosition.assetSubClass === 'CASH' hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetSubClass ? portfolioPosition.assetSubClass
: undefined, : undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
@ -236,8 +243,12 @@ export class PortfolioController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { endDate, startDate } = getInterval(dateRange);
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
endDate,
filters, filters,
startDate,
userCurrency, userCurrency,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
types: ['DIVIDEND'] types: ['DIVIDEND']
@ -245,7 +256,6 @@ export class PortfolioController {
let dividends = await this.portfolioService.getDividends({ let dividends = await this.portfolioService.getDividends({
activities, activities,
dateRange,
groupBy groupBy
}); });
@ -284,17 +294,21 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('holdingType') filterByHoldingType?: string,
@Query('query') filterBySearchQuery?: string, @Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioHoldingsResponse> { ): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByHoldingType,
filterBySearchQuery, filterBySearchQuery,
filterByTags filterByTags
}); });
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
dateRange,
filters, filters,
impersonationId, impersonationId,
userId: this.request.user.id userId: this.request.user.id
@ -380,11 +394,9 @@ export class PortfolioController {
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false', @Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
@Query('withItems') withItemsParam = 'false'
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true'; const withExcludedAccounts = withExcludedAccountsParam === 'true';
const withItems = withItemsParam === 'true';
const hasReadRestrictedAccessPermission = const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({ this.userService.hasReadRestrictedAccessPermission({
@ -403,7 +415,6 @@ export class PortfolioController {
filters, filters,
impersonationId, impersonationId,
withExcludedAccounts, withExcludedAccounts,
withItems,
userId: this.request.user.id userId: this.request.user.id
}); });
@ -416,6 +427,7 @@ export class PortfolioController {
({ ({
date, date,
netPerformanceInPercentage, netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netWorth, netWorth,
totalInvestment, totalInvestment,
value value
@ -423,6 +435,7 @@ export class PortfolioController {
return { return {
date, date,
netPerformanceInPercentage, netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netWorthInPercentage: netWorthInPercentage:
performanceInformation.performance.currentNetWorth === 0 performanceInformation.performance.currentNetWorth === 0
? 0 ? 0
@ -528,7 +541,8 @@ export class PortfolioController {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }], filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId, impersonationId: access.userId,
userId: user.id userId: user.id,
withMarkets: true
}); });
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {

View File

@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -15,6 +16,7 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@ -34,6 +36,7 @@ import { RulesService } from './rules.service';
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,
PrismaModule, PrismaModule,
RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
UserModule UserModule
], ],
@ -41,6 +44,7 @@ import { RulesService } from './rules.service';
AccountBalanceService, AccountBalanceService,
AccountService, AccountService,
CurrentRateService, CurrentRateService,
PortfolioCalculatorFactory,
PortfolioService, PortfolioService,
RulesService 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'; describe('PortfolioService', () => {
import { PortfolioCalculator } from './portfolio-calculator'; let portfolioService: PortfolioService;
describe('PortfolioCalculator', () => { beforeAll(async () => {
let currentRateService: CurrentRateService; portfolioService = new PortfolioService(
let exchangeRateDataService: ExchangeRateDataService; null,
null,
beforeEach(() => { null,
currentRateService = new CurrentRateService(null, null, null, null); null,
null,
exchangeRateDataService = new ExchangeRateDataService( null,
null,
null, null,
null, null,
null, null,
@ -21,16 +22,9 @@ describe('PortfolioCalculator', () => {
}); });
describe('annualized performance percentage', () => { describe('annualized performance percentage', () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'USD',
orders: []
});
it('Get annualized performance', async () => { it('Get annualized performance', async () => {
expect( expect(
portfolioCalculator portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0) netPerformancePercent: new Big(0)
@ -39,7 +33,7 @@ describe('PortfolioCalculator', () => {
).toEqual(0); ).toEqual(0);
expect( expect(
portfolioCalculator portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 0, daysInMarket: 0,
netPerformancePercent: new Big(0) netPerformancePercent: new Big(0)
@ -51,7 +45,7 @@ describe('PortfolioCalculator', () => {
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html * Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/ */
expect( expect(
portfolioCalculator portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025) netPerformancePercent: new Big(0.1025)
@ -60,7 +54,7 @@ describe('PortfolioCalculator', () => {
).toBeCloseTo(0.729705); ).toBeCloseTo(0.729705);
expect( expect(
portfolioCalculator portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05) netPerformancePercent: new Big(0.05)
@ -72,7 +66,7 @@ describe('PortfolioCalculator', () => {
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation * Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/ */
expect( expect(
portfolioCalculator portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374) netPerformancePercent: new Big(0.2374)

File diff suppressed because it is too large Load Diff

View File

@ -17,8 +17,16 @@ export class RulesService {
return rule.getSettings(aUserSettings)?.isActive; return rule.getSettings(aUserSettings)?.isActive;
}) })
.map((rule) => { .map((rule) => {
const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings)); const { evaluation, value } = rule.evaluate(
return { ...evaluationResult, name: rule.getName() }; rule.getSettings(aUserSettings)
);
return {
evaluation,
value,
key: rule.getKey(),
name: rule.getName()
};
}); });
} }
} }

View File

@ -0,0 +1,13 @@
import { RedisCacheService } from './redis-cache.service';
export const RedisCacheServiceMock = {
get: (key: string): Promise<string> => {
return Promise.resolve(null);
},
getPortfolioSnapshotKey: (userId: string): string => {
return `portfolio-snapshot-${userId}`;
},
set: (key: string, value: string, ttlInSeconds?: number): Promise<string> => {
return Promise.resolve(value);
}
};

View File

@ -21,7 +21,11 @@ export class RedisCacheService {
} }
public async get(key: string): Promise<string> { public async get(key: string): Promise<string> {
return await this.cache.get(key); return this.cache.get(key);
}
public getPortfolioSnapshotKey({ userId }: { userId: string }) {
return `portfolio-snapshot-${userId}`;
} }
public getQuoteKey({ dataSource, symbol }: UniqueAsset) { public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
@ -29,15 +33,15 @@ export class RedisCacheService {
} }
public async remove(key: string) { public async remove(key: string) {
await this.cache.del(key); return this.cache.del(key);
} }
public async reset() { public async reset() {
await this.cache.reset(); return this.cache.reset();
} }
public async set(key: string, value: string, ttlInSeconds?: number) { public async set(key: string, value: string, ttlInSeconds?: number) {
await this.cache.set( return this.cache.set(
key, key,
value, value,
ttlInSeconds ?? this.configurationService.get('CACHE_TTL') ttlInSeconds ?? this.configurationService.get('CACHE_TTL')

View File

@ -116,7 +116,7 @@ export class SubscriptionController {
@Body() { couponId, priceId }: { couponId: string; priceId: string } @Body() { couponId, priceId }: { couponId: string; priceId: string }
) { ) {
try { try {
return await this.subscriptionService.createCheckoutSession({ return this.subscriptionService.createCheckoutSession({
couponId, couponId,
priceId, priceId,
user: this.request.user user: this.request.user

View File

@ -74,11 +74,21 @@ export class SymbolService {
date = new Date(), date = new Date(),
symbol symbol
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> { }: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
const historicalData = await this.dataProviderService.getHistoricalRaw( let historicalData: {
[{ dataSource, symbol }], [symbol: string]: {
date, [date: string]: IDataProviderHistoricalResponse;
date };
); } = {
[symbol]: {}
};
try {
historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
from: date,
to: date
});
} catch {}
return { return {
marketPrice: marketPrice:

View File

@ -14,6 +14,7 @@ import {
IsOptional, IsOptional,
IsString IsString
} from 'class-validator'; } from 'class-validator';
import { eachYearOfInterval, format } from 'date-fns';
export class UpdateUserSettingDto { export class UpdateUserSettingDto {
@IsNumber() @IsNumber()
@ -32,7 +33,20 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
colorScheme?: ColorScheme; colorScheme?: ColorScheme;
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) @IsIn(<DateRange[]>[
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd',
...eachYearOfInterval({ end: new Date(), start: new Date(0) }).map(
(date) => {
return format(date, 'yyyy');
}
)
])
@IsOptional() @IsOptional()
dateRange?: DateRange; dateRange?: DateRange;

View File

@ -2,11 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -63,13 +59,6 @@ export class UserController {
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string @Headers('accept-language') acceptLanguage: string
): Promise<User> { ): Promise<User> {
if (hasRole(this.request.user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
return this.userService.getUser( return this.userService.getUser(
this.request.user, this.request.user,
acceptLanguage?.split(',')?.[0] acceptLanguage?.split(',')?.[0]
@ -135,7 +124,7 @@ export class UserController {
} }
} }
return await this.userService.updateUserSetting({ return this.userService.updateUserSetting({
userSettings, userSettings,
userId: this.request.user.id userId: this.request.user.id
}); });

View File

@ -1,5 +1,6 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -25,6 +26,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { sortBy, without } from 'lodash'; import { sortBy, without } from 'lodash';
@ -37,6 +39,7 @@ export class UserService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -51,13 +54,22 @@ export class UserService {
{ Account, id, permissions, Settings, subscription }: UserWithSettings, { Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale aLocale = locale
): Promise<IUser> { ): Promise<IUser> {
const access = await this.prismaService.access.findMany({ let [access, firstActivity, tags] = await Promise.all([
include: { this.prismaService.access.findMany({
User: true include: {
}, User: true
orderBy: { alias: 'asc' }, },
where: { GranteeUser: { id } } orderBy: { alias: 'asc' },
}); where: { GranteeUser: { id } }
}),
this.prismaService.order.findFirst({
orderBy: {
date: 'asc'
},
where: { userId: id }
}),
this.tagService.getByUser(id)
]);
let systemMessage: SystemMessage; let systemMessage: SystemMessage;
@ -69,8 +81,6 @@ export class UserService {
systemMessage = systemMessageProperty; systemMessage = systemMessageProperty;
} }
let tags = await this.tagService.getByUser(id);
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === 'Basic' subscription.type === 'Basic'
@ -91,6 +101,7 @@ export class UserService {
}; };
}), }),
accounts: Account, accounts: Account,
dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(<UserSettings>Settings.settings), ...(<UserSettings>Settings.settings),
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
@ -429,11 +440,9 @@ export class UserService {
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
}) { }) {
const settings = userSettings as unknown as Prisma.JsonObject; const { settings } = await this.prismaService.settings.upsert({
await this.prismaService.settings.upsert({
create: { create: {
settings, settings: userSettings as unknown as Prisma.JsonObject,
User: { User: {
connect: { connect: {
id: userId id: userId
@ -441,25 +450,33 @@ export class UserService {
} }
}, },
update: { update: {
settings settings: userSettings as unknown as Prisma.JsonObject
}, },
where: { where: {
userId userId
} }
}); });
return; this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
return settings;
} }
private getRandomString(length: number) { private getRandomString(length: number) {
const bytes = crypto.randomBytes(length);
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const result = []; const result = [];
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
result.push( const randomByte = bytes[i];
characters.charAt(Math.floor(Math.random() * characters.length)) result.push(characters[randomByte % characters.length]);
);
} }
return result.join(''); return result.join('');
} }
} }

View File

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

View File

@ -1232,10 +1232,12 @@
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc> <loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<!--
<url> <url>
<loc>https://ghostfol.io/pl</loc> <loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
-->
<url> <url>
<loc>https://ghostfol.io/pt</loc> <loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -1296,4 +1298,10 @@
<loc>https://ghostfol.io/tr</loc> <loc>https://ghostfol.io/tr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<!--
<url>
<loc>https://ghostfol.io/zh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
</urlset> </urlset>

View File

@ -0,0 +1,11 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { Module } from '@nestjs/common';
import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({
imports: [RedisCacheModule],
providers: [PortfolioChangedListener]
})
export class EventsModule {}

View File

@ -0,0 +1,15 @@
export class PortfolioChangedEvent {
private userId: string;
public constructor({ userId }: { userId: string }) {
this.userId = userId;
}
public static getName() {
return 'portfolio.changed';
}
public getUserId() {
return this.userId;
}
}

View File

@ -0,0 +1,25 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable()
export class PortfolioChangedListener {
public constructor(private readonly redisCacheService: RedisCacheService) {}
@OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
Logger.log(
`Portfolio of user with id ${event.getUserId()} has changed`,
'PortfolioChangedListener'
);
this.redisCacheService.remove(
this.redisCacheService.getPortfolioSnapshotKey({
userId: event.getUserId()
})
);
}
}

View File

@ -1,4 +1,4 @@
import Big from 'big.js'; import { Big } from 'big.js';
import { cloneDeep, isArray, isObject } from 'lodash'; import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean { export function hasNotDefinedValuesInObject(aObject: Object): boolean {

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 { Type as ActivityType } from '@prisma/client';
import {
endOfDay,
max,
subDays,
startOfMonth,
startOfWeek,
startOfYear,
subYears,
endOfYear
} from 'date-fns';
export function getFactor(activityType: ActivityType) { export function getFactor(activityType: ActivityType) {
let factor: number; let factor: number;
switch (activityType) { switch (activityType) {
case 'BUY': case 'BUY':
case 'ITEM':
factor = 1; factor = 1;
break; break;
case 'LIABILITY':
case 'SELL': case 'SELL':
factor = -1; factor = -1;
break; break;
@ -19,3 +30,61 @@ export function getFactor(activityType: ActivityType) {
return factor; 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

@ -57,6 +57,7 @@ export class RedactValuesInResponseInterceptor<T>
'quantity', 'quantity',
'symbolMapping', 'symbolMapping',
'totalBalanceInBaseCurrency', 'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice', 'unitPrice',
'value', 'value',
'valueInBaseCurrency' 'valueInBaseCurrency'

View File

@ -1,25 +1,34 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface'; import { RuleInterface } from './interfaces/rule.interface';
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> { export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
private key: string;
private name: string; private name: string;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
{ {
key,
name name
}: { }: {
key: string;
name: string; name: string;
} }
) { ) {
this.key = key;
this.name = name; this.name = name;
} }
public getKey() {
return this.key;
}
public getName() { public getName() {
return this.name; return this.name;
} }

View File

@ -15,6 +15,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AccountClusterRiskCurrentInvestment.name,
name: 'Investment' name: 'Investment'
}); });
@ -35,7 +36,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
let maxItem; let maxItem: (typeof accounts)[0];
let totalInvestment = 0; let totalInvestment = 0;
for (const account of Object.values(accounts)) { for (const account of Object.values(accounts)) {
@ -52,7 +53,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
} }
} }
const maxInvestmentRatio = maxItem.investment / totalInvestment; const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
if (maxInvestmentRatio > ruleSettings.threshold) { if (maxInvestmentRatio > ruleSettings.threshold) {
return { return {

View File

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

View File

@ -1,7 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[]; private positions: TimelinePosition[];
@ -11,6 +12,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
positions: TimelinePosition[] positions: TimelinePosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name,
name: 'Investment: Base Currency' name: 'Investment: Base Currency'
}); });
@ -43,7 +45,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0; const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
if (maxItem.groupKey !== ruleSettings.baseCurrency) { if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
return { return {
evaluation: `The major part of your current investment is not in your base currency (${( evaluation: `The major part of your current investment is not in your base currency (${(
baseCurrencyValueRatio * 100 baseCurrencyValueRatio * 100

View File

@ -1,7 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[]; private positions: TimelinePosition[];
@ -11,6 +12,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
positions: TimelinePosition[] positions: TimelinePosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: CurrencyClusterRiskCurrentInvestment.name,
name: 'Investment' name: 'Investment'
}); });
@ -37,7 +39,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
} }
}); });
const maxValueRatio = maxItem.value / totalValue; const maxValueRatio = maxItem?.value / totalValue || 0;
if (maxValueRatio > ruleSettings.threshold) { if (maxValueRatio > ruleSettings.threshold) {
return { return {
@ -52,7 +54,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
return { return {
evaluation: `The major part of your current investment is in ${ evaluation: `The major part of your current investment is in ${
maxItem.groupKey maxItem?.groupKey ?? ruleSettings.baseCurrency
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${ } (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100 ruleSettings.threshold * 100
}%`, }%`,

View File

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

View File

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

View File

@ -10,18 +10,21 @@ export class ApiService {
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByAssetSubClasses, filterByAssetSubClasses,
filterByHoldingType,
filterBySearchQuery, filterBySearchQuery,
filterByTags filterByTags
}: { }: {
filterByAccounts?: string; filterByAccounts?: string;
filterByAssetClasses?: string; filterByAssetClasses?: string;
filterByAssetSubClasses?: string; filterByAssetSubClasses?: string;
filterByHoldingType?: string;
filterBySearchQuery?: string; filterBySearchQuery?: string;
filterByTags?: string; filterByTags?: string;
}): Filter[] { }): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? []; const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase(); const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? []; const tagIds = filterByTags?.split(',') ?? [];
@ -52,6 +55,13 @@ export class ApiService {
}) })
]; ];
if (holdingType) {
filters.push({
id: holdingType,
type: 'HOLDING_TYPE'
});
}
if (searchQuery) { if (searchQuery) {
filters.push({ filters.push({
id: searchQuery, id: searchQuery,

View File

@ -1,4 +1,5 @@
import { import {
DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
PROPERTY_IS_DATA_GATHERING_ENABLED PROPERTY_IS_DATA_GATHERING_ENABLED
@ -56,7 +57,8 @@ export class CronService {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...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 }) @Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
public async gatherAssetProfile(job: Job<UniqueAsset>) { public async gatherAssetProfile(job: Job<UniqueAsset>) {
try { 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]); 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) { } catch (error) {
Logger.error( Logger.error(
error, error,
@ -62,11 +72,11 @@ export class DataGatheringProcessor {
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})` `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
); );
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw({
[{ dataSource, symbol }], dataGatheringItems: [{ dataSource, symbol }],
currentDate, from: currentDate,
new Date() to: new Date()
); });
const data: Prisma.MarketDataUpdateInput[] = []; const data: Prisma.MarketDataUpdateInput[] = [];
let lastMarketPrice: number; 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
@ -61,24 +63,35 @@ export class DataGatheringService {
public async gather7Days() { public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D(); const dataGatheringItems = await this.getSymbols7D();
await this.gatherSymbols(dataGatheringItems); await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
} }
public async gatherMax() { public async gatherMax() {
const dataGatheringItems = await this.getSymbolsMax(); 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) { public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => { const dataGatheringItems = (await this.getSymbolsMax()).filter(
return ( (dataGatheringItem) => {
dataGatheringItem.dataSource === dataSource && return (
dataGatheringItem.symbol === symbol dataGatheringItem.dataSource === dataSource &&
); dataGatheringItem.symbol === symbol
);
}
);
await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
}); });
await this.gatherSymbols(symbols);
} }
public async gatherSymbolForDate({ public async gatherSymbolForDate({
@ -91,11 +104,11 @@ export class DataGatheringService {
symbol: string; symbol: string;
}) { }) {
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw({
[{ dataSource, symbol }], dataGatheringItems: [{ dataSource, symbol }],
date, from: date,
date to: date
); });
const marketPrice = const marketPrice =
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice; historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
@ -217,22 +230,23 @@ export class DataGatheringService {
error, error,
'DataGatheringService' '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( await this.addJobsToQueue(
aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => { dataGatheringItems.map(({ dataSource, date, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,
@ -242,6 +256,7 @@ export class DataGatheringService {
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS, name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
opts: { opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
priority,
jobId: `${getAssetProfileIdentifier({ jobId: `${getAssetProfileIdentifier({
dataSource, dataSource,
symbol symbol

View File

@ -59,7 +59,7 @@ export class CoinGeckoService implements DataProviderInterface {
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
symbol, symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataSource: this.getName() dataSource: this.getName()
@ -243,7 +243,7 @@ export class CoinGeckoService implements DataProviderInterface {
return { return {
name, name,
symbol, symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),

View File

@ -266,7 +266,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
switch (quoteType?.toLowerCase()) { switch (quoteType?.toLowerCase()) {
case 'cryptocurrency': case 'cryptocurrency':
assetClass = AssetClass.CASH; assetClass = AssetClass.LIQUIDITY;
assetSubClass = AssetSubClass.CRYPTOCURRENCY; assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break; break;
case 'equity': case 'equity':

View File

@ -20,7 +20,7 @@ import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import { Big } from 'big.js';
import { eachDayOfInterval, format, isValid } from 'date-fns'; import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -204,13 +204,14 @@ export class DataProviderService {
}); });
try { try {
const queryRaw = `SELECT * const queryRaw = `
FROM "MarketData" SELECT *
WHERE "dataSource" IN ('${dataSources.join(`','`)}') FROM "MarketData"
AND "symbol" IN ('${symbols.join( WHERE "dataSource" IN ('${dataSources.join(`','`)}')
`','` AND "symbol" IN ('${symbols.join(
)}') ${granularityQuery} ${rangeQuery} `','`
ORDER BY date;`; )}') ${granularityQuery} ${rangeQuery}
ORDER BY date;`;
const marketDataByGranularity: MarketData[] = const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRawUnsafe(queryRaw); await this.prismaService.$queryRawUnsafe(queryRaw);
@ -232,15 +233,17 @@ export class DataProviderService {
} }
} }
public async getHistoricalRaw( public async getHistoricalRaw({
aDataGatheringItems: UniqueAsset[], dataGatheringItems,
from: Date, from,
to: Date to
): Promise<{ }: {
dataGatheringItems: UniqueAsset[];
from: Date;
to: Date;
}): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
let dataGatheringItems = aDataGatheringItems;
for (const { currency, rootCurrency } of DERIVED_CURRENCIES) { for (const { currency, rootCurrency } of DERIVED_CURRENCIES) {
if ( if (
this.hasCurrency({ this.hasCurrency({
@ -329,6 +332,8 @@ export class DataProviderService {
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); Logger.error(error, 'DataProviderService');
throw error;
} }
return result; return result;
@ -394,7 +399,8 @@ export class DataProviderService {
numberOfItemsInCache > 1 ? 's' : '' numberOfItemsInCache > 1 ? 's' : ''
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
3 3
)} seconds` )} seconds`,
'DataProviderService'
); );
} }
@ -500,7 +506,8 @@ export class DataProviderService {
} from ${dataSource} in ${( } from ${dataSource} in ${(
(performance.now() - startTimeDataSource) / (performance.now() - startTimeDataSource) /
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`,
'DataProviderService'
); );
try { try {
@ -530,14 +537,15 @@ export class DataProviderService {
await Promise.all(promises); await Promise.all(promises);
Logger.debug('------------------------------------------------'); Logger.debug('--------------------------------------------------------');
Logger.debug( Logger.debug(
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`,
'DataProviderService'
); );
Logger.debug('================================================'); Logger.debug('========================================================');
return response; return response;
} }

View File

@ -468,7 +468,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
assetSubClass = AssetSubClass.STOCK; assetSubClass = AssetSubClass.STOCK;
break; break;
case 'currency': case 'currency':
assetClass = AssetClass.CASH; assetClass = AssetClass.LIQUIDITY;
if (Exchange?.toLowerCase() === 'cc') { if (Exchange?.toLowerCase() === 'cc') {
assetSubClass = AssetSubClass.CRYPTOCURRENCY; assetSubClass = AssetSubClass.CRYPTOCURRENCY;

View File

@ -91,7 +91,7 @@ export class ManualService implements DataProviderInterface {
headers = {}, headers = {},
selector, selector,
url url
} = symbolProfile.scraperConfiguration ?? {}; } = symbolProfile?.scraperConfiguration ?? {};
if (defaultMarketPrice) { if (defaultMarketPrice) {
const historical: { const historical: {

View File

@ -449,13 +449,16 @@ export class ExchangeRateDataService {
factors[format(date, DATE_FORMAT)] = factor; factors[format(date, DATE_FORMAT)] = factor;
} }
} catch { } catch {
Logger.error( let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( date,
date, DATE_FORMAT
DATE_FORMAT )}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`;
)}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom} and ${DEFAULT_CURRENCY}${currencyTo}.`,
'ExchangeRateDataService' if (DEFAULT_CURRENCY !== currencyTo) {
); errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`;
}
Logger.error(`${errorMessage}.`, 'ExchangeRateDataService');
} }
} }
} }

View File

@ -97,7 +97,9 @@ export class SymbolProfileService {
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol,
symbolMapping symbolMapping,
SymbolProfileOverrides,
url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
return this.prismaService.symbolProfile.update({ return this.prismaService.symbolProfile.update({
data: { data: {
@ -109,7 +111,9 @@ export class SymbolProfileService {
name, name,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbolMapping symbolMapping,
SymbolProfileOverrides,
url
}, },
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
}); });

View File

@ -72,6 +72,10 @@
"baseHref": "/tr/", "baseHref": "/tr/",
"localize": ["tr"] "localize": ["tr"]
}, },
"development-zh": {
"baseHref": "/zh/",
"localize": ["zh"]
},
"production": { "production": {
"fileReplacements": [ "fileReplacements": [
{ {
@ -190,6 +194,9 @@
"development-tr": { "development-tr": {
"buildTarget": "client:build:development-tr" "buildTarget": "client:build:development-tr"
}, },
"development-zh": {
"buildTarget": "client:build:development-zh"
},
"production": { "production": {
"buildTarget": "client:build:production" "buildTarget": "client:build:production"
} }
@ -209,7 +216,8 @@
"messages.nl.xlf", "messages.nl.xlf",
"messages.pl.xlf", "messages.pl.xlf",
"messages.pt.xlf", "messages.pt.xlf",
"messages.tr.xlf" "messages.tr.xlf",
"messages.zh.xlf"
] ]
} }
}, },
@ -260,6 +268,10 @@
"tr": { "tr": {
"baseHref": "/tr/", "baseHref": "/tr/",
"translation": "apps/client/src/locales/messages.tr.xlf" "translation": "apps/client/src/locales/messages.tr.xlf"
},
"zh": {
"baseHref": "/zh/",
"translation": "apps/client/src/locales/messages.zh.xlf"
} }
}, },
"sourceLocale": "en" "sourceLocale": "en"

View File

@ -163,6 +163,11 @@
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a> <a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
</li> </li>
--> -->
<!--
<li>
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
</li>
-->
</ul> </ul>
</div> </div>
</div> </div>

View File

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

View File

@ -1,3 +1,4 @@
import { getCssVariable } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
@ -187,20 +188,28 @@ export class AppComponent implements OnDestroy, OnInit {
? userPreferredColorScheme === 'DARK' ? userPreferredColorScheme === 'DARK'
: window.matchMedia('(prefers-color-scheme: dark)').matches; : window.matchMedia('(prefers-color-scheme: dark)').matches;
this.toggleThemeStyleClass(isDarkTheme); this.toggleTheme(isDarkTheme);
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => { window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
if (!this.user?.settings.colorScheme) { if (!this.user?.settings.colorScheme) {
this.toggleThemeStyleClass(event.matches); this.toggleTheme(event.matches);
} }
}); });
} }
private toggleThemeStyleClass(isDarkTheme: boolean) { private toggleTheme(isDarkTheme: boolean) {
const themeColor = getCssVariable(
isDarkTheme ? '--dark-background' : '--light-background'
);
if (isDarkTheme) { if (isDarkTheme) {
this.document.body.classList.add('is-dark-theme'); this.document.body.classList.add('is-dark-theme');
} else { } else {
this.document.body.classList.remove('is-dark-theme'); this.document.body.classList.remove('is-dark-theme');
} }
this.document
.querySelector('meta[name="theme-color"]')
.setAttribute('content', themeColor);
} }
} }

View File

@ -1,4 +1,4 @@
import { GfLogoModule } from '@ghostfolio/ui/logo'; import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { Platform } from '@angular/cdk/platform'; import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
@ -43,7 +43,7 @@ export function NgxStripeFactory(): string {
BrowserAnimationsModule, BrowserAnimationsModule,
BrowserModule, BrowserModule,
GfHeaderModule, GfHeaderModule,
GfLogoModule, GfLogoComponent,
GfSubscriptionInterstitialDialogModule, GfSubscriptionInterstitialDialogModule,
HttpClientModule, HttpClientModule,
MarkdownModule.forRoot(), MarkdownModule.forRoot(),

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