Compare commits

...

199 Commits

Author SHA1 Message Date
2bd9309827 Release 1.34.0 (#263) 2021-08-07 07:28:16 +02:00
f743c03e17 Feature/restructure page hierarchy (#259)
* Introduce portfolio group, remove tools

* Extract allocations to separate page

* Update changelog
2021-08-07 07:12:40 +02:00
dfcf826b4f implement support for buy-sell(-buy) scenario (#262)
Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-08-07 07:07:12 +02:00
218efbb5bd fix position diagram for sold items (#261)
* fix position diagram for sold items

* Add missing data to position from the past

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-07 07:03:35 +02:00
ba3b4564cd Bugfix/dashboard currencies (#260)
* filter list of orders only for relevant orders

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-06 21:47:27 +02:00
3723a5c725 Release 1.33.0 (#257) 2021-08-05 22:54:12 +02:00
bf256ae50c fix position currency conversion (#256)
Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-08-05 22:36:18 +02:00
c4b6273886 Clean up viewport scroller (#255) 2021-08-05 22:32:55 +02:00
7f047362cc Bugfix/fix division by zero in calculate overall gross performance (#253)
* Fix error with division by zero

* Update changelog
2021-08-05 07:49:25 +02:00
66900ffa24 Release 1.32.0 (#254) 2021-08-04 21:42:50 +02:00
33e05e949a Add links (#252)
* Ghostfolio Premium
* Blog
* Twitter
2021-08-04 20:59:59 +02:00
94d2310217 Bugfix/fix spike of positions with differing currency of user (#248)
* Convert market price back to currency of position

* Update changelog
2021-08-04 20:43:00 +02:00
b7d950f3f9 Bugfix/fix missing market state in positions (#250)
* Fix missing market state in positions

* Update changelog
2021-08-04 17:55:34 +02:00
b7943889da Feature/add screenshot (#251)
* Add screenshot

* Update changelog
2021-08-03 21:02:51 +02:00
84aa542560 Extend changelog (#247) 2021-08-02 21:56:36 +02:00
4bd41ffa41 Feature/eliminate name from timeline position (#245)
* Eliminate name from timeline position: Get the name from the symbolProfileService instead

* Update changelog
2021-08-01 20:43:12 +02:00
56c332c59a Release 1.31.1 (#246) 2021-08-01 17:58:10 +02:00
423ceec317 Feature/remove symbol icon from dialog header (#244)
* Remove symbol icon from dialog header

* Clean up code
2021-08-01 17:26:39 +02:00
5b4a1785ae fix investment values for portfolio.service.ts (#243)
* fix investment values for portfolio.service.ts

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-01 16:26:25 +02:00
56a5664c87 Release 1.31.0 (#239) 2021-08-01 10:05:32 +02:00
823501f43e Sort imports (#242) 2021-08-01 09:41:44 +02:00
331ac7ded2 remove unused method in portfolio.service.ts (#240)
Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-08-01 09:39:41 +02:00
92b44ba658 Update changelog 2021-08-01 00:55:10 +02:00
982ba7377a remove legacy portfolio.ts 2021-08-01 00:55:10 +02:00
4bbd17a37a fix tests 2021-08-01 00:55:10 +02:00
6a7def6c48 Clean up 2021-08-01 00:55:10 +02:00
ea219f0b88 Add guard 2021-08-01 00:55:10 +02:00
23b2e03923 refactor rule evaluation 2021-08-01 00:55:10 +02:00
04e6518226 move report generation to PortfolioService 2021-08-01 00:55:10 +02:00
72dbe00091 change rule service interface 2021-08-01 00:55:10 +02:00
9834c52739 refactor rule settings 2021-08-01 00:55:10 +02:00
c47578bd3e Clean up 2021-08-01 00:55:10 +02:00
9e4a49d811 Remove fallback 2021-08-01 00:55:10 +02:00
a3a98c68a5 Fix missing currency conversion 2021-08-01 00:55:10 +02:00
21570cca19 Fix impersonation in performance endpoint 2021-08-01 00:55:10 +02:00
71a3115fc6 refactor get position endpoint
Co-authored-by: Thomas <dotsilver@gmail.com>
2021-08-01 00:55:10 +02:00
de83dc7b84 create investment endpoint for analysis timeline
Co-authored-by: Thomas <dotsilver@gmail.com>
2021-08-01 00:55:10 +02:00
4a0695613e refactor PortfolioService#getOverview
Co-authored-by: Thomas <dotsilver@gmail.com>
2021-08-01 00:55:10 +02:00
328d814922 remove getPerformance from portfolio.ts
Co-authored-by: Thomas <dotsilver@gmail.com>
2021-08-01 00:55:10 +02:00
d23addb673 change getDetails to portfolio-calculator.ts
Co-authored-by: Thomas <dotsilver@gmail.com>
2021-08-01 00:55:10 +02:00
fb15cebb64 Add test (#237)
* Add test

* fix calculation for overall gross performance percentage

Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-08-01 00:55:10 +02:00
9c51a257ae fix performance of combination of investments 2021-08-01 00:55:10 +02:00
f9b9dc32cb Always show positions title 2021-08-01 00:55:10 +02:00
e7194ef3ce Use new positions service 2021-08-01 00:55:10 +02:00
ec5523b459 Handle empty portfolio 2021-08-01 00:55:10 +02:00
c8c21a016a Handle empty portfolio 2021-08-01 00:55:10 +02:00
9821b7f8f0 Add TWR test scenario 1 (#236)
* Add TWR test scenario 1

* fix second transaction item

* change time-weighted rate algorithm

Co-authored-by: Valentin Zickner <valentin.zickner@flowable.com>
2021-08-01 00:55:10 +02:00
ed731afc66 fix market price fetch in case symbols are missing 2021-08-01 00:55:10 +02:00
ff15d5cbc4 Rename performance labels 2021-08-01 00:55:10 +02:00
3c4949de35 Hide net performance 2021-08-01 00:55:10 +02:00
bd0e53525b Refactoring 2021-08-01 00:55:10 +02:00
cbdb68e2f8 Refactoring 2021-08-01 00:55:10 +02:00
8571709014 Refactoring 2021-08-01 00:55:10 +02:00
e7ef1d426e Refactoring 2021-08-01 00:55:10 +02:00
39cba0a8eb Refactoring 2021-08-01 00:55:10 +02:00
a90c314e30 Refactoring 2021-08-01 00:55:10 +02:00
47d71405e1 Refactoring 2021-08-01 00:55:10 +02:00
5e9cecc6c1 Refactoring 2021-08-01 00:55:10 +02:00
fb9e66318f Improve test 2021-08-01 00:55:10 +02:00
b8194eb64f Clean up code 2021-08-01 00:55:10 +02:00
cbb81916ee Sort imports 2021-08-01 00:55:10 +02:00
9b1e9397a8 add errors in case gross performance is not set 2021-08-01 00:55:10 +02:00
b779964adb add error handling to performance aggregation 2021-08-01 00:55:10 +02:00
409afac2a9 fix rate conversion for todays symbols 2021-08-01 00:55:10 +02:00
e0a4e16ea1 Improve error handling 2021-08-01 00:55:10 +02:00
dc84abdc0a change performance report to portfolio calculator 2021-08-01 00:55:10 +02:00
b031b028f1 add today to getValues 2021-08-01 00:55:10 +02:00
3b7e0a0106 remove unnecessary if condition 2021-08-01 00:55:10 +02:00
ea66081073 move interfaces to separate files 2021-08-01 00:55:10 +02:00
602a770a09 fix typo 2021-08-01 00:55:10 +02:00
e522722aa6 Update comment 2021-08-01 00:55:10 +02:00
03ca5d7663 add further tests for portfolio-calculator with one transaction 2021-08-01 00:55:10 +02:00
136563c949 fix single buy test 2021-08-01 00:55:10 +02:00
948c45c602 Update test 2021-08-01 00:55:10 +02:00
e0be792e46 Re-enable all tests 2021-08-01 00:55:10 +02:00
c3d010135f Add test for single buy 2021-08-01 00:55:10 +02:00
d6a16a6093 Improve error handling 2021-08-01 00:55:10 +02:00
34c13c80ec add error handling for current positions 2021-08-01 00:55:10 +02:00
f65a108436 change hardcoded timestamp in test to timezone specific 2021-08-01 00:55:10 +02:00
993f066e08 remove console.time statements during portfolio calls 2021-08-01 00:55:10 +02:00
852902d1ab add current position calculation with holding period return calculation 2021-08-01 00:55:10 +02:00
ee89822bfe Fix tests 2021-08-01 00:55:10 +02:00
e0435e5cad Add name to position 2021-08-01 00:55:10 +02:00
e2c23703dc Fix tests 2021-08-01 00:55:10 +02:00
1226c26a9d Refactor positions 2021-08-01 00:55:10 +02:00
fdc89f7182 optimize performance of positions endpoint 2021-08-01 00:55:10 +02:00
1e368d6e2d Extend type 2021-08-01 00:55:10 +02:00
04e03bd080 add multi-date fetch for current values 2021-08-01 00:55:10 +02:00
66e7ad3fd2 prepare for multi-day database fetch 2021-08-01 00:55:10 +02:00
b4dc21dd61 Implement new positions endpoint 2021-08-01 00:55:10 +02:00
8a482e63b9 fix gross performance number type 2021-08-01 00:55:10 +02:00
aabfb39e8f optimize portfolio calculator to fetch all symbols for one day 2021-08-01 00:55:10 +02:00
cdc8faff7f add current position gross performance (percentage) 2021-08-01 00:55:10 +02:00
7b696e39de add sorting and symbol as a result 2021-08-01 00:55:10 +02:00
c88ad2c225 change getValues to multiple symbols 2021-08-01 00:55:10 +02:00
fbc9269abf disable naming convention eslint 2021-08-01 00:55:10 +02:00
cbe079ae66 ignore missing values 2021-08-01 00:55:10 +02:00
8e4ee7feea optimize order loading 2021-08-01 00:55:10 +02:00
f1b3c61675 add additional time measurements 2021-08-01 00:55:10 +02:00
24dc312367 Add logs to benchmark 2021-08-01 00:55:10 +02:00
7ac7442f73 Declare functions explicitly as public 2021-08-01 00:55:10 +02:00
099571437e Extend current rate service with getRange() 2021-08-01 00:55:10 +02:00
7dac059a55 Sort imports 2021-08-01 00:55:10 +02:00
48fbeda72d Sort imports 2021-08-01 00:55:10 +02:00
19007cdc34 Optimize market data query 2021-08-01 00:55:10 +02:00
5037393866 Support today in current rate service 2021-08-01 00:55:10 +02:00
ddf24163b4 optimize database query execution for portfolio chart 2021-08-01 00:55:10 +02:00
b26521c4bd add workaround for database date search 2021-08-01 00:55:10 +02:00
cfee6c1ddd add draft integration of new portfolio calculator to chart 2021-08-01 00:55:10 +02:00
19bcd601d1 add gross performance 2021-08-01 00:55:10 +02:00
836df69e68 add calculation of current investment 2021-08-01 00:55:10 +02:00
dd86adcea1 add investment to timeline calculation 2021-08-01 00:55:10 +02:00
4f7628921d add timeline time point calculation 2021-08-01 00:55:10 +02:00
88f0cb095d make it pretty 2021-08-01 00:55:10 +02:00
7538133d09 implement getCurrentPositions of PortfolioCalculator 2021-08-01 00:55:10 +02:00
50b280c5a6 Fix version of big.js 2021-08-01 00:55:10 +02:00
67606e4026 Extend current rate service test 2021-08-01 00:55:10 +02:00
9de56c32ac create base structure for portfolio rewrite
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-01 00:55:10 +02:00
5c0f710563 Release 1.30.0 (#238) 2021-07-31 11:29:06 +02:00
1c65599a16 Feature/add blog (#218)
* Setup blog

* Add german blog post

* Add english blog post

* Update changelog
2021-07-31 11:21:32 +02:00
61e667213e Feature/add date range selector to positions tab (#234)
* Add date range selector

* Update changelog
2021-07-27 22:38:55 +02:00
ba47212057 Release 1.29.0 (#233) 2021-07-26 22:14:04 +02:00
f0c6517019 Improve tabs layout (#232) 2021-07-26 21:36:52 +02:00
80ba112bc0 Feature/change menu icon if menu open (#231)
* Change menu icon

* Update changelog
2021-07-26 21:06:10 +02:00
40696b425e Feature/add tabs on the home page (#230)
* Add tabs

* Update changelog
2021-07-26 20:23:32 +02:00
6dbdf23a68 Release 1.28.0 (#229) 2021-07-24 21:33:38 +02:00
cdcbe3ab71 Feature/various layout improvements (#224)
* Various layout improvements

* Update changelog
2021-07-24 21:17:06 +02:00
6996e5a140 Feature/add data gathering for symbol profile data (#228)
* Implement profile data gathering

* Update changelog
2021-07-24 21:13:48 +02:00
be8d60968d Feature/improve active menu button style (#227)
* Improve text decoration style

* Update changelog
2021-07-24 13:04:37 +02:00
d53e5c4da5 Refactor auth guard (#226) 2021-07-24 10:57:03 +02:00
a3a9957196 Release 1.27.0 (#223) 2021-07-18 20:05:46 +02:00
9072cbdba1 Feature/add no transactions info on zen page (#222)
* Add no transactions info to zen page

* Update changelog
2021-07-18 17:34:28 +02:00
120b691336 Bugfix/fix url to fear and greed index (#221)
* Fix url

* Update changelog
2021-07-18 09:33:23 +02:00
bd4ad76953 Feature/remove pause in onboarding (#220)
* Improve onboarding
  * Remove pause
  * Add icon

* Update changelog
2021-07-18 08:19:05 +02:00
94d56f553f Bugfix/fix chart on landing page (#219)
* Fix chart on landing page

* Update changelog
2021-07-17 20:48:08 +02:00
ecdd325228 Release 1.26.0 (#217) 2021-07-17 11:06:14 +02:00
51fbc538ca Feature/set public stripe key dynamically (#216)
* Set public Stripe key dynamically

* Update changelog
2021-07-17 11:04:43 +02:00
39a76f7f40 Feature/add robots.txt (#215)
* Add robots.txt

* Update changelog
2021-07-16 21:32:02 +02:00
e4d325daab Feature/various style improvements (#214)
* Improve styles

* Update changelog
2021-07-15 18:10:18 +02:00
b765df65d6 Improve wording (#213) 2021-07-14 20:54:23 +02:00
c7b7efae3b Feature/import transactions (#212)
* Implement import transactions functionality

* Update changelog
2021-07-14 20:54:05 +02:00
be5b58f49a Bugfix/fix warn color (#211)
* Fix warn color

* Update changelog
2021-07-13 20:29:22 +02:00
91c748c7ad Release 1.25.0 (#210) 2021-07-11 17:21:24 +02:00
ecfe694f0b Feature/export transactions (#209)
* Export functionality for transactions

* Update changelog
2021-07-11 17:05:58 +02:00
1491bf7f76 Update changelog (#208) 2021-07-11 10:37:02 +02:00
b3b9a051c3 Shorten slogan (#207) 2021-07-11 10:31:36 +02:00
bf1146bfd6 Feature/change slogan to wealth management (#206)
* Harmonize slogans to "Open Source Wealth Management Software"

* Update changelog
2021-07-10 19:20:02 +02:00
0774ca91a1 Improve settings selectors layout (#205) 2021-07-10 18:17:17 +02:00
f403807f2d Bugfix/fix average buy price calculation (#204)
* Fix average buy price calculation

* Update changelog
2021-07-10 18:16:46 +02:00
f22991b090 Feature/respect cash balance in analysis (#203)
* Respect cash balance in in analysis

* Update changelog
2021-07-10 14:57:03 +02:00
1135a5b335 Fix rendering of currency and platform in dialogs and clean up observables (#202) 2021-07-08 21:28:28 +02:00
d9ea255c17 Release 1.24.0 (#201) 2021-07-07 21:45:57 +02:00
2c19d8c8e7 Feature/add balance to account (#193)
* Add balance attribute and calculate total balance

* Update changelog
2021-07-07 21:23:36 +02:00
db090229ce Feature/add total value in the create or edit transaction dialog (#192)
* Display total value

* Update changelog
2021-07-07 21:14:01 +02:00
fbe590ddb9 Feature/upgrade angular material css vars to 2.0.0 (#200)
* Upgrade angular-material-css-vars

* Update changelog
2021-07-05 21:53:30 +02:00
0d65136a9e Revert "Remove unneeded dependencies (#197)" (#199)
This reverts commit a062a3cee4.
2021-07-05 20:35:10 +02:00
dea87cc3cf Improve README.md (#198) 2021-07-04 22:19:09 +02:00
a062a3cee4 Remove unneeded dependencies (#197) 2021-07-04 22:11:47 +02:00
5b1b207a6f Feature/upgrade angular dependencies to version 12.0.x (#196)
* Update angular dependencies to version 12.0.X

* Update changelog
2021-07-04 21:55:25 +02:00
63cc7b2871 Feature/upgrade nestjs dependencies (#195)
* Upgrade @nestjs dependencies

* Update changelog
2021-07-04 21:45:53 +02:00
3986e8f879 Upgrade Nx to version 12.5.4 (#194) 2021-07-04 21:31:15 +02:00
290e93bbd7 Release 1.23.1 (#191) 2021-07-03 12:20:36 +02:00
b08ecd1b18 Release 1.23.0 (#190) 2021-07-03 11:51:26 +02:00
92d321a001 Drafts for orders (#187)
* Render the future with a dashed border

* Update changelog
2021-07-03 11:32:03 +02:00
ce2d8d519d Change from travis-ci.org to travis-ci.com (#188) 2021-06-30 21:52:29 +02:00
f32bef071e Add contributing section (#186) 2021-06-27 10:23:51 +02:00
4aa7365d9b Release 1.22.0 (#185) 2021-06-25 17:34:49 +02:00
367f25a975 Feature/set user id in stripe callback (#184)
* Set user id as description

* Update changelog
2021-06-24 21:52:41 +02:00
9832334da1 Move @types/lodash to dev dependencies (#183) 2021-06-23 17:36:40 +02:00
e126f9ec54 Release 1.21.0 (#182) 2021-06-22 21:55:00 +02:00
09bbda3502 Change from subscription to one time payment (#181) 2021-06-22 21:53:29 +02:00
ee9a521813 Bugfix/fix base currency in pricing page (#180)
* Fix base currency

* Update changelog
2021-06-21 20:52:01 +02:00
169c151547 Feature/improve style of about page (#177)
* Improve style

* Update changelog
2021-06-21 20:08:45 +02:00
3a95ec0f81 Release 1.20.0 (#179) 2021-06-21 20:05:54 +02:00
ad00cd9d81 Feature/setup subscription with stripe (#178)
* Set up stripe for subscriptions

* Update permissions and add discount

* Update changelog
2021-06-21 20:03:36 +02:00
373a2015c0 Release 1.19.0 (#176) 2021-06-17 23:01:59 +02:00
66c955ad6c Feature/ghostfolio in numbers (#175)
* Add Ghostfolio in numbers section

* Update changelog
2021-06-17 22:59:48 +02:00
a2440fc067 Release 1.18.0 (#174) 2021-06-16 17:34:43 +02:00
3d7624d997 Feature/improve twa onboarding (#173)
* Improve TWA onboarding (Redirect to the account registration page)

* Update changelog
2021-06-16 17:31:31 +02:00
0264b592b9 Feature/improve investments by sector (#172)
* Improve investments analysis by sector

* Update changelog
2021-06-16 17:05:43 +02:00
198eaf57d3 Release 1.17.0 (#171) 2021-06-15 21:17:41 +02:00
6783ea2ebb Feature/upgrade various frontend dependencies (#170)
* Upgrade frontend dependencies

* Update changelog
2021-06-15 21:15:48 +02:00
a35701fe24 Feature/upgrade to angular 12 (#169)
* Upgrade to Angular 12

* Update changelog
2021-06-15 21:03:55 +02:00
5db90f1787 Feature/improve error page of fingerprint sign in (#167)
* Improve error page

* Update changelog
2021-06-15 09:47:18 +02:00
81fe538484 Order attribute 2021-06-15 09:43:48 +02:00
51884913be Feature/disable fingerprint sign in in demo account page (#163)
* Disable fingerprint toggle for demo user

* Update changelog
2021-06-15 09:21:53 +02:00
8886082dfa Feature/upgrade eslint and prettier dependencies (#164)
* Upgrade eslint and prettier dependencies

* Feature/upgrade date fns to version 2.22.1 (#165)

* Feature/upgrade chart.js to version 3.3.2 (#166)

* Update changelog
2021-06-15 09:17:27 +02:00
3b12e5b85b Release 1.16.0 (#162) 2021-06-14 22:00:00 +02:00
6c1119caec Restrict webauthn to fingerprint only and improve UX (#161)
* Restrict webauthn to fingerprint only

* Move webauthn login to separate page /webauthn

* Stay signed in with social login

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-06-14 21:57:09 +02:00
698d5ec3b7 Release 1.15.0 (#160) 2021-06-14 16:15:50 +02:00
e87c942cb8 Add webauthn (#82)
* Add webauthn

* Complete WebAuthn device sign up and login

* Move device registration to account page
* Replace the token login with a WebAuthn prompt if the current device has been registered
* Mark the current device in the list of registered auth devices

* Fix after rebase

* Fix tests

* Disable "Add current device" button if current device is registered

* Add option to "Stay signed in"

* Remove device list feature, sign in with deviceId instead

* Improve usability

* Update changelog

Co-authored-by: Matthias Frey <mfrey43@gmail.com>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-06-14 16:09:40 +02:00
f7860a9799 Feature/introduce max items in portfolio proportion chart (#159)
* Add option to limit items

* Update changelog
2021-06-14 14:18:02 +02:00
c519eb0e99 Bugfix/fix last activity column (#158)
* Fix last activity (only values in the past)

* Update changelog
2021-06-14 14:06:24 +02:00
8314b98f81 Feature/improve tables (#157)
* Improve tables

* Update changelog
2021-06-14 14:02:25 +02:00
194cf1ddcc Feature/clean up analysis page (#155)
* Clean up analysis page

* Update changelog
2021-06-14 13:55:15 +02:00
7da6478699 Improve server start instructions (#156) 2021-06-12 22:29:56 +02:00
4f2bbba782 Release 1.14.0 (#154) 2021-06-09 20:36:31 +02:00
9eb25f6c9e Feature/connect or create logic for symbol profile (#153)
* Add connectOrCreate logic

* Extend seed

* Update changelog
2021-06-09 20:35:02 +02:00
f74b00446c Feature/improve world map chart (#152)
* Improve world map chart

* Update changelog
2021-06-09 20:32:39 +02:00
271 changed files with 12496 additions and 6452 deletions

View File

@ -44,7 +44,7 @@
} }
], ],
"@typescript-eslint/member-ordering": "error", "@typescript-eslint/member-ordering": "error",
"@typescript-eslint/naming-convention": "error", "@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-interface": "error", "@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-inferrable-types": [ "@typescript-eslint/no-inferrable-types": [

View File

@ -5,8 +5,249 @@ 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).
## 1.34.0 - 07.08.2021
### Changed
- Restructured the page hierarchy
### Fixed
- Fixed an issue with the currency conversion of the market price in the position detail dialog
- Fixed the chart and missing data of positions from the past in the position detail dialog
## 1.33.0 - 05.08.2021
### Fixed
- Fixed an issue of a division by zero in the portfolio calculations
- Fixed an issue with the currency conversion in the position detail dialog
## 1.32.0 - 04.08.2021
### Added
- Added the name to the position detail dialog when opened from the transactions table
- Added a screenshot to the blog posts
### Fixed
- Fixed the missing market state in the positions tab
- Fixed the chart of positions with differing currency from user
## 1.31.1 - 01.08.2021
### Fixed
- Fixed an issue with the currency conversion in the portfolio calculations
## 1.31.0 - 01.08.2021
### Added
- Added more data points to the chart
### Changed
- Rewritten the core engine for the portfolio calculations
- Switched to [Time-Weighted Rate of Return](https://www.investopedia.com/terms/t/time-weightedror.asp) (TWR) for the performance calculation
- Improved the performance of the portfolio calculations
## 1.30.0 - 31.07.2021
### Added
- Added the date range component to the positions tab
- Added a blog
## 1.29.0 - 26.07.2021
### Changed
- Introduced tabs on the home page
- Changed the menu icon if the menu is open on mobile
## 1.28.0 - 24.07.2021
### Added
- Extended the data management by symbol profile data
- Added a currency attribute to the symbol profile model
- Added a positions button on the home page which scrolls into the view
### Changed
- Improved the style of the active page in the navigation on desktop
- Removed the footer for users
- Extended the _Zen Mode_ by positions
- Improved the _Create Account_ message in the _Live Demo_
## 1.27.0 - 18.07.2021
### Changed
- Improved the onboarding
- Flow of creating a new account
- Info message to add the first transaction
### Fixed
- Fixed the chart on the landing page
- Fixed the url to the _Fear & Greed Index_ on the resources page
## 1.26.0 - 17.07.2021
### Added
- Added the import functionality for transactions
- Added the `robots.txt` file
### Changed
- Improved the styling of the current pricing plan
- Improved the styling of the transaction type badge
- Set the public _Stripe_ key dynamically
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
### Fixed
- Fixed the warn color (button) of the theme
## 1.25.0 - 11.07.2021
### Added
- Added the export functionality for transactions
### Changed
- Respected the cash balance on the analysis page
- Improved the settings selectors on the account page
- Harmonized the slogan to "Open Source Wealth Management Software"
### Fixed
- Fixed rendering of currency and platform in dialogs (account and transaction)
- Fixed an issue in the calculation of the average buy prices in the position detail chart
## 1.24.0 - 07.07.2021
### Added
- Added the total value in the create or edit transaction dialog
- Added a balance attribute to the account model
- Calculated the total balance (cash)
### Changed
- Upgraded `@angular/cdk` and `@angular/material` from version `11.0.4` to `12.0.6`
- Upgraded `@nestjs` dependencies
- Upgraded `angular-material-css-vars` from version `1.2.0` to `2.0.0`
- Upgraded `Nx` from version `12.3.6` to `12.5.4`
## 1.23.1 - 03.07.2021
### Fixed
- Fixed the investment chart (drafts)
## 1.23.0 - 03.07.2021
### Added
- Added support for future transactions (drafts)
## 1.22.0 - 25.06.2021
### Added
- Set the user id in the _Stripe_ callback
## 1.21.0 - 22.06.2021
### Changed
- Changed _Stripe_ mode from `subscription` to `payment`
### Fixed
- Fixed the base currency on the pricing page
## 1.20.0 - 21.06.2021
### Added
- Set up _Stripe_ for subscriptions
### Changed
- Improved the style of the _Ghostfolio in Numbers_ section
## 1.19.0 - 17.06.2021
### Added
- Added a _Ghostfolio in Numbers_ section to the about page
## 1.18.0 - 16.06.2021
### Changed
- Improved the pie chart: Investments by sector
- Improved the onboarding for TWA by redirecting to the account registration page
## 1.17.0 - 15.06.2021
### Changed
- Improved the error page of the sign in with fingerprint
- Disable the sign in with fingerprint selector for the demo user
- Upgraded `angular` from version `11.2.4` to `12.0.4`
- Upgraded `angular-material-css-vars` from version `1.1.2` to `1.2.0`
- Upgraded `chart.js` from version `3.2.1` to `3.3.2`
- Upgraded `date-fns` from version `2.19.0` to `2.22.1`
- Upgraded `eslint` and `prettier` dependencies
- Upgraded `ngx-device-detector` from version `2.0.6` to `2.1.1`
- Upgraded `ngx-markdown` from version `11.1.2` to `12.0.1`
## 1.16.0 - 14.06.2021
### Changed
- Improved the sign in with fingerprint
## 1.15.0 - 14.06.2021
### Added
- Added a counter column to the transactions table
- Added a label to indicate the default account in the accounts table
- Added an option to limit the items in pie charts
- Added sign in with fingerprint
### Changed
- Cleaned up the analysis page with an unused chart module
- Improved the cell alignment in the users table of the admin control panel
### Fixed
- Fixed the last activity column of users in the admin control panel
## 1.14.0 - 09.06.2021
### Added
- Added a connect or create symbol profile model logic on creating a new transaction
### Changed
- Improved the global heat map to visualize investments by country
## 1.13.0 - 08.06.2021 ## 1.13.0 - 08.06.2021
### Added
- Added a global heat map to visualize investments by country - Added a global heat map to visualize investments by country
## 1.12.0 - 06.06.2021 ## 1.12.0 - 06.06.2021
@ -88,11 +329,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added an index in the user table of the admin control panel - Added an index in the users table of the admin control panel
### Changed ### Changed
- Improved the alignment in the user table of the admin control panel - Improved the alignment in the users table of the admin control panel
## 1.5.0 - 22.05.2021 ## 1.5.0 - 22.05.2021
@ -224,7 +465,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the user table styling of the admin control panel - Improved the users table styling of the admin control panel
- Improved the background colors in the dark mode - Improved the background colors in the dark mode
## 0.92.0 - 25.04.2021 ## 0.92.0 - 25.04.2021
@ -232,7 +473,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Prepared further for multi accounts support: store account for new transactions - Prepared further for multi accounts support: store account for new transactions
- Added a horizontal scrollbar to the user table of the admin control panel - Added a horizontal scrollbar to the users table of the admin control panel
### Fixed ### Fixed
@ -259,7 +500,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the user table of the admin control panel - Improved the users table of the admin control panel
## 0.89.0 - 21.04.2021 ## 0.89.0 - 21.04.2021
@ -290,7 +531,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed an issue in the user table of the admin control panel with missing data - Fixed an issue in the users table of the admin control panel with missing data
## 0.86.1 - 18.04.2021 ## 0.86.1 - 18.04.2021
@ -305,7 +546,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the about page for the new license - Changed the about page for the new license
- Optimized the data management for historical data - Optimized the data management for historical data
- Optimized the exchange rate service - Optimized the exchange rate service
- Improved the user table of the admin control panel - Improved the users table of the admin control panel
### Fixed ### Fixed

View File

@ -1,21 +1,40 @@
<div align="center"> <div align="center">
<a href="https://ghostfol.io">
<img
alt="Ghostfolio Logo"
src="https://avatars.githubusercontent.com/u/82473144?s=200"
width="100"
/>
</a>
<h1>Ghostfolio</h1> <h1>Ghostfolio</h1>
<p> <p>
<strong>Open Source Portfolio Tracker</strong> <strong>Open Source Wealth Management Software made for Humans</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> <a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p> </p>
<p> <p>
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/> <a href="#contributing">
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow"> <img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a> <a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow"> <a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a> <img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</p> </p>
</div> </div>
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules. **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
<div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
</div>
## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
## Why Ghostfolio? ## Why Ghostfolio?
@ -79,26 +98,34 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data 1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development)) 1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9` 1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data 1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Press _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
## Development ## Development
Please make sure you have completed the instructions from [_Setup_](#Setup) Please make sure you have completed the instructions from [_Setup_](#Setup).
### Start server ### Start server
- Debug: Run `yarn watch:server` and click "Launch Program" in _Visual Studio Code_ <ol type="a">
- Serve: Run `yarn start:server` <li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
<li>Serve: Run <code>yarn start:server</code></li>
</ol>
### Start client ### Start client
- Run `yarn start:client` Run `yarn start:client`
## Testing ## Testing
Run `yarn test` Run `yarn test`
## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
## License ## License
© 2021 [Ghostfolio](https://ghostfol.io) © 2021 [Ghostfolio](https://ghostfol.io)

View File

@ -86,7 +86,6 @@
"main": "apps/client/src/main.ts", "main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts", "polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json", "tsConfig": "apps/client/tsconfig.app.json",
"aot": true,
"assets": [ "assets": [
"apps/client/src/assets", "apps/client/src/assets",
{ {
@ -104,6 +103,11 @@
"input": "", "input": "",
"output": "./" "output": "./"
}, },
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./"
},
{ {
"glob": "sitemap.xml", "glob": "sitemap.xml",
"input": "apps/client/src/assets", "input": "apps/client/src/assets",
@ -121,7 +125,13 @@
} }
], ],
"styles": ["apps/client/src/styles.scss"], "styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"] "scripts": ["node_modules/marked/lib/marked.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -152,7 +162,8 @@
] ]
} }
}, },
"outputs": ["{options.outputPath}"] "outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",

View File

@ -11,5 +11,6 @@ module.exports = {
}, },
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000 testTimeout: 10000,
testEnvironment: 'node'
}; };

View File

@ -4,6 +4,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -20,6 +21,7 @@ import { AccountService } from './account.service';
AlphaVantageService, AlphaVantageService,
ConfigurationService, ConfigurationService,
DataProviderService, DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
ImpersonationService, ImpersonationService,
PrismaService, PrismaService,

View File

@ -1,12 +1,15 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Account, Order, Prisma } from '@prisma/client'; import { Account, Currency, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service'; import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface';
@Injectable() @Injectable()
export class AccountService { export class AccountService {
public constructor( public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService private prisma: PrismaService
) {} ) {}
@ -73,6 +76,27 @@ export class AccountService {
}); });
} }
public async getCashDetails(
aUserId: string,
aCurrency: Currency
): Promise<CashDetails> {
let totalCashBalance = 0;
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
);
});
return { accounts, balance: totalCashBalance };
}
public async updateAccount( public async updateAccount(
params: { params: {
where: Prisma.AccountWhereUniqueInput; where: Prisma.AccountWhereUniqueInput;

View File

@ -1,10 +1,16 @@
import { AccountType } from '@prisma/client'; import { AccountType, Currency } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator'; import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto { export class CreateAccountDto {
@IsString() @IsString()
accountType: AccountType; accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString() @IsString()
name: string; name: string;

View File

@ -0,0 +1,6 @@
import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
balance: number;
}

View File

@ -1,10 +1,16 @@
import { AccountType } from '@prisma/client'; import { AccountType, Currency } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator'; import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto { export class UpdateAccountDto {
@IsString() @IsString()
accountType: AccountType; accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString() @IsString()
id: string; id: string;

View File

@ -61,8 +61,29 @@ export class AdminController {
); );
} }
await this.dataGatheringService.gatherProfileData();
this.dataGatheringService.gatherMax(); this.dataGatheringService.gatherMax();
return; return;
} }
@Post('gather/profile-data')
@UseGuards(AuthGuard('jwt'))
public async gatherProfileData(): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherProfileData();
return;
}
} }

View File

@ -1,5 +1,6 @@
import { join } from 'path'; import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -21,11 +22,15 @@ import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { CoreModule } from './core/core.module';
import { ExperimentalModule } from './experimental/experimental.module'; import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -34,10 +39,14 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
AuthDeviceModule,
AuthModule, AuthModule,
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
CoreModule,
ExperimentalModule, ExperimentalModule,
ExportModule,
ImportModule,
InfoModule, InfoModule,
OrderModule, OrderModule,
PortfolioModule, PortfolioModule,
@ -57,6 +66,7 @@ import { UserModule } from './user/user.module';
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*'] exclude: ['/api*']
}), }),
SubscriptionModule,
SymbolModule, SymbolModule,
UserModule UserModule
], ],

View File

@ -0,0 +1,44 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('auth-device')
export class AuthDeviceController {
public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAuthDevice
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id });
}
}

View File

@ -0,0 +1,4 @@
export interface AuthDeviceDto {
createdAt: string;
id: string;
}

View File

@ -0,0 +1,18 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@Module({
controllers: [AuthDeviceController],
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
})
],
providers: [AuthDeviceService, ConfigurationService, PrismaService]
})
export class AuthDeviceModule {}

View File

@ -0,0 +1,65 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { AuthDevice, Prisma } from '@prisma/client';
@Injectable()
export class AuthDeviceService {
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
) {}
public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice | null> {
return this.prisma.authDevice.findUnique({
where
});
}
public async authDevices(params: {
skip?: number;
take?: number;
cursor?: Prisma.AuthDeviceWhereUniqueInput;
where?: Prisma.AuthDeviceWhereInput;
orderBy?: Prisma.AuthDeviceOrderByInput;
}): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.authDevice.findMany({
skip,
take,
cursor,
where,
orderBy
});
}
public async createAuthDevice(
data: Prisma.AuthDeviceCreateInput
): Promise<AuthDevice> {
return this.prisma.authDevice.create({
data
});
}
public async updateAuthDevice(params: {
data: Prisma.AuthDeviceUpdateInput;
where: Prisma.AuthDeviceWhereUniqueInput;
}): Promise<AuthDevice> {
const { data, where } = params;
return this.prisma.authDevice.update({
data,
where
});
}
public async deleteAuthDevice(
where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice> {
return this.prisma.authDevice.delete({
where
});
}
}

View File

@ -1,9 +1,12 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { import {
Body,
Controller, Controller,
Get, Get,
HttpException, HttpException,
Param, Param,
Post,
Req, Req,
Res, Res,
UseGuards UseGuards
@ -12,12 +15,17 @@ import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly webAuthService: WebAuthService
) {} ) {}
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
@ -53,4 +61,44 @@ export class AuthController {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`); res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
} }
} }
@Get('webauthn/generate-attestation-options')
@UseGuards(AuthGuard('jwt'))
public async generateAttestationOptions() {
return this.webAuthService.generateAttestationOptions();
}
@Post('webauthn/verify-attestation')
@UseGuards(AuthGuard('jwt'))
public async verifyAttestation(
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) {
return this.webAuthService.verifyAttestation(
body.deviceName,
body.credential
);
}
@Post('webauthn/generate-assertion-options')
public async generateAssertionOptions(@Body() body: { deviceId: string }) {
return this.webAuthService.generateAssertionOptions(body.deviceId);
}
@Post('webauthn/verify-assertion')
public async verifyAssertion(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) {
try {
const authToken = await this.webAuthService.verifyAssertion(
body.deviceId,
body.credential
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
} }

View File

@ -1,3 +1,5 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -18,12 +20,14 @@ import { JwtStrategy } from './jwt.strategy';
}) })
], ],
providers: [ providers: [
AuthDeviceService,
AuthService, AuthService,
ConfigurationService, ConfigurationService,
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
PrismaService, PrismaService,
UserService UserService,
WebAuthService
] ]
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,5 +1,10 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
export interface AuthDeviceDialogParams {
authDevice: AuthDeviceDto;
}
export interface ValidateOAuthLoginParams { export interface ValidateOAuthLoginParams {
provider: Provider; provider: Provider;
thirdPartyId: string; thirdPartyId: string;

View File

@ -0,0 +1,226 @@
export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
readonly authenticatorData: ArrayBuffer;
readonly signature: ArrayBuffer;
readonly userHandle: ArrayBuffer | null;
}
export interface AuthenticatorAttestationResponse
extends AuthenticatorResponse {
readonly attestationObject: ArrayBuffer;
}
export interface AuthenticationExtensionsClientInputs {
appid?: string;
appidExclude?: string;
credProps?: boolean;
uvm?: boolean;
}
export interface AuthenticationExtensionsClientOutputs {
appid?: boolean;
credProps?: CredentialPropertiesOutput;
uvm?: UvmEntries;
}
export interface AuthenticatorSelectionCriteria {
authenticatorAttachment?: AuthenticatorAttachment;
requireResidentKey?: boolean;
residentKey?: ResidentKeyRequirement;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredential extends Credential {
readonly rawId: ArrayBuffer;
readonly response: AuthenticatorResponse;
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
}
export interface PublicKeyCredentialCreationOptions {
attestation?: AttestationConveyancePreference;
authenticatorSelection?: AuthenticatorSelectionCriteria;
challenge: BufferSource;
excludeCredentials?: PublicKeyCredentialDescriptor[];
extensions?: AuthenticationExtensionsClientInputs;
pubKeyCredParams: PublicKeyCredentialParameters[];
rp: PublicKeyCredentialRpEntity;
timeout?: number;
user: PublicKeyCredentialUserEntity;
}
export interface PublicKeyCredentialDescriptor {
id: BufferSource;
transports?: AuthenticatorTransport[];
type: PublicKeyCredentialType;
}
export interface PublicKeyCredentialParameters {
alg: COSEAlgorithmIdentifier;
type: PublicKeyCredentialType;
}
export interface PublicKeyCredentialRequestOptions {
allowCredentials?: PublicKeyCredentialDescriptor[];
challenge: BufferSource;
extensions?: AuthenticationExtensionsClientInputs;
rpId?: string;
timeout?: number;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredentialUserEntity
extends PublicKeyCredentialEntity {
displayName: string;
id: BufferSource;
}
export interface AuthenticatorResponse {
readonly clientDataJSON: ArrayBuffer;
}
export interface CredentialPropertiesOutput {
rk?: boolean;
}
export interface Credential {
readonly id: string;
readonly type: string;
}
export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity {
id?: string;
}
export interface PublicKeyCredentialEntity {
name: string;
}
export declare type AttestationConveyancePreference =
| 'direct'
| 'enterprise'
| 'indirect'
| 'none';
export declare type AuthenticatorTransport = 'ble' | 'internal' | 'nfc' | 'usb';
export declare type COSEAlgorithmIdentifier = number;
export declare type UserVerificationRequirement =
| 'discouraged'
| 'preferred'
| 'required';
export declare type UvmEntries = UvmEntry[];
export declare type AuthenticatorAttachment = 'cross-platform' | 'platform';
export declare type ResidentKeyRequirement =
| 'discouraged'
| 'preferred'
| 'required';
export declare type BufferSource = ArrayBufferView | ArrayBuffer;
export declare type PublicKeyCredentialType = 'public-key';
export declare type UvmEntry = number[];
export interface PublicKeyCredentialCreationOptionsJSON
extends Omit<
PublicKeyCredentialCreationOptions,
'challenge' | 'user' | 'excludeCredentials'
> {
user: PublicKeyCredentialUserEntityJSON;
challenge: Base64URLString;
excludeCredentials: PublicKeyCredentialDescriptorJSON[];
extensions?: AuthenticationExtensionsClientInputs;
}
/**
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to
* (eventually) get passed into navigator.credentials.get(...) in the browser.
*/
export interface PublicKeyCredentialRequestOptionsJSON
extends Omit<
PublicKeyCredentialRequestOptions,
'challenge' | 'allowCredentials'
> {
challenge: Base64URLString;
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
extensions?: AuthenticationExtensionsClientInputs;
}
export interface PublicKeyCredentialDescriptorJSON
extends Omit<PublicKeyCredentialDescriptor, 'id'> {
id: Base64URLString;
}
export interface PublicKeyCredentialUserEntityJSON
extends Omit<PublicKeyCredentialUserEntity, 'id'> {
id: string;
}
/**
* The value returned from navigator.credentials.create()
*/
export interface AttestationCredential extends PublicKeyCredential {
response: AuthenticatorAttestationResponseFuture;
}
/**
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AttestationCredentialJSON
extends Omit<
AttestationCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString;
response: AuthenticatorAttestationResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
transports?: AuthenticatorTransport[];
}
/**
* The value returned from navigator.credentials.get()
*/
export interface AssertionCredential extends PublicKeyCredential {
response: AuthenticatorAssertionResponse;
}
/**
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AssertionCredentialJSON
extends Omit<
AssertionCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString;
response: AuthenticatorAssertionResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
}
/**
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AuthenticatorAttestationResponseJSON
extends Omit<
AuthenticatorAttestationResponseFuture,
'clientDataJSON' | 'attestationObject'
> {
clientDataJSON: Base64URLString;
attestationObject: Base64URLString;
}
/**
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AuthenticatorAssertionResponseJSON
extends Omit<
AuthenticatorAssertionResponse,
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
> {
authenticatorData: Base64URLString;
clientDataJSON: Base64URLString;
signature: Base64URLString;
userHandle?: string;
}
/**
* A WebAuthn-compatible device and the information needed to verify assertions by it
*/
export declare type AuthenticatorDevice = {
credentialPublicKey: Buffer;
credentialID: Buffer;
counter: number;
transports?: AuthenticatorTransport[];
};
/**
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
*/
export declare type Base64URLString = string;
/**
* AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7).
* Maintain an augmented version here so we can implement additional properties as the WebAuthn
* spec evolves.
*
* See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse
*
* Properties marked optional are not supported in all browsers.
*/
export interface AuthenticatorAttestationResponseFuture
extends AuthenticatorAttestationResponse {
getTransports?: () => AuthenticatorTransport[];
getAuthenticatorData?: () => ArrayBuffer;
getPublicKey?: () => ArrayBuffer;
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[];
}

View File

@ -0,0 +1,216 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Inject,
Injectable,
InternalServerErrorException
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import {
GenerateAssertionOptionsOpts,
GenerateAttestationOptionsOpts,
VerifiedAssertion,
VerifiedAttestation,
VerifyAssertionResponseOpts,
VerifyAttestationResponseOpts,
generateAssertionOptions,
generateAttestationOptions,
verifyAssertionResponse,
verifyAttestationResponse
} from '@simplewebauthn/server';
import { UserService } from '../user/user.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Injectable()
export class WebAuthService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService,
private readonly jwtService: JwtService,
private readonly userService: UserService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
get rpID() {
return this.configurationService.get('WEB_AUTH_RP_ID');
}
get expectedOrigin() {
return this.configurationService.get('ROOT_URL');
}
public async generateAttestationOptions() {
const user = this.request.user;
const opts: GenerateAttestationOptionsOpts = {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userName: user.alias,
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: false,
userVerification: 'required'
}
};
const options = generateAttestationOptions(opts);
await this.userService.updateUser({
data: {
authChallenge: options.challenge
},
where: {
id: user.id
}
});
return options;
}
public async verifyAttestation(
deviceName: string,
credential: AttestationCredentialJSON
): Promise<AuthDeviceDto> {
const user = this.request.user;
const expectedChallenge = user.authChallenge;
let verification: VerifiedAttestation;
try {
const opts: VerifyAttestationResponseOpts = {
credential,
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
};
verification = await verifyAttestationResponse(opts);
} catch (error) {
console.error(error);
throw new InternalServerErrorException(error.message);
}
const { verified, attestationInfo } = verification;
const devices = await this.deviceService.authDevices({
where: { userId: user.id }
});
if (verified && attestationInfo) {
const { credentialPublicKey, credentialID, counter } = attestationInfo;
let existingDevice = devices.find(
(device) => device.credentialId === credentialID
);
if (!existingDevice) {
/**
* Add the returned device to the user's list of devices
*/
existingDevice = await this.deviceService.createAuthDevice({
credentialPublicKey,
credentialId: credentialID,
counter,
User: { connect: { id: user.id } }
});
}
return {
createdAt: existingDevice.createdAt.toISOString(),
id: existingDevice.id
};
}
throw new InternalServerErrorException('An unknown error occurred');
}
public async generateAssertionOptions(deviceId: string) {
const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) {
throw new Error('Device not found');
}
const opts: GenerateAssertionOptionsOpts = {
timeout: 60000,
allowCredentials: [
{
id: device.credentialId,
type: 'public-key',
transports: ['internal']
}
],
userVerification: 'preferred',
rpID: this.rpID
};
const options = generateAssertionOptions(opts);
await this.userService.updateUser({
data: {
authChallenge: options.challenge
},
where: {
id: device.userId
}
});
return options;
}
public async verifyAssertion(
deviceId: string,
credential: AssertionCredentialJSON
) {
const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) {
throw new Error('Device not found');
}
const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAssertion;
try {
const opts: VerifyAssertionResponseOpts = {
credential,
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
counter: device.counter
}
};
verification = verifyAssertionResponse(opts);
} catch (error) {
console.error(error);
throw new InternalServerErrorException({ error: error.message });
}
const { verified, assertionInfo } = verification;
if (verified) {
device.counter = assertionInfo.newCounter;
await this.deviceService.updateAuthDevice({
data: device,
where: { id: device.id }
});
return this.jwtService.sign({
id: user.id
});
}
throw new Error();
}
}

View File

@ -0,0 +1,30 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
@Module({
imports: [],
controllers: [],
providers: [
AlphaVantageService,
ConfigurationService,
CurrentRateService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
MarketDataService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class CoreModule {}

View File

@ -0,0 +1,129 @@
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Currency, MarketData } from '@prisma/client';
import { MarketDataService } from './market-data.service';
jest.mock('./market-data.service', () => {
return {
MarketDataService: jest.fn().mockImplementation(() => {
return {
get: (date: Date, symbol: string) => {
return Promise.resolve<MarketData>({
date,
symbol,
createdAt: date,
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
marketPrice: 1847.839966
});
},
getRange: ({
dateRangeEnd,
dateRangeStart,
symbols
}: {
dateRangeEnd: Date;
dateRangeStart: Date;
symbols: string[];
}) => {
return Promise.resolve<MarketData[]>([
{
createdAt: dateRangeStart,
date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
symbol: symbols[0]
},
{
createdAt: dateRangeEnd,
date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
symbol: symbols[0]
}
]);
}
};
})
};
});
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
toCurrency: (value: number) => {
return 1 * value;
}
};
})
};
});
describe('CurrentRateService', () => {
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let marketDataService: MarketDataService;
beforeAll(async () => {
dataProviderService = new DataProviderService(
null,
null,
null,
null,
null,
null
);
exchangeRateDataService = new ExchangeRateDataService(null);
marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize();
currentRateService = new CurrentRateService(
dataProviderService,
exchangeRateDataService,
marketDataService
);
});
it('getValue', async () => {
expect(
await currentRateService.getValue({
currency: Currency.USD,
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
symbol: 'AMZN',
userCurrency: Currency.CHF
})
).toMatchObject({
marketPrice: 1847.839966
});
});
it('getValues', async () => {
expect(
await currentRateService.getValues({
currencies: { AMZN: Currency.USD },
dateQuery: {
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
},
symbols: ['AMZN'],
userCurrency: Currency.CHF
})
).toMatchObject([
{
date: undefined,
marketPrice: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPrice: 1847.839966,
symbol: 'AMZN'
}
]);
});
});

View File

@ -0,0 +1,128 @@
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
import { GetValueParams } from '@ghostfolio/api/app/core/interfaces/get-value-params.interface';
import { GetValuesParams } from '@ghostfolio/api/app/core/interfaces/get-values-params.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
import { MarketDataService } from './market-data.service';
@Injectable()
export class CurrentRateService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService
) {}
public async getValue({
currency,
date,
symbol,
userCurrency
}: GetValueParams): Promise<GetValueObject> {
if (isToday(date)) {
const dataProviderResult = await this.dataProviderService.get([symbol]);
return {
date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
symbol: symbol
};
}
const marketData = await this.marketDataService.get({
date,
symbol
});
if (marketData) {
return {
date: marketData.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketData.marketPrice,
currency,
userCurrency
),
symbol: marketData.symbol
};
}
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
}
public async getValues({
currencies,
dateQuery,
symbols,
userCurrency
}: GetValuesParams): Promise<GetValueObject[]> {
const includeToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise<
{
date: Date;
marketPrice: number;
symbol: string;
}[]
>[] = [];
if (includeToday) {
const today = resetHours(new Date());
promises.push(
this.dataProviderService.get(symbols).then((dataResultProvider) => {
const result = [];
for (const symbol of symbols) {
result.push({
symbol,
date: today,
marketPrice: this.exchangeRateDataService.toCurrency(
dataResultProvider?.[symbol]?.marketPrice ?? 0,
dataResultProvider?.[symbol]?.currency,
userCurrency
)
});
}
return result;
})
);
}
promises.push(
this.marketDataService
.getRange({
dateQuery,
symbols
})
.then((data) => {
return data.map((marketDataItem) => {
return {
date: marketDataItem.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice,
currencies[marketDataItem.symbol],
userCurrency
),
symbol: marketDataItem.symbol
};
});
})
);
return flatten(await Promise.all(promises));
}
private containsToday(dates: Date[]): boolean {
for (const date of dates) {
if (isToday(date)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,11 @@
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
export interface CurrentPositions {
hasErrors: boolean;
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;
currentValue: Big;
totalInvestment: Big;
}

View File

@ -0,0 +1,5 @@
export interface DateQuery {
gte?: Date;
in?: Date[];
lt?: Date;
}

View File

@ -0,0 +1,5 @@
export interface GetValueObject {
date: Date;
marketPrice: number;
symbol: string;
}

View File

@ -0,0 +1,8 @@
import { Currency } from '@prisma/client';
export interface GetValueParams {
currency: Currency;
date: Date;
symbol: string;
userCurrency: Currency;
}

View File

@ -0,0 +1,9 @@
import { DateQuery } from '@ghostfolio/api/app/core/interfaces/date-query.interface';
import { Currency } from '@prisma/client';
export interface GetValuesParams {
currencies: { [symbol: string]: Currency };
dateQuery: DateQuery;
symbols: string[];
userCurrency: Currency;
}

View File

@ -0,0 +1,13 @@
import { OrderType } from '@ghostfolio/api/models/order-type';
import { Currency } from '@prisma/client';
import Big from 'big.js';
export interface PortfolioOrder {
currency: Currency;
date: string;
name: string;
quantity: Big;
symbol: string;
type: OrderType;
unitPrice: Big;
}

View File

@ -0,0 +1,8 @@
import Big from 'big.js';
export interface TimelinePeriod {
date: string;
grossPerformance: Big;
investment: Big;
value: Big;
}

View File

@ -0,0 +1,6 @@
export type Accuracy = 'day' | 'month' | 'year';
export interface TimelineSpecification {
accuracy: Accuracy;
start: string;
}

View File

@ -0,0 +1,11 @@
import { Currency } from '@prisma/client';
import Big from 'big.js';
export interface TransactionPointSymbol {
currency: Currency;
firstBuyDate: string;
investment: Big;
quantity: Big;
symbol: string;
transactionCount: number;
}

View File

@ -0,0 +1,6 @@
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
export interface TransactionPoint {
date: string;
items: TransactionPointSymbol[];
}

View File

@ -0,0 +1,51 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { MarketData } from '@prisma/client';
import { DateQuery } from './interfaces/date-query.interface';
@Injectable()
export class MarketDataService {
public constructor(private prisma: PrismaService) {}
public async get({
date,
symbol
}: {
date: Date;
symbol: string;
}): Promise<MarketData> {
return await this.prisma.marketData.findFirst({
where: {
symbol,
date: resetHours(date)
}
});
}
public async getRange({
dateQuery,
symbols
}: {
dateQuery: DateQuery;
symbols: string[];
}): Promise<MarketData[]> {
return await this.prisma.marketData.findMany({
orderBy: [
{
date: 'asc'
},
{
symbol: 'asc'
}
],
where: {
date: dateQuery,
symbol: {
in: symbols
}
}
});
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,567 @@
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
import { TimelinePeriod } from '@ghostfolio/api/app/core/interfaces/timeline-period.interface';
import {
Accuracy,
TimelineSpecification
} from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
import { OrderType } from '@ghostfolio/api/models/order-type';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
addMonths,
addYears,
endOfDay,
format,
isAfter,
isBefore,
max,
min
} from 'date-fns';
import { flatten } from 'lodash';
export class PortfolioCalculator {
private transactionPoints: TransactionPoint[];
public constructor(
private currentRateService: CurrentRateService,
private currency: Currency
) {}
public computeTransactionPoints(orders: PortfolioOrder[]) {
orders.sort((a, b) => a.date.localeCompare(b.date));
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
for (const order of orders) {
const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[order.symbol];
const factor = this.getFactor(order.type);
const unitPrice = new Big(order.unitPrice);
if (oldAccumulatedSymbol) {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = {
currency: order.currency,
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.add(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
currentTransactionPointItem = {
currency: order.currency,
firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor),
symbol: order.symbol,
transactionCount: 1
};
}
symbols[order.symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
);
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = {
date: currentDate,
items: newItems
};
this.transactionPoints.push(lastTransactionPoint);
} else {
lastTransactionPoint.items = newItems;
}
lastDate = currentDate;
}
}
public getTransactionPoints(): TransactionPoint[] {
return this.transactionPoints;
}
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
this.transactionPoints = transactionPoints;
}
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) {
return {
hasErrors: false,
positions: [],
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
currentValue: new Big(0),
totalInvestment: new Big(0)
};
}
const lastTransactionPoint =
this.transactionPoints[this.transactionPoints.length - 1];
// use Date.now() to use the mock for today
const today = new Date(Date.now());
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length;
const dates = [];
const symbols = new Set<string>();
const currencies: { [symbol: string]: Currency } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
symbols.add(item.symbol);
currencies[item.symbol] = item.currency;
}
for (let i = 0; i < this.transactionPoints.length; i++) {
if (
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
firstTransactionPoint === null
) {
firstTransactionPoint = this.transactionPoints[i];
firstIndex = i;
}
if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
}
}
dates.push(resetHours(today));
const marketSymbols = await this.currentRateService.getValues({
currencies,
dateQuery: {
in: dates
},
symbols: Array.from(symbols),
userCurrency: this.currency
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
let hasErrors = false;
const startString = format(start, DATE_FORMAT);
const holdingPeriodReturns: { [symbol: string]: Big } = {};
const grossPerformance: { [symbol: string]: Big } = {};
const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
}
const invalidSymbols = [];
const lastInvestments: { [symbol: string]: Big } = {};
const lastQuantities: { [symbol: string]: Big } = {};
const initialValues: { [symbol: string]: Big } = {};
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
const currentDate =
i === firstIndex ? startString : this.transactionPoints[i].date;
const nextDate =
i + 1 < this.transactionPoints.length
? this.transactionPoints[i + 1].date
: todayString;
const items = this.transactionPoints[i].items;
for (const item of items) {
let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol];
if (!oldHoldingPeriodReturn) {
oldHoldingPeriodReturn = new Big(1);
}
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol);
hasErrors = true;
console.error(
`Missing value for symbol ${item.symbol} at ${nextDate}`
);
continue;
}
let lastInvestment: Big = new Big(0);
let lastQuantity: Big = item.quantity;
if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) {
lastInvestment = item.investment.minus(lastInvestments[item.symbol]);
lastQuantity = lastQuantities[item.symbol];
}
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
let initialValue = itemValue?.mul(lastQuantity);
let investedValue = itemValue?.mul(item.quantity);
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
initialValue = item.investment;
investedValue = item.investment;
}
if (i === firstIndex || !initialValues[item.symbol]) {
initialValues[item.symbol] = initialValue;
}
if (!item.quantity.eq(0)) {
if (!initialValue) {
invalidSymbols.push(item.symbol);
hasErrors = true;
console.error(
`Missing value for symbol ${item.symbol} at ${currentDate}`
);
continue;
}
const cashFlow = lastInvestment;
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
item.quantity
);
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] =
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
let oldGrossPerformance = grossPerformance[item.symbol];
if (!oldGrossPerformance) {
oldGrossPerformance = new Big(0);
}
const currentPerformance = endValue.minus(investedValue);
grossPerformance[item.symbol] =
oldGrossPerformance.plus(currentPerformance);
}
lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity;
}
}
const positions: TimelinePosition[] = [];
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
positions.push({
averagePrice: item.quantity.eq(0)
? new Big(0)
: item.investment.div(item.quantity),
currency: item.currency,
firstBuyDate: item.firstBuyDate,
grossPerformance: isValid
? grossPerformance[item.symbol] ?? null
: null,
grossPerformancePercentage:
isValid && holdingPeriodReturns[item.symbol]
? holdingPeriodReturns[item.symbol].minus(1)
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
}
const overall = this.calculateOverallGrossPerformance(
positions,
initialValues
);
return {
...overall,
positions,
hasErrors: hasErrors || overall.hasErrors
};
}
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
}
return this.transactionPoints.map((transactionPoint) => {
return {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
investment.add(transactionPointSymbol.investment),
new Big(0)
)
};
});
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
): Promise<TimelinePeriod[]> {
if (timelineSpecification.length === 0) {
return [];
}
const startDate = timelineSpecification[0].start;
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = [];
let i = 0;
let j = -1;
for (
let currentDate = start;
!isAfter(currentDate, end);
currentDate = this.addToDate(
currentDate,
timelineSpecification[i].accuracy
)
) {
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
i++;
}
while (
j + 1 < this.transactionPoints.length &&
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
) {
j++;
}
let periodEndDate = currentDate;
if (timelineSpecification[i].accuracy === 'day') {
let nextEndDate = end;
if (j + 1 < this.transactionPoints.length) {
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
}
periodEndDate = min([
addMonths(currentDate, 3),
max([currentDate, nextEndDate])
]);
}
const timePeriodForDates = this.getTimePeriodForDate(
j,
currentDate,
endOfDay(periodEndDate)
);
currentDate = periodEndDate;
if (timePeriodForDates != null) {
timelinePeriodPromises.push(timePeriodForDates);
}
}
const timelinePeriods: TimelinePeriod[][] = await Promise.all(
timelinePeriodPromises
);
return flatten(timelinePeriods);
}
private calculateOverallGrossPerformance(
positions: TimelinePosition[],
initialValues: { [p: string]: Big }
) {
let hasErrors = false;
let currentValue = new Big(0);
let totalInvestment = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
currentValue = currentValue.add(
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
);
} else {
hasErrors = true;
}
totalInvestment = totalInvestment.add(currentPosition.investment);
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (
currentPosition.grossPerformancePercentage &&
initialValues[currentPosition.symbol]
) {
const currentInitialValue = initialValues[currentPosition.symbol];
completeInitialValue = completeInitialValue.plus(currentInitialValue);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
);
} else if (!currentPosition.quantity.eq(0)) {
console.error(
`Initial value is missing for symbol ${currentPosition.symbol}`
);
hasErrors = true;
}
}
if (!completeInitialValue.eq(0)) {
grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue);
}
return {
currentValue,
grossPerformance,
grossPerformancePercentage,
hasErrors,
totalInvestment
};
}
private async getTimePeriodForDate(
j: number,
startDate: Date,
endDate: Date
): Promise<TimelinePeriod[]> {
let investment: Big = new Big(0);
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
if (j >= 0) {
const currencies: { [name: string]: Currency } = {};
const symbols: string[] = [];
for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency;
symbols.push(item.symbol);
investment = investment.add(item.investment);
}
let marketSymbols: GetValueObject[] = [];
if (symbols.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
dateQuery: {
gte: startDate,
lt: endOfDay(endDate)
},
symbols,
currencies,
userCurrency: this.currency
});
} catch (error) {
console.error(
`Failed to fetch info for date ${startDate} with exception`,
error
);
return null;
}
}
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
}
const results = [];
for (
let currentDate = startDate;
isBefore(currentDate, endDate);
currentDate = addDays(currentDate, 1)
) {
let value = new Big(0);
const currentDateAsString = format(currentDate, DATE_FORMAT);
let invalid = false;
if (j >= 0) {
for (const item of this.transactionPoints[j].items) {
if (
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
) {
invalid = true;
break;
}
value = value.add(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
}
if (!invalid) {
const result = {
date: currentDateAsString,
grossPerformance: value.minus(investment),
investment,
value
};
results.push(result);
}
}
return results;
}
private getFactor(type: OrderType) {
let factor: number;
switch (type) {
case OrderType.Buy:
factor = 1;
break;
case OrderType.Sell:
factor = -1;
break;
default:
factor = 0;
break;
}
return factor;
}
private addToDate(date: Date, accuracy: Accuracy): Date {
switch (accuracy) {
case 'day':
return addDays(date, 1);
case 'month':
return addMonths(date, 1);
case 'year':
return addYears(date, 1);
}
}
private isNextItemActive(
timelineSpecification: TimelineSpecification[],
currentDate: Date,
i: number
) {
return (
i + 1 < timelineSpecification.length &&
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
);
}
}

View File

@ -1,4 +1,5 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config'; import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions'; import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -65,26 +66,4 @@ export class ExperimentalController {
return marketData; return marketData;
} }
@Post('value/:dateString?')
public async getValue(
@Body() orders: CreateOrderDto[],
@Headers('Authorization') apiToken: string,
@Param('dateString') dateString: string
): Promise<Data> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let date = new Date();
if (dateString) {
date = parse(dateString, 'yyyy-MM-dd', new Date());
}
return this.experimentalService.getValue(orders, date, baseCurrency);
}
} }

View File

@ -1,3 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -13,9 +15,10 @@ import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service'; import { ExperimentalService } from './experimental.service';
@Module({ @Module({
imports: [], imports: [RedisCacheModule],
controllers: [ExperimentalController], controllers: [ExperimentalController],
providers: [ providers: [
AccountService,
AlphaVantageService, AlphaVantageService,
ConfigurationService, ConfigurationService,
DataProviderService, DataProviderService,

View File

@ -1,19 +1,14 @@
import { Portfolio } from '@ghostfolio/api/models/portfolio'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service'; import { RulesService } from '@ghostfolio/api/services/rules.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Type } from '@prisma/client';
import { parseISO } from 'date-fns';
import { CreateOrderDto } from './create-order.dto';
import { Data } from './interfaces/data.interface';
@Injectable() @Injectable()
export class ExperimentalService { export class ExperimentalService {
public constructor( public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService, private prisma: PrismaService,
@ -27,40 +22,4 @@ export class ExperimentalService {
where: { symbol: aSymbol } where: { symbol: aSymbol }
}); });
} }
public async getValue(
aOrders: CreateOrderDto[],
aDate: Date,
aBaseCurrency: Currency
): Promise<Data> {
const ordersWithPlatform: OrderWithAccount[] = aOrders.map((order) => {
return {
...order,
accountId: undefined,
accountUserId: undefined,
createdAt: new Date(),
dataSource: undefined,
date: parseISO(order.date),
fee: 0,
id: undefined,
platformId: undefined,
symbolProfileId: undefined,
type: Type.BUY,
updatedAt: undefined,
userId: undefined
};
});
const portfolio = new Portfolio(
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
);
await portfolio.setOrders(ordersWithPlatform);
return {
currency: aBaseCurrency,
value: portfolio.getValue(aDate)
};
}
} }

View File

@ -0,0 +1,23 @@
import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ExportService } from './export.service';
@Controller('export')
export class ExportController {
public constructor(
private readonly exportService: ExportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async export(): Promise<Export> {
return await this.exportService.export({
userId: this.request.user.id
});
}
}

View File

@ -0,0 +1,32 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [RedisCacheModule],
controllers: [ExportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExportService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ExportModule {}

View File

@ -0,0 +1,31 @@
import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExportService {
public constructor(private prisma: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prisma.order.findMany({
orderBy: { date: 'desc' },
select: {
currency: true,
dataSource: true,
date: true,
fee: true,
quantity: true,
symbol: true,
type: true,
unitPrice: true
},
where: { userId }
});
return {
meta: { date: new Date().toISOString(), version: environment.version },
orders
};
}
}

View File

@ -0,0 +1,7 @@
import { Order } from '@prisma/client';
import { IsArray } from 'class-validator';
export class ImportDataDto {
@IsArray()
orders: Partial<Order>[];
}

View File

@ -0,0 +1,50 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ImportDataDto } from './import-data.dto';
import { ImportService } from './import.service';
@Controller('import')
export class ImportController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Post()
@UseGuards(AuthGuard('jwt'))
public async import(@Body() importData: ImportDataDto): Promise<void> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return await this.importService.import({
orders: importData.orders,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -0,0 +1,34 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
import { ImportService } from './import.service';
@Module({
imports: [RedisCacheModule],
controllers: [ImportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
ImportService,
OrderService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ImportModule {}

View File

@ -0,0 +1,43 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(private readonly orderService: OrderService) {}
public async import({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
for (const {
currency,
dataSource,
date,
fee,
quantity,
symbol,
type,
unitPrice
} of orders) {
await this.orderService.createOrder(
{
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
date: parseISO(<string>(<unknown>date)),
User: { connect: { id: userId } }
},
userId
);
}
}
}

View File

@ -1,10 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import * as bent from 'bent';
import { subDays } from 'date-fns';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
@ -17,6 +20,7 @@ export class InfoService {
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
const platforms = await this.prisma.platform.findMany({ const platforms = await this.prisma.platform.findMany({
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { id: true, name: true } select: { id: true, name: true }
@ -24,23 +28,87 @@ export class InfoService {
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
globalPermissions.push(permissions.enableBlog);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin); globalPermissions.push(permissions.enableSocialLogin);
} }
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
globalPermissions.push(permissions.enableStatistics);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
globalPermissions.push(permissions.enableSubscription); globalPermissions.push(permissions.enableSubscription);
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
return { return {
...info,
globalPermissions, globalPermissions,
platforms, platforms,
currencies: Object.values(Currency), currencies: Object.values(Currency),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering() lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions()
}; };
} }
private async countActiveUsers(aDays: number) {
return await this.prisma.user.count({
orderBy: {
Analytics: {
updatedAt: 'desc'
}
},
where: {
AND: [
{
NOT: {
Analytics: null
}
},
{
Analytics: {
updatedAt: {
gt: subDays(new Date(), aDays)
}
}
}
]
}
});
}
private async countGitHubStargazers(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio`,
'GET',
'json',
200,
{
'User-Agent': 'request'
}
);
const { stargazers_count } = await get();
return stargazers_count;
} catch (error) {
console.error(error);
return undefined;
}
}
private getDemoAuthToken() { private getDemoAuthToken() {
return this.jwtService.sign({ return this.jwtService.sign({
id: InfoService.DEMO_USER_ID id: InfoService.DEMO_USER_ID
@ -54,4 +122,36 @@ export class InfoService {
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null; return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
} }
private async getStatistics() {
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
return undefined;
}
const activeUsers1d = await this.countActiveUsers(1);
const activeUsers30d = await this.countActiveUsers(30);
const gitHubStargazers = await this.countGitHubStargazers();
return {
activeUsers1d,
activeUsers30d,
gitHubStargazers
};
}
private async getSubscriptions(): Promise<Subscription[]> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
const stripeConfig = await this.prisma.property.findUnique({
where: { key: 'STRIPE_CONFIG' }
});
if (stripeConfig) {
return [JSON.parse(stripeConfig.value)];
}
return [];
}
} }

View File

@ -1,5 +1,5 @@
import { Currency, DataSource, Type } from '@prisma/client'; import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto { export class CreateOrderDto {
@IsString() @IsString()

View File

@ -68,7 +68,8 @@ export class OrderController {
public async getAllOrders( public async getAllOrders(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> { ): Promise<OrderModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId( const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId, impersonationId,
this.request.user.id this.request.user.id
); );
@ -79,6 +80,11 @@ export class OrderController {
include: { include: {
Platform: true Platform: true
} }
},
SymbolProfile: {
select: {
name: true
}
} }
}, },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
@ -132,12 +138,26 @@ export class OrderController {
return this.orderService.createOrder( return this.orderService.createOrder(
{ {
...data, ...data,
date,
Account: { Account: {
connect: { connect: {
id_userId: { id: accountId, userId: this.request.user.id } id_userId: { id: accountId, userId: this.request.user.id }
} }
}, },
date,
SymbolProfile: {
connectOrCreate: {
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } } User: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id

View File

@ -3,6 +3,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma } from '@prisma/client'; import { DataSource, Order, Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service'; import { RedisCacheService } from '../redis-cache/redis-cache.service';
@ -50,7 +51,8 @@ export class OrderService {
): Promise<Order> { ): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`); this.redisCacheService.remove(`${aUserId}.portfolio`);
// Gather symbol data of order in the background if (!isAfter(data.date as Date, endOfToday())) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
dataSource: data.dataSource, dataSource: data.dataSource,
@ -58,6 +60,9 @@ export class OrderService {
symbol: data.symbol symbol: data.symbol
} }
]); ]);
}
this.dataGatheringService.gatherProfileData([data.symbol]);
await this.cacheService.flush(aUserId); await this.cacheService.flush(aUserId);

View File

@ -0,0 +1,5 @@
import { Position } from '@ghostfolio/common/interfaces';
export interface PortfolioPositions {
positions: Position[];
}

View File

@ -11,6 +11,7 @@ import {
PortfolioPosition, PortfolioPosition,
PortfolioReport PortfolioReport
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { import {
getPermissions, getPermissions,
hasPermission, hasPermission,
@ -37,6 +38,7 @@ import {
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
@ -48,12 +50,14 @@ export class PortfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get() @Get('/investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async findAll( public async findAll(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<PortfolioItem[]> { ): Promise<InvestmentItem[]> {
let portfolio = await this.portfolioService.findAll(impersonationId); let investments = await this.portfolioService.getInvestments(
impersonationId
);
if ( if (
impersonationId && impersonationId &&
@ -62,25 +66,18 @@ export class PortfolioController {
permissions.readForeignPortfolio permissions.readForeignPortfolio
) )
) { ) {
portfolio = portfolio.map((portfolioItem) => { const maxInvestment = investments.reduce(
Object.keys(portfolioItem.positions).forEach((symbol) => { (investment, item) => Math.max(investment, item.investment),
portfolioItem.positions[symbol].investment = 1
portfolioItem.positions[symbol].investment > 0 ? 1 : 0; );
portfolioItem.positions[symbol].investmentInOriginalCurrency =
portfolioItem.positions[symbol].investmentInOriginalCurrency > 0
? 1
: 0;
portfolioItem.positions[symbol].quantity =
portfolioItem.positions[symbol].quantity > 0 ? 1 : 0;
});
portfolioItem.investment = null; investments = investments.map((item) => ({
date: item.date,
return portfolioItem; investment: item.investment / maxInvestment
}); }));
} }
return portfolio; return investments;
} }
@Get('chart') @Get('chart')
@ -142,17 +139,17 @@ export class PortfolioController {
): Promise<{ [symbol: string]: PortfolioPosition }> { ): Promise<{ [symbol: string]: PortfolioPosition }> {
let details: { [symbol: string]: PortfolioPosition } = {}; let details: { [symbol: string]: PortfolioPosition } = {};
const impersonationUserId = await this.impersonationService.validateImpersonationId( const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId, impersonationId,
this.request.user.id this.request.user.id
); );
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
);
try { try {
details = await portfolio.getDetails(range); details = await this.portfolioService.getDetails(
impersonationUserId,
range
);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -221,6 +218,7 @@ export class PortfolioController {
) )
) { ) {
overview = nullifyValuesInObject(overview, [ overview = nullifyValuesInObject(overview, [
'cash',
'committedFunds', 'committedFunds',
'fees', 'fees',
'totalBuy', 'totalBuy',
@ -238,21 +236,16 @@ export class PortfolioController {
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioPerformance> { ): Promise<PortfolioPerformance> {
const impersonationUserId = await this.impersonationService.validateImpersonationId( const performanceInformation = await this.portfolioService.getPerformance(
impersonationId, impersonationId,
this.request.user.id range
); );
const portfolio = await this.portfolioService.createPortfolio( if (performanceInformation?.hasErrors) {
impersonationUserId || this.request.user.id
);
let performance = await portfolio.getPerformance(range);
if (hasNotDefinedValuesInObject(performance)) {
res.status(StatusCodes.ACCEPTED); res.status(StatusCodes.ACCEPTED);
} }
let performance = performanceInformation.performance;
if ( if (
impersonationId && impersonationId &&
!hasPermission( !hasPermission(
@ -270,6 +263,25 @@ export class PortfolioController {
return <any>res.json(performance); return <any>res.json(performance);
} }
@Get('positions')
@UseGuards(AuthGuard('jwt'))
public async getPositions(
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions(
impersonationId,
range
);
if (result?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
return <any>res.json(result);
}
@Get('position/:symbol') @Get('position/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPosition( public async getPosition(
@ -306,15 +318,6 @@ export class PortfolioController {
public async getReport( public async getReport(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {
const impersonationUserId = await this.impersonationService.validateImpersonationId( return await this.portfolioService.getReport(impersonationId);
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
);
return await portfolio.getReport();
} }
} }

View File

@ -1,3 +1,10 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
import { MarketDataService } from '@ghostfolio/api/app/core/market-data.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
@ -9,12 +16,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service'; import { RulesService } from '@ghostfolio/api/services/rules.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheService } from '../cache/cache.service';
import { OrderService } from '../order/order.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { UserService } from '../user/user.service';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@ -22,19 +26,23 @@ import { PortfolioService } from './portfolio.service';
imports: [RedisCacheModule], imports: [RedisCacheModule],
controllers: [PortfolioController], controllers: [PortfolioController],
providers: [ providers: [
AccountService,
AlphaVantageService, AlphaVantageService,
CacheService, CacheService,
CurrentRateService,
ConfigurationService, ConfigurationService,
DataGatheringService, DataGatheringService,
DataProviderService, DataProviderService,
ExchangeRateDataService, ExchangeRateDataService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
ImpersonationService, ImpersonationService,
MarketDataService,
OrderService, OrderService,
PortfolioService, PortfolioService,
PrismaService, PrismaService,
RakutenRapidApiService, RakutenRapidApiService,
RulesService, RulesService,
SymbolProfileService,
UserService, UserService,
YahooFinanceService YahooFinanceService
] ]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,57 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Post,
Req,
Res,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service';
@Controller('subscription')
export class SubscriptionController {
public constructor(
private readonly configurationService: ConfigurationService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly subscriptionService: SubscriptionService
) {}
@Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) {
await this.subscriptionService.createSubscription(
req.query.checkoutSessionId
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
}
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'))
public async createCheckoutSession(
@Body() { couponId, priceId }: { couponId: string; priceId: string }
) {
try {
return await this.subscriptionService.createCheckoutSession({
couponId,
priceId,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -0,0 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service';
@Module({
imports: [],
controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService]
})
export class SubscriptionModule {}

View File

@ -0,0 +1,89 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { addDays } from 'date-fns';
import Stripe from 'stripe';
@Injectable()
export class SubscriptionService {
private stripe: Stripe;
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
) {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2020-08-27'
}
);
}
public async createCheckoutSession({
couponId,
priceId,
userId
}: {
couponId?: string;
priceId: string;
userId: string;
}) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
client_reference_id: userId,
line_items: [
{
price: priceId,
quantity: 1
}
],
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
if (couponId) {
checkoutSessionCreateParams.discounts = [
{
coupon: couponId
}
];
}
const session = await this.stripe.checkout.sessions.create(
checkoutSessionCreateParams
);
return {
sessionId: session.id
};
}
public async createSubscription(aCheckoutSessionId: string) {
try {
const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId
);
await this.prisma.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: session.client_reference_id
}
}
}
});
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
});
} catch (error) {
console.error(error);
}
}
}

View File

@ -0,0 +1,7 @@
import { Currency, ViewMode } from '@prisma/client';
export interface UserSettingsParams {
currency?: Currency;
userId: string;
viewMode?: ViewMode;
}

View File

@ -25,6 +25,7 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { UserItem } from './interfaces/user-item.interface'; import { UserItem } from './interfaces/user-item.interface';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UpdateUserSettingsDto } from './update-user-settings.dto'; import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -92,10 +93,20 @@ export class UserController {
); );
} }
return await this.userService.updateUserSettings({ const userSettings: UserSettingsParams = {
currency: data.baseCurrency, currency: data.baseCurrency,
userId: this.request.user.id, userId: this.request.user.id
viewMode: data.viewMode };
});
if (
hasPermission(
getPermissions(this.request.user.role),
permissions.updateViewMode
)
) {
userSettings.viewMode = data.viewMode;
}
return await this.userService.updateUserSettings(userSettings);
} }
} }

View File

@ -1,13 +1,14 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { locale } from '@ghostfolio/common/config'; import { locale } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions'; import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client'; import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { add, isBefore } from 'date-fns'; import { isBefore } from 'date-fns';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
const crypto = require('crypto'); const crypto = require('crypto');
@ -24,7 +25,7 @@ export class UserService {
Account, Account,
alias, alias,
id, id,
role, permissions,
Settings, Settings,
subscription subscription
}: UserWithSettings): Promise<IUser> { }: UserWithSettings): Promise<IUser> {
@ -36,15 +37,10 @@ export class UserService {
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}); });
const currentPermissions = getPermissions(role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
return { return {
alias, alias,
id, id,
permissions,
subscription, subscription,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
@ -53,7 +49,6 @@ export class UserService {
}; };
}), }),
accounts: Account, accounts: Account,
permissions: currentPermissions,
settings: { settings: {
locale, locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
@ -72,6 +67,14 @@ export class UserService {
const user: UserWithSettings = userFromDatabase; const user: UserWithSettings = userFromDatabase;
const currentPermissions = getPermissions(userFromDatabase.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
user.permissions = currentPermissions;
if (userFromDatabase?.Settings) { if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) { if (!userFromDatabase.Settings.currency) {
// Set default currency if needed // Set default currency if needed
@ -106,6 +109,13 @@ export class UserService {
type: SubscriptionType.Basic type: SubscriptionType.Basic
}; };
} }
if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => {
return permission !== permissions.updateViewMode;
});
user.Settings.viewMode = ViewMode.ZEN;
}
} }
return user; return user;
@ -213,11 +223,7 @@ export class UserService {
currency, currency,
userId, userId,
viewMode viewMode
}: { }: UserSettingsParams) {
currency?: Currency;
userId: string;
viewMode?: ViewMode;
}) {
await this.prisma.settings.upsert({ await this.prisma.settings.upsert({
create: { create: {
currency, currency,

View File

@ -1,3 +1,4 @@
export const environment = { export const environment = {
production: true production: true,
version: `v${require('../../../../package.json').version}`
}; };

View File

@ -1,3 +1,4 @@
export const environment = { export const environment = {
production: false production: false,
version: 'dev'
}; };

View File

@ -5,13 +5,9 @@ import { Order } from '../order';
export interface PortfolioInterface { export interface PortfolioInterface {
get(aDate?: Date): PortfolioItem[]; get(aDate?: Date): PortfolioItem[];
getCommittedFunds(): number;
getFees(): number; getFees(): number;
getPositions( getPositions(aDate: Date): {
aDate: Date
): {
[symbol: string]: Position; [symbol: string]: Position;
}; };

View File

@ -0,0 +1,3 @@
export interface RuleSettings {
isActive: boolean;
}

View File

@ -1,15 +1,10 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { EvaluationResult } from './evaluation-result.interface'; import { EvaluationResult } from './evaluation-result.interface';
export interface RuleInterface { export interface RuleInterface<T extends RuleSettings> {
evaluate( evaluate(aRuleSettings: T): EvaluationResult;
aPortfolioPositionMap: {
[symbol: string]: PortfolioPosition; getSettings(aUserSettings: UserSettings): T;
},
aFees: number,
aRuleSettingsMap: {
[key: string]: any;
}
): EvaluationResult;
} }

View File

@ -0,0 +1,5 @@
import { Currency } from '@prisma/client';
export interface UserSettings {
baseCurrency: Currency;
}

View File

@ -1,4 +1,5 @@
import { Account, Currency, Platform, SymbolProfile } from '@prisma/client'; import { Account, Currency, SymbolProfile } from '@prisma/client';
import { endOfToday, isAfter, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
@ -52,6 +53,10 @@ export class Order {
return this.id; return this.id;
} }
public getIsDraft() {
return isAfter(parseISO(this.date), endOfToday());
}
public getQuantity() { public getQuantity() {
return this.quantity; return this.quantity;
} }

View File

@ -1,645 +0,0 @@
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import {
AccountType,
Currency,
DataSource,
Role,
Type,
ViewMode
} from '@prisma/client';
import { format } from 'date-fns';
import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { MarketState } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio';
jest.mock('../services/data-provider.service', () => {
return {
DataProviderService: jest.fn().mockImplementation(() => {
const today = format(new Date(), 'yyyy-MM-dd');
const yesterday = format(getYesterday(), 'yyyy-MM-dd');
return {
get: () => {
return Promise.resolve({
BTCUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
type: 'Cryptocurrency'
},
ETHUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 3915.337,
marketState: MarketState.open,
name: 'Ethereum USD',
type: 'Cryptocurrency'
}
});
},
getHistorical: () => {
return Promise.resolve({
BTCUSD: {
[yesterday]: 56710.122,
[today]: 57973.008
},
ETHUSD: {
[yesterday]: 3641.984,
[today]: 3915.337
}
});
}
};
})
};
});
jest.mock('../services/exchange-rate-data.service', () => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
toCurrency: (value: number) => value
};
})
};
});
jest.mock('../services/data-provider.service');
jest.mock('../services/exchange-rate-data.service');
jest.mock('../services/rules.service');
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => {
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolio: Portfolio;
let rulesService: RulesService;
beforeAll(async () => {
dataProviderService = new DataProviderService(
null,
null,
null,
null,
null,
null
);
exchangeRateDataService = new ExchangeRateDataService(null);
rulesService = new RulesService();
await exchangeRateDataService.initialize();
portfolio = new Portfolio(
dataProviderService,
exchangeRateDataService,
rulesService
);
portfolio.setUser({
accessToken: null,
Account: [
{
accountType: AccountType.SECURITIES,
createdAt: new Date(),
id: DEFAULT_ACCOUNT_ID,
isDefault: true,
name: 'Default Account',
platformId: null,
updatedAt: new Date(),
userId: USER_ID
}
],
alias: 'Test',
createdAt: new Date(),
id: USER_ID,
provider: null,
role: Role.USER,
Settings: {
currency: Currency.CHF,
updatedAt: new Date(),
userId: USER_ID,
viewMode: ViewMode.DEFAULT
},
thirdPartyId: null,
updatedAt: new Date()
});
});
describe('works with no orders', () => {
it('should return []', () => {
expect(portfolio.get(new Date())).toEqual([]);
expect(portfolio.getFees()).toEqual(0);
expect(portfolio.getPositions(new Date())).toEqual({});
});
it('should return empty details', async () => {
const details = await portfolio.getDetails('1d');
expect(details).toEqual({});
});
it('should return empty details', async () => {
const details = await portfolio.getDetails('max');
expect(details).toEqual({});
});
it('should return zero performance for 1d', async () => {
const performance = await portfolio.getPerformance('1d');
expect(performance).toEqual({
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
});
});
it('should return zero performance for max', async () => {
const performance = await portfolio.getPerformance('max');
expect(performance).toEqual({
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
});
});
});
describe(`works with today's orders`, () => {
it('should return ["BTC"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(),
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
quantity: 1,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 49631.24,
updatedAt: null,
userId: USER_ID
}
]);
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
)
);
const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({
BTCUSD: {
accounts: {
[UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),*/
original: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
)
}
},
allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
quantity: 1,
symbol: 'BTCUSD',
transactionCount: 1,
type: 'Cryptocurrency'
}
});
expect(portfolio.getFees()).toEqual(0);
/*const performance1d = await portfolio.getPerformance('1d');
expect(performance1d).toEqual({
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: exchangeRateDataService.toBaseCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
)
});*/
/*const performanceMax = await portfolio.getPerformance('max');
expect(performanceMax).toEqual({
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: exchangeRateDataService.toBaseCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
)
});*/
expect(portfolio.getPositions(getYesterday())).toMatchObject({});
expect(portfolio.getSymbols(getYesterday())).toEqual(['BTCUSD']);
});
});
describe('works with orders', () => {
it('should return ["ETHUSD"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: USER_ID
}
]);
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
)
);
const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({
ETHUSD: {
accounts: {
[UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),*/
original: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
)
}
},
// allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
// grossPerformance: 0,
// grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
marketPrice: 3915.337,
name: 'Ethereum USD',
quantity: 0.2,
transactionCount: 1,
symbol: 'ETHUSD',
type: 'Cryptocurrency'
}
});
expect(portfolio.getFees()).toEqual(0);
/*const performance = await portfolio.getPerformance('max');
expect(performance).toEqual({
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
});*/
expect(portfolio.getPositions(getYesterday())).toMatchObject({
ETHUSD: {
averagePrice: 991.49,
currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z',
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49,
// marketPrice: 3915.337,
quantity: 0.2
}
});
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
});
it('should return ["ETHUSD"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.3,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,
userId: USER_ID
}
]);
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
) +
exchangeRateDataService.toCurrency(
0.3 * 1050,
Currency.USD,
baseCurrency
)
);
expect(portfolio.getFees()).toEqual(0);
expect(portfolio.getPositions(getYesterday())).toMatchObject({
ETHUSD: {
averagePrice: (0.2 * 991.49 + 0.3 * 1050) / (0.2 + 0.3),
currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z',
investment:
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
) +
exchangeRateDataService.toCurrency(
0.3 * 1050,
Currency.USD,
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
// marketPrice: 3641.984,
quantity: 0.5
}
});
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
});
it('should return ["BTCUSD", "ETHUSD"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.EUR,
dataSource: DataSource.YAHOO,
date: new Date(getUtc('2017-08-16')),
fee: 2.99,
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
quantity: 0.05614682,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 3562.089535970158,
updatedAt: null,
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 2.99,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: USER_ID
}
]);
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
0.05614682 * 3562.089535970158,
Currency.EUR,
baseCurrency
) +
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
)
);
expect(portfolio.getFees()).toEqual(
exchangeRateDataService.toCurrency(2.99, Currency.EUR, baseCurrency) +
exchangeRateDataService.toCurrency(2.99, Currency.USD, baseCurrency)
);
expect(portfolio.getPositions(getYesterday())).toMatchObject({
BTCUSD: {
averagePrice: 3562.089535970158,
currency: Currency.EUR,
firstBuyDate: '2017-08-16T00:00:00.000Z',
investment: exchangeRateDataService.toCurrency(
0.05614682 * 3562.089535970158,
Currency.EUR,
baseCurrency
),
investmentInOriginalCurrency: 0.05614682 * 3562.089535970158,
// marketPrice: 0,
quantity: 0.05614682
},
ETHUSD: {
averagePrice: 991.49,
currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z',
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49,
// marketPrice: 0,
quantity: 0.2
}
});
expect(portfolio.getSymbols(getYesterday())).toEqual([
'BTCUSD',
'ETHUSD'
]);
});
it('should work with buy and sell', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0,
date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.1,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.SELL,
unitPrice: 1050,
updatedAt: null,
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0,
date: new Date(getUtc('2018-01-31')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,
userId: USER_ID
}
]);
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
) -
exchangeRateDataService.toCurrency(
0.1 * 1050,
Currency.USD,
baseCurrency
) +
exchangeRateDataService.toCurrency(
0.2 * 1050,
Currency.USD,
baseCurrency
)
);
expect(portfolio.getFees()).toEqual(
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
);
expect(portfolio.getPositions(getYesterday())).toMatchObject({
ETHUSD: {
averagePrice:
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z',
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
Currency.USD,
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
// marketPrice: 0,
quantity: 0.2 - 0.1 + 0.2
}
});
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
});
});
});

View File

@ -1,863 +0,0 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import {
PortfolioItem,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport,
Position,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import {
add,
format,
getDate,
getMonth,
getYear,
isAfter,
isBefore,
isSameDay,
isToday,
isYesterday,
parseISO,
setDate,
setMonth,
sub
} from 'date-fns';
import { cloneDeep, isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { IOrder } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order';
import { OrderType } from './order-type';
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment';
export class Portfolio implements PortfolioInterface {
private orders: Order[] = [];
private portfolioItems: PortfolioItem[] = [];
private user: UserWithSettings;
public constructor(
private dataProviderService: DataProviderService,
private exchangeRateDataService: ExchangeRateDataService,
private rulesService: RulesService
) {}
public async addCurrentPortfolioItems() {
const currentData = await this.dataProviderService.get(this.getSymbols());
const currentDate = new Date();
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
const today = new Date(Date.UTC(year, month, day));
const yesterday = getYesterday();
const [portfolioItemsYesterday] = this.get(yesterday);
let positions: { [symbol: string]: Position } = {};
this.getSymbols().forEach((symbol) => {
positions[symbol] = {
averagePrice: portfolioItemsYesterday?.positions[symbol]?.averagePrice,
currency: portfolioItemsYesterday?.positions[symbol]?.currency,
firstBuyDate: portfolioItemsYesterday?.positions[symbol]?.firstBuyDate,
investment: portfolioItemsYesterday?.positions[symbol]?.investment,
investmentInOriginalCurrency:
portfolioItemsYesterday?.positions[symbol]
?.investmentInOriginalCurrency,
marketPrice:
currentData[symbol]?.marketPrice ??
portfolioItemsYesterday.positions[symbol]?.marketPrice,
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity,
transactionCount:
portfolioItemsYesterday?.positions[symbol]?.transactionCount
};
});
if (portfolioItemsYesterday?.investment) {
const portfolioItemsLength = this.portfolioItems.push(
cloneDeep({
date: today.toISOString(),
grossPerformancePercent: 0,
investment: portfolioItemsYesterday?.investment,
positions: positions,
value: 0
})
);
// Set value after pushing today's portfolio items
this.portfolioItems[portfolioItemsLength - 1].value = this.getValue(
today
);
}
return this;
}
public createFromData({
orders,
portfolioItems,
user
}: {
orders: IOrder[];
portfolioItems: PortfolioItem[];
user: UserWithSettings;
}): Portfolio {
orders.forEach(
({
account,
currency,
fee,
date,
id,
quantity,
symbol,
symbolProfile,
type,
unitPrice
}) => {
this.orders.push(
new Order({
account,
currency,
fee,
date,
id,
quantity,
symbol,
symbolProfile,
type,
unitPrice
})
);
}
);
portfolioItems.forEach(
({ date, grossPerformancePercent, investment, positions, value }) => {
this.portfolioItems.push({
date,
grossPerformancePercent,
investment,
positions,
value
});
}
);
this.setUser(user);
return this;
}
public get(aDate?: Date): PortfolioItem[] {
if (aDate) {
const filteredPortfolio = this.portfolioItems.find((item) => {
return isSameDay(aDate, new Date(item.date));
});
if (filteredPortfolio) {
return [cloneDeep(filteredPortfolio)];
}
}
return cloneDeep(this.portfolioItems);
}
public getCommittedFunds() {
return this.getTotalBuy() - this.getTotalSell();
}
public async getDetails(
aDateRange: DateRange = 'max'
): Promise<{ [symbol: string]: PortfolioPosition }> {
const dateRangeDate = this.convertDateRangeToDate(
aDateRange,
this.getMinDate()
);
const [portfolioItemsBefore] = this.get(dateRangeDate);
const [portfolioItemsNow] = await this.get(new Date());
const investment = this.getInvestment(new Date());
const portfolioItems = this.get(new Date());
const symbols = this.getSymbols(new Date());
const value = this.getValue();
const details: { [symbol: string]: PortfolioPosition } = {};
const data = await this.dataProviderService.get(symbols);
symbols.forEach((symbol) => {
const accounts: PortfolioPosition['accounts'] = {};
let countriesOfSymbol: Country[];
const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => {
return order.getSymbol() === symbol;
});
ordersBySymbol.forEach((orderOfSymbol) => {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
orderOfSymbol.getQuantity() *
portfolioItemsNow.positions[symbol].marketPrice,
orderOfSymbol.getCurrency(),
this.user.Settings.currency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
orderOfSymbol.getQuantity() * orderOfSymbol.getUnitPrice(),
orderOfSymbol.getCurrency(),
this.user.Settings.currency
);
if (orderOfSymbol.getType() === 'SELL') {
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1;
}
if (
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
) {
accounts[
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].current += currentValueOfSymbol;
accounts[
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].original += originalValueOfSymbol;
} else {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
}
countriesOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
[]
).map((country) => {
const { code, weight } = country as Prisma.JsonObject;
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
});
});
let now = portfolioItemsNow.positions[symbol].marketPrice;
// 1d
let before = portfolioItemsBefore.positions[symbol].marketPrice;
if (aDateRange === 'ytd') {
before =
portfolioItemsBefore.positions[symbol].marketPrice ||
portfolioItemsNow.positions[symbol].averagePrice;
} else if (
aDateRange === '1y' ||
aDateRange === '5y' ||
aDateRange === 'max'
) {
before = portfolioItemsNow.positions[symbol].averagePrice;
}
if (
!isBefore(
parseISO(portfolioItemsNow.positions[symbol].firstBuyDate),
parseISO(portfolioItemsBefore.date)
)
) {
// Trade was not before the date of portfolioItemsBefore, then override it with average price
// (e.g. on same day)
before = portfolioItemsNow.positions[symbol].averagePrice;
}
if (isToday(parseISO(portfolioItemsNow.positions[symbol].firstBuyDate))) {
now = portfolioItemsNow.positions[symbol].averagePrice;
}
details[symbol] = {
...data[symbol],
accounts,
symbol,
allocationCurrent:
this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
) / value,
allocationInvestment:
portfolioItem.positions[symbol].investment / investment,
countries: countriesOfSymbol,
grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before),
2
),
grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity,
transactionCount: portfolioItem.positions[symbol].transactionCount,
value: this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
)
};
});
return details;
}
public getFees(aDate = new Date(0)) {
return this.orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(aDate, new Date(order.getDate()));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getFee(),
order.getCurrency(),
this.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public getInvestment(aDate: Date): number {
return this.get(aDate)[0]?.investment || 0;
}
public getMinDate() {
if (this.orders.length > 0) {
return new Date(this.orders[0].getDate());
}
return null;
}
public async getPerformance(
aDateRange: DateRange = 'max'
): Promise<PortfolioPerformance> {
const dateRangeDate = this.convertDateRangeToDate(
aDateRange,
this.getMinDate()
);
const currentInvestment = this.getInvestment(new Date());
const currentValue = await this.getValue();
let originalInvestment = currentInvestment;
let originalValue = this.getCommittedFunds();
if (dateRangeDate) {
originalInvestment = this.getInvestment(dateRangeDate);
originalValue = (await this.getValue(dateRangeDate)) || originalValue;
}
const fees = this.getFees(dateRangeDate);
const currentGrossPerformance =
currentValue - currentInvestment - (originalValue - originalInvestment);
// https://www.skillsyouneed.com/num/percent-change.html
const currentGrossPerformancePercent =
currentGrossPerformance / originalInvestment || 0;
const currentNetPerformance = currentGrossPerformance - fees;
// https://www.skillsyouneed.com/num/percent-change.html
const currentNetPerformancePercent =
currentNetPerformance / originalInvestment || 0;
return {
currentGrossPerformance,
currentGrossPerformancePercent,
currentNetPerformance,
currentNetPerformancePercent,
currentValue
};
}
public getPositions(aDate: Date) {
const [portfolioItem] = this.get(aDate);
if (portfolioItem) {
return portfolioItem.positions;
}
return {};
}
public getPortfolioItems() {
return this.portfolioItems;
}
public async getReport(): Promise<PortfolioReport> {
const details = await this.getDetails();
if (isEmpty(details)) {
return {
rules: {}
};
}
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
this,
[
new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
],
{ baseCurrency: this.user.Settings.currency }
),
currencyClusterRisk: await this.rulesService.evaluate(
this,
[
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
this.exchangeRateDataService
),
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService
),
new CurrencyClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService
)
],
{ baseCurrency: this.user.Settings.currency }
),
fees: await this.rulesService.evaluate(
this,
[new FeeRatioInitialInvestment(this.exchangeRateDataService)],
{ baseCurrency: this.user.Settings.currency }
)
}
};
}
public getSymbols(aDate?: Date) {
let symbols: string[] = [];
if (aDate) {
const positions = this.getPositions(aDate);
for (const symbol in positions) {
if (positions[symbol].quantity > 0) {
symbols.push(symbol);
}
}
} else {
symbols = this.orders.map((order) => {
return order.getSymbol();
});
}
// unique values
return Array.from(new Set(symbols));
}
public getTotalBuy() {
return this.orders
.filter((order) => order.getType() === 'BUY')
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public getTotalSell() {
return this.orders
.filter((order) => order.getType() === 'SELL')
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public getOrders(aSymbol?: string) {
if (aSymbol) {
return this.orders.filter((order) => {
return order.getSymbol() === aSymbol;
});
}
return this.orders;
}
public getValue(aDate = getToday()) {
const positions = this.getPositions(aDate);
let value = 0;
const [portfolioItem] = this.get(aDate);
for (const symbol in positions) {
if (portfolioItem.positions[symbol]?.quantity > 0) {
if (
isBefore(
aDate,
parseISO(portfolioItem.positions[symbol]?.firstBuyDate)
) ||
portfolioItem.positions[symbol]?.marketPrice === 0
) {
value += this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol]?.quantity *
portfolioItem.positions[symbol]?.averagePrice,
portfolioItem.positions[symbol]?.currency,
this.user.Settings.currency
);
} else {
value += this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol]?.quantity *
portfolioItem.positions[symbol]?.marketPrice,
portfolioItem.positions[symbol]?.currency,
this.user.Settings.currency
);
}
}
}
return isFinite(value) ? value : null;
}
public async setOrders(aOrders: OrderWithAccount[]) {
this.orders = [];
// Map data
aOrders.forEach((order) => {
this.orders.push(
new Order({
account: order.Account,
currency: order.currency,
date: order.date.toISOString(),
fee: order.fee,
quantity: order.quantity,
symbol: order.symbol,
symbolProfile: order.SymbolProfile,
type: <OrderType>order.type,
unitPrice: order.unitPrice
})
);
});
await this.update();
return this;
}
public setUser(aUser: UserWithSettings) {
this.user = aUser;
return this;
}
/**
* TODO: Refactor
*/
private async update() {
this.portfolioItems = [];
let currentDate = this.getMinDate();
if (!currentDate) {
return;
}
// Set current date to first of month
currentDate = setDate(currentDate, 1);
const historicalData = await this.dataProviderService.getHistorical(
this.getSymbols(),
'month',
currentDate,
new Date()
);
while (isBefore(currentDate, Date.now())) {
const positions: { [symbol: string]: Position } = {};
this.getSymbols().forEach((symbol) => {
positions[symbol] = {
averagePrice: 0,
currency: undefined,
firstBuyDate: null,
investment: 0,
investmentInOriginalCurrency: 0,
marketPrice:
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
?.marketPrice || 0,
quantity: 0,
transactionCount: 0
};
});
if (!isYesterday(currentDate) && !isToday(currentDate)) {
// Add to portfolio (ignore yesterday and today because they are added later)
this.portfolioItems.push(
cloneDeep({
date: currentDate.toISOString(),
grossPerformancePercent: 0,
investment: 0,
positions: positions,
value: 0
})
);
}
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
// Count month one up for iteration
currentDate = new Date(Date.UTC(year, month + 1, day, 0));
}
const yesterday = getYesterday();
let positions: { [symbol: string]: Position } = {};
if (isAfter(yesterday, this.getMinDate())) {
// Add yesterday
this.getSymbols().forEach((symbol) => {
positions[symbol] = {
averagePrice: 0,
currency: undefined,
firstBuyDate: null,
investment: 0,
investmentInOriginalCurrency: 0,
marketPrice:
historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')]
?.marketPrice || 0,
quantity: 0,
transactionCount: 0
};
});
this.portfolioItems.push(
cloneDeep({
date: yesterday.toISOString(),
grossPerformancePercent: 0,
investment: 0,
positions: positions,
value: 0
})
);
}
this.updatePortfolioItems();
}
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
let currentDate = new Date();
const normalizedMinDate =
getDate(aMinDate) === 1
? aMinDate
: add(setDate(aMinDate, 1), { months: 1 });
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
currentDate = new Date(Date.UTC(year, month, day, 0));
switch (aDateRange) {
case '1d':
return sub(currentDate, {
days: 1
});
case 'ytd':
currentDate = setDate(currentDate, 1);
currentDate = setMonth(currentDate, 0);
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '1y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 1
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '5y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 5
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
default:
// Gets handled as all data
return undefined;
}
}
private updatePortfolioItems() {
// console.time('update-portfolio-items');
let currentDate = new Date();
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
currentDate = new Date(Date.UTC(year, month, day, 0));
if (this.portfolioItems?.length === 1) {
// At least one portfolio items is needed, keep it but change the date to today.
// This happens if there are only orders from today
this.portfolioItems[0].date = currentDate.toISOString();
} else {
// Only keep entries which are not before first buy date
this.portfolioItems = this.portfolioItems.filter((portfolioItem) => {
return (
isSameDay(parseISO(portfolioItem.date), this.getMinDate()) ||
isAfter(parseISO(portfolioItem.date), this.getMinDate())
);
});
}
this.orders.forEach((order) => {
let index = this.portfolioItems.findIndex((item) => {
const dateOfOrder = setDate(parseISO(order.getDate()), 1);
return isSameDay(parseISO(item.date), dateOfOrder);
});
if (index === -1) {
// if not found, we only have one order, which means we do not loop below
index = 0;
}
for (let i = index; i < this.portfolioItems.length; i++) {
// Set currency
this.portfolioItems[i].positions[
order.getSymbol()
].currency = order.getCurrency();
this.portfolioItems[i].positions[
order.getSymbol()
].transactionCount += 1;
if (order.getType() === 'BUY') {
if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
) {
this.portfolioItems[i].positions[
order.getSymbol()
].firstBuyDate = resetHours(
parseISO(order.getDate())
).toISOString();
}
this.portfolioItems[i].positions[
order.getSymbol()
].quantity += order.getQuantity();
this.portfolioItems[i].positions[
order.getSymbol()
].investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency += order.getTotal();
this.portfolioItems[
i
].investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
} else if (order.getType() === 'SELL') {
this.portfolioItems[i].positions[
order.getSymbol()
].quantity -= order.getQuantity();
if (
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
) {
this.portfolioItems[i].positions[order.getSymbol()].investment = 0;
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency = 0;
} else {
this.portfolioItems[i].positions[
order.getSymbol()
].investment -= this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency -= order.getTotal();
}
this.portfolioItems[
i
].investment -= this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
}
this.portfolioItems[i].positions[order.getSymbol()].averagePrice =
this.portfolioItems[i].positions[order.getSymbol()]
.investmentInOriginalCurrency /
this.portfolioItems[i].positions[order.getSymbol()].quantity;
const currentValue = this.getValue(
parseISO(this.portfolioItems[i].date)
);
this.portfolioItems[i].grossPerformancePercent =
currentValue / this.portfolioItems[i].investment - 1 || 0;
this.portfolioItems[i].value = currentValue;
}
});
// console.timeEnd('update-portfolio-items');
}
}

View File

@ -1,16 +1,18 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
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 implements RuleInterface { export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
private name: string; private name: string;
public constructor( public constructor(
public exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
{ {
name name
}: { }: {
@ -20,44 +22,38 @@ export abstract class Rule implements RuleInterface {
this.name = name; this.name = name;
} }
public abstract evaluate(
aPortfolioPositionMap: {
[symbol: string]: PortfolioPosition;
},
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
): EvaluationResult;
public getName() { public getName() {
return this.name; return this.name;
} }
public groupPositionsByAttribute( public groupCurrentPositionsByAttribute(
aPositions: { [symbol: string]: PortfolioPosition }, positions: TimelinePosition[],
aAttribute: keyof PortfolioPosition, attribute: keyof TimelinePosition,
aBaseCurrency: Currency baseCurrency: Currency
) { ) {
return Array.from( return Array.from(groupBy(attribute, positions).entries()).map(
groupBy(aAttribute, Object.values(aPositions)).entries() ([attributeValue, objs]) => ({
).map(([attributeValue, objs]) => ({
groupKey: attributeValue, groupKey: attributeValue,
investment: objs.reduce( investment: objs.reduce(
(previousValue, currentValue) => (previousValue, currentValue) =>
previousValue + currentValue.investment, previousValue + currentValue.investment.toNumber(),
0 0
), ),
value: objs.reduce( value: objs.reduce(
(previousValue, currentValue) => (previousValue, currentValue) =>
previousValue + previousValue +
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
currentValue.quantity * currentValue.marketPrice, currentValue.quantity.mul(currentValue.marketPrice).toNumber(),
currentValue.currency, currentValue.currency,
aBaseCurrency baseCurrency
), ),
0 0
) )
})); })
);
} }
public abstract evaluate(aRuleSettings: T): EvaluationResult;
public abstract getSettings(aUserSettings: UserSettings): T;
} }

View File

@ -1,43 +1,35 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class AccountClusterRiskCurrentInvestment extends Rule { export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: {
[account: string]: { current: number; original: number };
}
) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Current Investment' name: 'Current Investment'
}); });
} }
public evaluate( public evaluate(ruleSettings: Settings) {
aPositions: { [symbol: string]: PortfolioPosition },
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[AccountClusterRiskCurrentInvestment.name];
const accounts: { const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number; investment: number;
}; };
} = {}; } = {};
Object.values(aPositions).forEach((position) => { for (const account of Object.keys(this.accounts)) {
for (const [account, { current }] of Object.entries(position.accounts)) {
if (accounts[account]?.investment) {
accounts[account].investment += current;
} else {
accounts[account] = { accounts[account] = {
investment: current, name: account,
name: account investment: this.accounts[account].current
}; };
} }
}
});
let maxItem; let maxItem;
let totalInvestment = 0; let totalInvestment = 0;
@ -78,4 +70,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule {
value: true value: true
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
threshold: number;
} }

View File

@ -1,43 +1,35 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class AccountClusterRiskInitialInvestment extends Rule { export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: {
[account: string]: { current: number; original: number };
}
) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Initial Investment' name: 'Initial Investment'
}); });
} }
public evaluate( public evaluate(ruleSettings?: Settings) {
aPositions: { [symbol: string]: PortfolioPosition },
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[AccountClusterRiskInitialInvestment.name];
const platforms: { const platforms: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number; investment: number;
}; };
} = {}; } = {};
Object.values(aPositions).forEach((position) => { for (const account of Object.keys(this.accounts)) {
for (const [account, { original }] of Object.entries(position.accounts)) {
if (platforms[account]?.investment) {
platforms[account].investment += original;
} else {
platforms[account] = { platforms[account] = {
investment: original, name: account,
name: account investment: this.accounts[account].original
}; };
} }
}
});
let maxItem; let maxItem;
let totalInvestment = 0; let totalInvestment = 0;
@ -78,4 +70,18 @@ export class AccountClusterRiskInitialInvestment extends Rule {
value: true value: true
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
isActive: boolean;
threshold: number;
} }

View File

@ -1,25 +1,23 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class AccountClusterRiskSingleAccount extends Rule { export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: {
[account: string]: { current: number; original: number };
}
) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Single Account' name: 'Single Account'
}); });
} }
public evaluate(positions: { [symbol: string]: PortfolioPosition }) { public evaluate() {
const accounts: string[] = []; const accounts: string[] = Object.keys(this.accounts);
Object.values(positions).forEach((position) => {
for (const [account] of Object.entries(position.accounts)) {
if (!accounts.includes(account)) {
accounts.push(account);
}
}
});
if (accounts.length === 1) { if (accounts.length === 1) {
return { return {
@ -33,4 +31,10 @@ export class AccountClusterRiskSingleAccount extends Rule {
value: true value: true
}; };
} }
public getSettings(aUserSettings: UserSettings): RuleSettings {
return {
isActive: true
};
}
} }

View File

@ -1,27 +1,25 @@
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Current Investment: Base Currency' name: 'Current Investment: Base Currency'
}); });
} }
public evaluate( public evaluate(ruleSettings: Settings) {
aPositions: { [symbol: string]: PortfolioPosition }, const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
aFees: number, this.currentPositions.positions,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[CurrencyClusterRiskBaseCurrencyCurrentInvestment.name];
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
aPositions,
'currency', 'currency',
ruleSettings.baseCurrency ruleSettings.baseCurrency
); );
@ -61,4 +59,15 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule {
value: true value: true
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
};
}
}
interface Settings extends RuleSettings {
baseCurrency: Currency;
} }

View File

@ -1,27 +1,24 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule { export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Initial Investment: Base Currency' name: 'Initial Investment: Base Currency'
}); });
} }
public evaluate( public evaluate(ruleSettings: Settings) {
aPositions: { [symbol: string]: PortfolioPosition }, const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
aFees: number, this.currentPositions.positions,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[CurrencyClusterRiskBaseCurrencyInitialInvestment.name];
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
aPositions,
'currency', 'currency',
ruleSettings.baseCurrency ruleSettings.baseCurrency
); );
@ -62,4 +59,15 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule {
value: true value: true
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
};
}
}
interface Settings extends RuleSettings {
baseCurrency: Currency;
} }

View File

@ -1,27 +1,24 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class CurrencyClusterRiskCurrentInvestment extends Rule { export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(
public exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Current Investment' name: 'Current Investment'
}); });
} }
public evaluate( public evaluate(ruleSettings: Settings) {
aPositions: { [symbol: string]: PortfolioPosition }, const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
aFees: number, this.currentPositions.positions,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[CurrencyClusterRiskCurrentInvestment.name];
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
aPositions,
'currency', 'currency',
ruleSettings.baseCurrency ruleSettings.baseCurrency
); );
@ -61,4 +58,17 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule {
value: true value: true
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
};
}
}
interface Settings extends RuleSettings {
baseCurrency: Currency;
threshold: number;
} }

View File

@ -1,27 +1,24 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class CurrencyClusterRiskInitialInvestment extends Rule { export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Initial Investment' name: 'Initial Investment'
}); });
} }
public evaluate( public evaluate(ruleSettings: Settings) {
aPositions: { [symbol: string]: PortfolioPosition }, const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
aFees: number, this.currentPositions.positions,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[CurrencyClusterRiskInitialInvestment.name];
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
aPositions,
'currency', 'currency',
ruleSettings.baseCurrency ruleSettings.baseCurrency
); );
@ -61,4 +58,17 @@ export class CurrencyClusterRiskInitialInvestment extends Rule {
value: true value: true
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
};
}
}
interface Settings extends RuleSettings {
baseCurrency: Currency;
threshold: number;
} }

View File

@ -1,38 +1,23 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class FeeRatioInitialInvestment extends Rule { export class FeeRatioInitialInvestment extends Rule<Settings> {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private totalInvestment: number,
private fees: number
) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Initial Investment' name: 'Initial Investment'
}); });
} }
public evaluate( public evaluate(ruleSettings: Settings) {
aPositions: { [symbol: string]: PortfolioPosition }, const feeRatio = this.fees / this.totalInvestment;
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings = aRuleSettingsMap[FeeRatioInitialInvestment.name];
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
aPositions,
'currency',
ruleSettings.baseCurrency
);
let totalInvestment = 0;
positionsGroupedByCurrency.forEach((groupItem) => {
// Calculate total investment
totalInvestment += groupItem.investment;
});
const feeRatio = aFees / totalInvestment;
if (feeRatio > ruleSettings.threshold) { if (feeRatio > ruleSettings.threshold) {
return { return {
@ -50,4 +35,17 @@ export class FeeRatioInitialInvestment extends Rule {
value: true value: true
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.01
};
}
}
interface Settings extends RuleSettings {
baseCurrency: Currency;
threshold: number;
} }

View File

@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { environment } from '../environments/environment';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@Injectable() @Injectable()
@ -14,9 +15,12 @@ export class ConfigurationService {
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }), DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SECRET: str({ default: 'dummySecret' }),
@ -26,7 +30,10 @@ export class ConfigurationService {
RAKUTEN_RAPID_API_KEY: str({ default: '' }), RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }) ROOT_URL: str({ default: 'http://localhost:4200' }),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
}); });
} }

View File

@ -18,6 +18,7 @@ export class CronService {
@Cron(CronExpression.EVERY_12_HOURS) @Cron(CronExpression.EVERY_12_HOURS)
public async runEveryTwelveHours() { public async runEveryTwelveHours() {
await this.dataGatheringService.gatherProfileData();
await this.exchangeRateDataService.loadCurrencies(); await this.exchangeRateDataService.loadCurrencies();
} }
} }

View File

@ -1,13 +1,16 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config'; import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT,
getUtc, getUtc,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
endOfToday,
format, format,
getDate, getDate,
getMonth, getMonth,
@ -36,7 +39,7 @@ export class DataGatheringService {
if (isDataGatheringNeeded) { if (isDataGatheringNeeded) {
console.log('7d data gathering has been started.'); console.log('7d data gathering has been started.');
console.time('data-gathering'); console.time('7d-data-gathering');
await this.prisma.property.create({ await this.prisma.property.create({
data: { data: {
@ -69,7 +72,7 @@ export class DataGatheringService {
}); });
console.log('7d data gathering has been completed.'); console.log('7d data gathering has been completed.');
console.timeEnd('data-gathering'); console.timeEnd('7d-data-gathering');
} }
} }
@ -80,7 +83,7 @@ export class DataGatheringService {
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
console.log('Max data gathering has been started.'); console.log('Max data gathering has been started.');
console.time('data-gathering'); console.time('max-data-gathering');
await this.prisma.property.create({ await this.prisma.property.create({
data: { data: {
@ -113,10 +116,56 @@ export class DataGatheringService {
}); });
console.log('Max data gathering has been completed.'); console.log('Max data gathering has been completed.');
console.timeEnd('data-gathering'); console.timeEnd('max-data-gathering');
} }
} }
public async gatherProfileData(aSymbols?: string[]) {
console.log('Profile data gathering has been started.');
console.time('profile-data-gathering');
let symbols = aSymbols;
if (!symbols) {
const dataGatheringItems = await this.getSymbolsProfileData();
symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
}
const currentData = await this.dataProviderService.get(symbols);
for (const [symbol, { currency, dataSource, name }] of Object.entries(
currentData
)) {
try {
await this.prisma.symbolProfile.upsert({
create: {
currency,
dataSource,
name,
symbol
},
update: {
currency,
name
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
});
} catch (error) {
console.error(`${symbol}: ${error?.meta?.cause}`);
}
}
console.log('Profile data gathering has been completed.');
console.timeEnd('profile-data-gathering');
}
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false; let hasError = false;
@ -145,11 +194,11 @@ export class DataGatheringService {
) )
) { ) {
if ( if (
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')] historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice ?.marketPrice
) { ) {
lastMarketPrice = lastMarketPrice =
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')] historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice; ?.marketPrice;
} }
@ -187,7 +236,8 @@ export class DataGatheringService {
public async getCustomSymbolsToGather( public async getCustomSymbolsToGather(
startDate?: Date startDate?: Date
): Promise<IDataGatheringItem[]> { ): Promise<IDataGatheringItem[]> {
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations(); const scraperConfigurations =
await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => { return scraperConfigurations.map((scraperConfiguration) => {
return { return {
@ -224,7 +274,12 @@ export class DataGatheringService {
const distinctOrders = await this.prisma.order.findMany({ const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true } select: { dataSource: true, symbol: true },
where: {
date: {
lt: endOfToday() // no draft
}
}
}); });
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
@ -280,7 +335,12 @@ export class DataGatheringService {
const distinctOrders = await this.prisma.order.findMany({ const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { dataSource: true, date: true, symbol: true } select: { dataSource: true, date: true, symbol: true },
where: {
date: {
lt: endOfToday() // no draft
}
}
}); });
return [ return [
@ -291,6 +351,25 @@ export class DataGatheringService {
]; ];
} }
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }
});
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
(distinctOrder) => {
return (
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.RAKUTEN
);
}
);
}
private async isDataGatheringNeeded() { private async isDataGatheringNeeded() {
const lastDataGathering = await this.prisma.property.findUnique({ const lastDataGathering = await this.prisma.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' } where: { key: 'LAST_DATA_GATHERING' }

View File

@ -1,4 +1,5 @@
import { import {
DATE_FORMAT,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -29,7 +30,7 @@ export class DataProviderService {
private readonly rakutenRapidApiService: RakutenRapidApiService, private readonly rakutenRapidApiService: RakutenRapidApiService,
private readonly yahooFinanceService: YahooFinanceService private readonly yahooFinanceService: YahooFinanceService
) { ) {
this.rakutenRapidApiService.setPrisma(this.prisma); this.rakutenRapidApiService?.setPrisma(this.prisma);
} }
public async get( public async get(
@ -46,7 +47,10 @@ export class DataProviderService {
} }
const yahooFinanceSymbols = aSymbols.filter((symbol) => { const yahooFinanceSymbols = aSymbols.filter((symbol) => {
return !isGhostfolioScraperApiSymbol(symbol); return (
!isGhostfolioScraperApiSymbol(symbol) &&
!isRakutenRapidApiSymbol(symbol)
);
}); });
const response = await this.yahooFinanceService.get(yahooFinanceSymbols); const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
@ -57,13 +61,24 @@ export class DataProviderService {
for (const symbol of ghostfolioScraperApiSymbols) { for (const symbol of ghostfolioScraperApiSymbols) {
if (symbol) { if (symbol) {
const ghostfolioScraperApiResult = await this.ghostfolioScraperApiService.get( const ghostfolioScraperApiResult =
[symbol] await this.ghostfolioScraperApiService.get([symbol]);
);
response[symbol] = ghostfolioScraperApiResult[symbol]; response[symbol] = ghostfolioScraperApiResult[symbol];
} }
} }
const rakutenRapidApiSymbols = aSymbols.filter((symbol) => {
return isRakutenRapidApiSymbol(symbol);
});
for (const symbol of rakutenRapidApiSymbols) {
if (symbol) {
const rakutenRapidApiResult =
await this.ghostfolioScraperApiService.get([symbol]);
response[symbol] = rakutenRapidApiResult[symbol];
}
}
return response; return response;
} }
@ -86,9 +101,9 @@ export class DataProviderService {
const rangeQuery = const rangeQuery =
from && to from && to
? `AND date >= '${format(from, 'yyyy-MM-dd')}' AND date <= '${format( ? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format(
to, to,
'yyyy-MM-dd' DATE_FORMAT
)}'` )}'`
: ''; : '';
@ -106,7 +121,7 @@ export class DataProviderService {
r[symbol] = { r[symbol] = {
...(r[symbol] || {}), ...(r[symbol] || {}),
[format(new Date(date), 'yyyy-MM-dd')]: { marketPrice } [format(new Date(date), DATE_FORMAT)]: { marketPrice }
}; };
return r; return r;

View File

@ -1,4 +1,5 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -66,8 +67,8 @@ export class AlphaVantageService implements DataProviderInterface {
historicalData['Time Series (Digital Currency Daily)'] historicalData['Time Series (Digital Currency Daily)']
).sort()) { ).sort()) {
if ( if (
isAfter(from, parse(key, 'yyyy-MM-dd', new Date())) && isAfter(from, parse(key, DATE_FORMAT, new Date())) &&
isBefore(to, parse(key, 'yyyy-MM-dd', new Date())) isBefore(to, parse(key, DATE_FORMAT, new Date()))
) { ) {
response[symbol][key] = { response[symbol][key] = {
marketPrice: parseFloat(timeSeries['4a. close (USD)']) marketPrice: parseFloat(timeSeries['4a. close (USD)'])

View File

@ -1,4 +1,5 @@
import { import {
DATE_FORMAT,
getYesterday, getYesterday,
isGhostfolioScraperApiSymbol isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -95,7 +96,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return { return {
[symbol]: { [symbol]: {
[format(getYesterday(), 'yyyy-MM-dd')]: { [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value marketPrice: value
} }
} }
@ -109,9 +110,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
public async getScraperConfigurations(): Promise<ScraperConfig[]> { public async getScraperConfigurations(): Promise<ScraperConfig[]> {
try { try {
const { const { value: scraperConfigString } =
value: scraperConfigString await this.prisma.property.findFirst({
} = await this.prisma.property.findFirst({
select: { select: {
value: true value: true
}, },

View File

@ -1,4 +1,5 @@
import { import {
DATE_FORMAT,
getToday, getToday,
getYesterday, getYesterday,
isRakutenRapidApiSymbol isRakutenRapidApiSymbol
@ -117,7 +118,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return { return {
'GF.FEAR_AND_GREED_INDEX': { 'GF.FEAR_AND_GREED_INDEX': {
[format(getYesterday(), 'yyyy-MM-dd')]: { [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value marketPrice: fgi.previousClose.value
} }
} }

View File

@ -1,6 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
isCrypto,
isCurrency,
parseCurrency
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -12,9 +17,7 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
Industry,
MarketState, MarketState,
Sector,
Type Type
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { import {
@ -70,16 +73,6 @@ export class YahooFinanceService implements DataProviderInterface {
type: this.parseType(this.getType(symbol, value)) type: this.parseType(this.getType(symbol, value))
}; };
const industry = this.parseIndustry(value.summaryProfile?.industry);
if (industry) {
response[symbol].industry = industry;
}
const sector = this.parseSector(value.summaryProfile?.sector);
if (sector) {
response[symbol].sector = sector;
}
const url = value.summaryProfile?.website; const url = value.summaryProfile?.website;
if (url) { if (url) {
response[symbol].url = url; response[symbol].url = url;
@ -115,8 +108,8 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: IYahooFinanceHistoricalResponse[]; [symbol: string]: IYahooFinanceHistoricalResponse[];
} = await yahooFinance.historical({ } = await yahooFinance.historical({
symbols: yahooSymbols, symbols: yahooSymbols,
from: format(from, 'yyyy-MM-dd'), from: format(from, DATE_FORMAT),
to: format(to, 'yyyy-MM-dd') to: format(to, DATE_FORMAT)
}); });
const response: { const response: {
@ -129,7 +122,7 @@ export class YahooFinanceService implements DataProviderInterface {
response[symbol] = {}; response[symbol] = {};
timeSeries.forEach((timeSerie) => { timeSeries.forEach((timeSerie) => {
response[symbol][format(timeSerie.date, 'yyyy-MM-dd')] = { response[symbol][format(timeSerie.date, DATE_FORMAT)] = {
marketPrice: timeSerie.close, marketPrice: timeSerie.close,
performance: timeSerie.open - timeSerie.close performance: timeSerie.open - timeSerie.close
}; };
@ -228,55 +221,6 @@ export class YahooFinanceService implements DataProviderInterface {
return aString; return aString;
} }
private parseIndustry(aString: string): Industry {
if (aString === undefined) {
return undefined;
}
if (aString?.toLowerCase() === 'auto manufacturers') {
return Industry.Automotive;
} else if (aString?.toLowerCase() === 'biotechnology') {
return Industry.Biotechnology;
} else if (
aString?.toLowerCase() === 'drug manufacturers—specialty & generic'
) {
return Industry.Pharmaceutical;
} else if (
aString?.toLowerCase() === 'internet content & information' ||
aString?.toLowerCase() === 'internet retail'
) {
return Industry.Internet;
} else if (aString?.toLowerCase() === 'packaged foods') {
return Industry.Food;
} else if (aString?.toLowerCase() === 'software—application') {
return Industry.Software;
}
return Industry.Unknown;
}
private parseSector(aString: string): Sector {
if (aString === undefined) {
return undefined;
}
if (
aString?.toLowerCase() === 'consumer cyclical' ||
aString?.toLowerCase() === 'consumer defensive'
) {
return Sector.Consumer;
} else if (aString?.toLowerCase() === 'healthcare') {
return Sector.Healthcare;
} else if (
aString?.toLowerCase() === 'communication services' ||
aString?.toLowerCase() === 'technology'
) {
return Sector.Technology;
}
return Sector.Unknown;
}
private parseType(aString: string): Type { private parseType(aString: string): Type {
if (aString?.toLowerCase() === 'cryptocurrency') { if (aString?.toLowerCase() === 'cryptocurrency') {
return Type.Cryptocurrency; return Type.Cryptocurrency;
@ -291,6 +235,6 @@ export class YahooFinanceService implements DataProviderInterface {
} }
export const convertFromYahooSymbol = (aSymbol: string) => { export const convertFromYahooSymbol = (aSymbol: string) => {
let symbol = aSymbol.replace('-', ''); const symbol = aSymbol.replace('-', '');
return symbol.replace('=X', ''); return symbol.replace('=X', '');
}; };

View File

@ -1,4 +1,4 @@
import { getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -51,7 +51,7 @@ export class ExchangeRateDataService {
this.pairs.forEach((pair) => { this.pairs.forEach((pair) => {
const [currency1, currency2] = pair.match(/.{1,3}/g); const [currency1, currency2] = pair.match(/.{1,3}/g);
const date = format(getYesterday(), 'yyyy-MM-dd'); const date = format(getYesterday(), DATE_FORMAT);
this.currencies[pair] = resultExtended[pair]?.[date]?.marketPrice; this.currencies[pair] = resultExtended[pair]?.[date]?.marketPrice;

View File

@ -5,9 +5,12 @@ export interface Environment extends CleanedEnvAccessors {
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid? DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
@ -18,4 +21,7 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PORT: number; REDIS_PORT: number;
ROOT_URL: string; ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;
WEB_AUTH_RP_ID: string;
} }

View File

@ -3,30 +3,14 @@ import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
import { OrderType } from '../../models/order-type'; import { OrderType } from '../../models/order-type';
export const Industry = {
Automotive: 'Automotive',
Biotechnology: 'Biotechnology',
Food: 'Food',
Internet: 'Internet',
Pharmaceutical: 'Pharmaceutical',
Software: 'Software',
Unknown: UNKNOWN_KEY
};
export const MarketState = { export const MarketState = {
closed: 'closed', closed: 'closed',
delayed: 'delayed', delayed: 'delayed',
open: 'open' open: 'open'
}; };
export const Sector = {
Consumer: 'Consumer',
Healthcare: 'Healthcare',
Technology: 'Technology',
Unknown: UNKNOWN_KEY
};
export const Type = { export const Type = {
Cash: 'Cash',
Cryptocurrency: 'Cryptocurrency', Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF', ETF: 'ETF',
Stock: 'Stock', Stock: 'Stock',
@ -55,13 +39,11 @@ export interface IDataProviderResponse {
currency: Currency; currency: Currency;
dataSource: DataSource; dataSource: DataSource;
exchange?: string; exchange?: string;
industry?: Industry;
marketChange?: number; marketChange?: number;
marketChangePercent?: number; marketChangePercent?: number;
marketPrice: number; marketPrice: number;
marketState: MarketState; marketState: MarketState;
name: string; name: string;
sector?: Sector;
type?: Type; type?: Type;
url?: string; url?: string;
} }
@ -72,10 +54,6 @@ export interface IDataGatheringItem {
symbol: string; symbol: string;
} }
export type Industry = typeof Industry[keyof typeof Industry];
export type MarketState = typeof MarketState[keyof typeof MarketState]; export type MarketState = typeof MarketState[keyof typeof MarketState];
export type Sector = typeof Sector[keyof typeof Sector];
export type Type = typeof Type[keyof typeof Type]; export type Type = typeof Type[keyof typeof Type];

View File

@ -0,0 +1,15 @@
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Currency, DataSource } from '@prisma/client';
export interface EnhancedSymbolProfile {
createdAt: Date;
currency: Currency | null;
dataSource: DataSource;
id: string;
name: string | null;
updatedAt: Date;
symbol: string;
countries: Country[];
sectors: Sector[];
}

View File

@ -1,78 +1,24 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { Portfolio } from '../models/portfolio';
import { Rule } from '../models/rule'; import { Rule } from '../models/rule';
import { AccountClusterRiskCurrentInvestment } from '../models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '../models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '../models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '../models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '../models/rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from '../models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '../models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '../models/rules/fees/fee-ratio-initial-investment';
@Injectable() @Injectable()
export class RulesService { export class RulesService {
public constructor() {} public constructor() {}
public async evaluate( public async evaluate<T extends RuleSettings>(
aPortfolio: Portfolio, aRules: Rule<T>[],
aRules: Rule[], aUserSettings: { baseCurrency: Currency }
aUserSettings: { baseCurrency: string }
) { ) {
const defaultSettings = this.getDefaultRuleSettings(aUserSettings);
const details = await aPortfolio.getDetails();
return aRules return aRules
.filter((rule) => { .filter((rule) => {
return defaultSettings[rule.constructor.name]?.isActive; return rule.getSettings(aUserSettings)?.isActive;
}) })
.map((rule) => { .map((rule) => {
const evaluationResult = rule.evaluate( const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings));
details,
aPortfolio.getFees(),
defaultSettings
);
return { ...evaluationResult, name: rule.getName() }; return { ...evaluationResult, name: rule.getName() };
}); });
} }
private getDefaultRuleSettings(aUserSettings: { baseCurrency: string }) {
return {
[AccountClusterRiskCurrentInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[AccountClusterRiskInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[AccountClusterRiskSingleAccount.name]: { isActive: true },
[CurrencyClusterRiskBaseCurrencyInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
},
[CurrencyClusterRiskBaseCurrencyCurrentInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
},
[CurrencyClusterRiskCurrentInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[CurrencyClusterRiskInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[FeeRatioInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.01
}
};
}
} }

View File

@ -0,0 +1,64 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
import { Prisma, SymbolProfile } from '@prisma/client';
import { continents, countries } from 'countries-list';
@Injectable()
export class SymbolProfileService {
constructor(private prisma: PrismaService) {}
public async getSymbolProfiles(
symbols: string[]
): Promise<EnhancedSymbolProfile[]> {
return this.prisma.symbolProfile
.findMany({
where: {
symbol: {
in: symbols
}
}
})
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
}
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => ({
...symbolProfile,
countries: this.getCountries(symbolProfile),
sectors: this.getSectors(symbolProfile)
}));
}
private getCountries(symbolProfile: SymbolProfile): Country[] {
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map(
(country) => {
const { code, weight } = country as Prisma.JsonObject;
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
}
);
}
private getSectors(symbolProfile: SymbolProfile): Sector[] {
return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
(sector) => {
const { name, weight } = sector as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
weight: weight as number
};
}
);
}
}

View File

@ -5,19 +5,14 @@ module.exports = {
globals: { globals: {
'ts-jest': { 'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json', tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$', stringifyContentPathRegex: '\\.(html|svg)$'
astTransformers: {
before: [
'jest-preset-angular/build/InlineFilesTransformer',
'jest-preset-angular/build/StripStylesTransformer'
]
}
} }
}, },
coverageDirectory: '../../coverage/apps/client', coverageDirectory: '../../coverage/apps/client',
snapshotSerializers: [ snapshotSerializers: [
'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', 'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/AngularSnapshotSerializer.js', 'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/HTMLCommentSerializer.js' 'jest-preset-angular/build/serializers/html-comment'
] ],
transform: { '^.+\\.(ts|js|html)$': 'jest-preset-angular' }
}; };

View File

@ -33,11 +33,60 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
}, },
{
path: 'de/blog/2021/07/hallo-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
).then((m) => m.HalloGhostfolioPageModule)
},
{
path: 'en/blog/2021/07/hello-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{ {
path: 'home', path: 'home',
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
{
path: 'portfolio',
loadChildren: () =>
import('./pages/portfolio/portfolio-page.module').then(
(m) => m.PortfolioPageModule
)
},
{
path: 'portfolio/allocations',
loadChildren: () =>
import('./pages/portfolio/allocations/allocations-page.module').then(
(m) => m.AllocationsPageModule
)
},
{
path: 'portfolio/analysis',
loadChildren: () =>
import('./pages/portfolio/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'portfolio/report',
loadChildren: () =>
import('./pages/portfolio/report/report-page.module').then(
(m) => m.ReportPageModule
)
},
{
path: 'portfolio/transactions',
loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{ {
path: 'pricing', path: 'pricing',
loadChildren: () => loadChildren: () =>
@ -67,29 +116,10 @@ const routes: Routes = [
) )
}, },
{ {
path: 'tools', path: 'webauthn',
loadChildren: () => loadChildren: () =>
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule) import('./pages/webauthn/webauthn-page.module').then(
}, (m) => m.WebauthnPageModule
{
path: 'tools/analysis',
loadChildren: () =>
import('./pages/tools/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'tools/report',
loadChildren: () =>
import('./pages/tools/report/report-page.module').then(
(m) => m.ReportPageModule
)
},
{
path: 'transactions',
loadChildren: () =>
import('./pages/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
) )
}, },
{ {
@ -112,6 +142,7 @@ const routes: Routes = [
routes, routes,
// Preload all lazy loaded modules with the attribute preload === true // Preload all lazy loaded modules with the attribute preload === true
{ {
anchorScrolling: 'enabled',
preloadingStrategy: ModulePreloadService, preloadingStrategy: ModulePreloadService,
// enableTracing: true // <-- debugging purposes only // enableTracing: true // <-- debugging purposes only
relativeLinkResolution: 'legacy' relativeLinkResolution: 'legacy'

View File

@ -10,16 +10,16 @@
<main role="main"> <main role="main">
<div *ngIf="canCreateAccount" class="container create-account-container"> <div *ngIf="canCreateAccount" class="container create-account-container">
<div class="row mb-5"> <div class="row">
<div class="col-md-6 offset-md-3"> <div class="col-md-8 offset-md-2 text-center">
<a [routerLink]="['/']"> <a class="text-center" [routerLink]="['/']">
<mat-card <div
class="create-account-box p-2 text-center" class="create-account-box d-inline-block px-3 py-2"
(click)="onCreateAccount()" (click)="onCreateAccount()"
> >
<div class="mt-1" i18n>You are using the Live Demo.</div> <span i18n>You are using the Live Demo.</span>
<button mat-button color="primary" i18n>Create Account</button> <a class="ml-2" href="#" i18n>Create Account</a>
</mat-card></a </div></a
> >
</div> </div>
</div> </div>
@ -28,10 +28,7 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<footer <footer *ngIf="!user" class="footer d-flex justify-content-center w-100">
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
class="footer d-flex justify-content-center position-absolute w-100"
>
<div class="container text-center"> <div class="container text-center">
<div> <div>
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a> © {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>

View File

@ -1,21 +1,32 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host { :host {
display: block; display: block;
min-height: 100vh;
main { main {
padding: 5rem 0; min-height: 100vh;
padding-top: 5rem;
.create-account-container {
height: 3.5rem;
margin-top: -0.5rem;
.create-account-box { .create-account-box {
background-color: rgba(0, 0, 0, $alpha-hover);
border-radius: 2rem;
cursor: pointer; cursor: pointer;
font-size: 90%; font-size: 80%;
.link { a {
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
}
} }
} }
} }
.footer { .footer {
bottom: 0;
height: 5rem; height: 5rem;
line-height: 1; line-height: 1;
} }

View File

@ -6,7 +6,11 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router'; import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config'; import {
primaryColorHex,
secondaryColorHex,
warnColorHex
} from '@ghostfolio/common/config';
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 { MaterialCssVarsService } from 'angular-material-css-vars'; import { MaterialCssVarsService } from 'angular-material-css-vars';
@ -52,10 +56,6 @@ export class AppComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService.fetchInfo().subscribe((info) => {
this.info = info;
});
this.router.events this.router.events
.pipe(filter((event) => event instanceof NavigationEnd)) .pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => { .subscribe(() => {
@ -63,6 +63,8 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET]; const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
const urlSegments = urlSegmentGroup.segments; const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path; this.currentRoute = urlSegments[0].path;
this.info = this.dataService.fetchInfo();
}); });
this.userService.stateChanged this.userService.stateChanged
@ -106,5 +108,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.materialCssVarsService.setPrimaryColor(primaryColorHex); this.materialCssVarsService.setPrimaryColor(primaryColorHex);
this.materialCssVarsService.setAccentColor(secondaryColorHex); this.materialCssVarsService.setAccentColor(secondaryColorHex);
this.materialCssVarsService.setWarnColor(warnColorHex);
} }
} }

View File

@ -1,8 +1,6 @@
import { Platform } from '@angular/cdk/platform'; import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { import {
DateAdapter, DateAdapter,
MAT_DATE_FORMATS, MAT_DATE_FORMATS,
@ -15,7 +13,9 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialCssVarsModule } from 'angular-material-css-vars'; import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown'; import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
import { environment } from '../environments/environment';
import { CustomDateAdapter } from './adapter/custom-date-adapter'; import { CustomDateAdapter } from './adapter/custom-date-adapter';
import { DateFormats } from './adapter/date-formats'; import { DateFormats } from './adapter/date-formats';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
@ -25,6 +25,10 @@ import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor'; import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageService } from './core/language.service'; import { LanguageService } from './core/language.service';
export function NgxStripeFactory(): string {
return environment.stripePublicKey;
}
@NgModule({ @NgModule({
declarations: [AppComponent], declarations: [AppComponent],
imports: [ imports: [
@ -34,8 +38,6 @@ import { LanguageService } from './core/language.service';
GfHeaderModule, GfHeaderModule,
HttpClientModule, HttpClientModule,
MarkdownModule.forRoot(), MarkdownModule.forRoot(),
MatButtonModule,
MatCardModule,
MaterialCssVarsModule.forRoot({ MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme', darkThemeClass: 'is-dark-theme',
isAutoContrast: true, isAutoContrast: true,
@ -43,7 +45,8 @@ import { LanguageService } from './core/language.service';
}), }),
MatNativeDateModule, MatNativeDateModule,
MatSnackBarModule, MatSnackBarModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule,
NgxStripeModule.forRoot(environment.stripePublicKey)
], ],
providers: [ providers: [
authInterceptorProviders, authInterceptorProviders,
@ -54,7 +57,11 @@ import { LanguageService } from './core/language.service';
useClass: CustomDateAdapter, useClass: CustomDateAdapter,
deps: [LanguageService, MAT_DATE_LOCALE, Platform] deps: [LanguageService, MAT_DATE_LOCALE, Platform]
}, },
{ provide: MAT_DATE_FORMATS, useValue: DateFormats } { provide: MAT_DATE_FORMATS, useValue: DateFormats },
{
provide: STRIPE_PUBLISHABLE_KEY,
useFactory: NgxStripeFactory
}
], ],
bootstrap: [AppComponent] bootstrap: [AppComponent]
}) })

View File

@ -3,6 +3,11 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }} {{ element.name }}
<span
*ngIf="element.isDefault"
class="d-lg-inline-block d-none text-muted"
>(Default)</span
>
</td> </td>
</ng-container> </ng-container>
@ -21,6 +26,27 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Transactions
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>Balance</th>
<td *matCellDef="let element" class="text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[currency]="element.currency"
[locale]="locale"
[value]="element.balance"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -48,13 +74,6 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef i18n mat-header-cell>Transactions</th>
<td *matCellDef="let element" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>

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