Compare commits

...

326 Commits

Author SHA1 Message Date
0b2252755c Release 1.74.0 (#464) 2021-11-11 21:52:57 +01:00
239bd09cbd Feature/move market mood to tab (#463)
* Move market mood to tab

* Update changelog
2021-11-11 21:43:17 +01:00
cd76f89902 Feature/increase decimal places for cryptocurrencies (#462)
* Calculate quantity precision

* Update changelog
2021-11-11 21:21:37 +01:00
7425ba94f1 Release 1.73.0 (#461) 2021-11-10 21:16:06 +01:00
b9522307c4 Feature/various client improvements (#460)
* Various improvements
  * info messages
  * skeleton loader of portfolio holdings

* Update changelog
2021-11-10 21:03:25 +01:00
e01e039a00 Release 1.72.0 (#459) 2021-11-08 20:55:38 +01:00
6d3513c17f Update version 2021-11-07 21:27:37 +01:00
d60b444324 Release/1.71.0 (#458)
* Refactor logger

* Fix exception

* Update changelog
2021-11-07 21:25:18 +01:00
2873130259 Release 1.70.0 (#457) 2021-11-07 18:38:29 +01:00
d999a27159 Feature/move scraper configuration to symbol profile (#456)
* Move scraper configuration

* Update changelog
2021-11-07 18:36:28 +01:00
b6902e10ea Feature/improve import json file validation (#455)
* Improve import validation

* Update changelog
2021-11-07 17:16:19 +01:00
7f3f75386d Release 1.69.0 (#454) 2021-11-07 09:45:19 +01:00
678544748a Add symbol mapping (#452)
* Add symbol mapping

* Update changelog
2021-11-07 09:42:36 +01:00
632f3e3872 Add ok.csv (#453) 2021-11-06 21:09:06 +01:00
87301ddbd5 Feature/improve registration page (#451)
* Improve registration page

* Update changelog
2021-11-02 21:49:57 +01:00
7d03c373ac Release 1.68.0 (#450) 2021-11-01 21:27:23 +01:00
edb66bb166 Feature/extend statistics (#449)
* Extend statistics

* Update changelog
2021-11-01 21:15:09 +01:00
54bbc8446b Feature/prettify scraper symbol in chart (#448)
* Prettify scraper symbol in chart

* Update changelog
2021-11-01 20:29:16 +01:00
9933967e42 Release 1.67.0 (#447) 2021-11-01 19:33:45 +01:00
5618513d07 Update changelog (#446) 2021-10-31 17:02:41 +01:00
1397cd62a8 Feature/extend public page (#445)
* Extend public page

* Update changelog
2021-10-31 17:00:59 +01:00
e7fb31d1a6 add Dockerfile with all in one docker image (#431)
* Add Dockerfile with all in one docker image

* Change to alpine image and reduce node_modules size

* Improve documentation and fix changelog and license

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-10-30 18:54:14 +02:00
51e7b94ad0 Release 1.66.0 (#443) 2021-10-30 11:22:50 +02:00
9133ea38f3 Feature/order accesses (#441)
* Order accesses

* Update changelog
2021-10-30 10:49:17 +02:00
a864c617b9 Feature/improve landing page (#442)
* Add intro screen

* Update changelog
2021-10-30 10:46:54 +02:00
442df9d6f8 Release 1.65.0 (#440) 2021-10-25 21:03:36 +02:00
2de0e75cb8 Feature/add user interface for granting and revoking public access (#439)
* Add user interface for granting and revoking public access

* Update changelog
2021-10-25 20:46:36 +02:00
1296f95602 Feature/move data enhancer from data provider to data gathering (#437)
* Move data enhancers from data provider to data enhancer module

* Update changelog
2021-10-24 10:49:17 +02:00
e98dff877a Add link to Visual Studio Code (#436) 2021-10-22 20:20:59 +02:00
964b37af30 Add Visual Studio Code link (#435) 2021-10-22 20:17:33 +02:00
2d89d12d31 Release 1.64.0 (#434) 2021-10-21 22:25:58 +02:00
19e2d54791 Refactor custom cryptocurrencies (#433) 2021-10-21 22:11:12 +02:00
e24e5e1c44 Add custom crypto currencies (#432)
* Add custom cryptocurrencies
2021-10-21 21:20:01 +02:00
a0d4ff7920 Clean up ts-node script (#430)
Co-Authored-By: Davin <52686922+Frozies@users.noreply.github.com>
2021-10-20 22:46:58 +02:00
099ad18aaf refactor data-provider.service.ts to have dynamic list of services (#429)
* Refactor data-provider.service.ts to have dynamic list of services

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-10-20 21:41:40 +02:00
e3cd99f5d2 Release 1.63.0 (#427) 2021-10-19 18:49:11 +02:00
6dea9093ba Feature/add public portfolio (#426)
* Setup public portfolio page

* Update changelog
2021-10-19 18:27:50 +02:00
43104f81d0 Add test data (#424) 2021-10-17 11:56:35 +02:00
6d2e3b6e40 Feature/improve skeleton loader size (#425)
* Improve skeleton loader size

* Update changelog
2021-10-17 11:53:27 +02:00
1d9a31dbb8 Improve styling (#423) 2021-10-17 11:42:27 +02:00
ea0e92220c Release 1.62.0 (#422) 2021-10-17 10:52:08 +02:00
b57301ef50 Feature/extend import validation message (#421)
* Extend import validation message

* Update changelog
2021-10-16 21:23:15 +02:00
67dbc6b014 Release 1.61.0 (#420) 2021-10-15 22:30:44 +02:00
2e5176bacf Feature/extend import by csv files (#419)
* Support import of csv files

* Update changelog
2021-10-15 22:22:45 +02:00
060846023f Release 1.60.0 (#418) 2021-10-13 12:10:12 +02:00
f06a0fbbee Feature/validate duplicate orders for import (#416)
* Validate duplicate orders

* Update changelog
2021-10-13 11:52:04 +02:00
4ab6a1a071 Feature/harmonize page layouts (#417)
* Harmonize page layouts

* Update changelog
2021-10-13 11:51:33 +02:00
93dcbeb6c7 Feature/add validation for import (#415)
* Valid data types
* Maximum number of orders
* Data provider service returns data for the dataSource / symbol pair
2021-10-12 22:19:32 +02:00
b9f0a57522 Bugfix/unregister chartjs plugin datalabels (#414)
* Unregister chartjs-plugin-datalabels

* Update changelog
2021-10-12 09:23:37 +02:00
174c1d1a62 Release 1.59.0 (#413) 2021-10-11 20:35:06 +02:00
f308ae7a13 add sectors and countries for ETFs (#410)
* Update changelog

Co-Authored-By: Valentin Zickner <valentin@coderworks.de>

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2021-10-11 19:32:21 +02:00
a7a6b0608b Add ghostfolio-docker (#411)
Co-Authored-By: psychowood <115389+psychowood@users.noreply.github.com>
2021-10-09 10:45:58 +02:00
15a61b7a20 Feature/improve values of global heat map (#408)
* Convert value

* Update changelog
2021-10-04 21:22:42 +02:00
d1eedf9726 Bugfix/various fixes (#407)
* Fix links

* Update column

* Fix impersonation mode

* Update changelog
2021-10-03 22:04:23 +02:00
30a592b524 Release 1.58.1 (#406) 2021-10-03 10:50:01 +02:00
de94494aa0 Bugfix/fix symbol conversion for yahoo finance (#405)
* Fix symbol conversion for Yahoo Finance

* Update changelog
2021-10-03 10:40:21 +02:00
d3c6788ad5 Release 1.58.0 (#404) 2021-10-02 20:45:02 +02:00
3ec4a73b35 Feature/improve tooltips (#403)
* Improve tooltips

* Update changelog
2021-10-02 20:38:41 +02:00
1050bfa098 Feature/improve yahoo finance symbol conversion (#402)
* Improve symbol conversion

* Update changelog
2021-10-02 10:28:06 +02:00
595ec1d7b4 Feature/upgrade envalid to version 7.2.1 (#401)
* Upgrade envalid

* Update changelog
2021-09-30 21:54:58 +02:00
c8389599b6 Release 1.57.0 (#400) 2021-09-29 21:34:50 +02:00
8769fe4c90 Improve styling (#399) 2021-09-29 21:10:04 +02:00
4219e1121e Improve style (#398) 2021-09-29 21:05:01 +02:00
f558eb8de8 Fix template (#397) 2021-09-29 21:04:41 +02:00
fe2bd6eea8 Feature/protect endpoints (#396)
* Protect endpoints

* Update changelog
2021-09-28 21:37:01 +02:00
035052be99 Feature/improve exchange rates table (#394)
* Improve exchange rates table

* Update changelog
2021-09-26 20:57:37 +02:00
bcdd2780b3 Release 1.56.0 (#393) 2021-09-25 18:02:02 +02:00
22d1ed7920 Extend data (#392)
* assetClass
* assetSubClass
* currency
* name
2021-09-25 17:42:45 +02:00
39d9828f9f Feature/respect account currency in exchange rate data service (#391)
* Respect the accounts' currencies

* Update changelog
2021-09-25 16:45:21 +02:00
6333aa972d Bugfix/fix data gathering after seed (#390)
* Fix data gathering after seed

* Update changelog
2021-09-25 16:44:24 +02:00
554f2f861f Upgrade @types (#389) 2021-09-25 09:20:47 +02:00
dcee651098 Feature/support unlimited currencies (#387)
* Support unlimited currencies

* Update changelog
2021-09-24 21:09:48 +02:00
508a48f4c3 Feature/hide actions in presenter view (#380)
* Hide actions if restricted view is active

* Update changelog
2021-09-24 20:15:10 +02:00
8466e3d73f Feature/always show adapted portfolio page (#388)
* Always show adapted portfolio page

* Update changelog
2021-09-24 20:12:17 +02:00
9ae9904389 Feature/add story for line chart component (#385)
* Add story for line chart component

* Update changelog
2021-09-20 21:44:47 +02:00
af022ae316 Feature/add story for portfolio proportion chart component (#384)
* Add story

* Use new component

* Update changelog
2021-09-20 20:45:58 +02:00
5cd6edaf3a Release 1.55.0 (#383) 2021-09-20 11:58:48 +02:00
98be8745d9 Bugfix/fix create or edit transaction dialog (#382)
* Fix create or edit transaction dialog

* Update changelog
2021-09-20 11:49:50 +02:00
861dff9210 Feature/upgrade storybook dependencies (#381)
* Upgrade storybook dependencies
2021-09-19 18:01:41 +02:00
f2364eed10 Feature/remove default value of data source (#379)
* Remove default value of dataSource

* Update changelog
2021-09-19 17:16:29 +02:00
d5392de7c9 Release 1.54.0 (#378) 2021-09-18 20:21:43 +02:00
0f72673ef4 Feature/respect data source in symbol data endpoint (#370)
* Respect data source in symbol data endpoint

* Respect data source in the data provider service

* Combine symbol with data source in get() of data provider service

* Improve search functionality for multiple data sources

* Update changelog
2021-09-18 19:32:22 +02:00
641fe4e8f4 Bugfix/net performance in positions endpoint (#377)
* Nullify net performance

* Update changelog
2021-09-18 19:20:14 +02:00
18e06bb6e6 Feature/hide sign if performance is zero (#376)
* Hide sign if performance is zero

* Update changelog
2021-09-18 13:36:49 +02:00
5b588c2000 Bugfix/hide the current net performance (#373)
* Hide the current net performance

* Update changelog
2021-09-15 22:27:18 +02:00
162d19fa44 Release 1.53.0 (#369) 2021-09-13 21:37:18 +02:00
4a815d2031 Feature/change data gathering selection (#368)
* Change data gathering selection from distinct orders to symbol profiles

* Update changelog
2021-09-13 21:26:23 +02:00
d2aeeb3e88 optimize annual performance calculation (#367)
* Optimize annual performance calculation

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-12 20:08:42 +02:00
ba926ffcf2 Release 1.52.0 (#366) 2021-09-11 21:36:22 +02:00
5ea455b98b Feature/upgrade simplewebauthn dependencies to version 4.1.0 (#365)
* Upgrade @simplewebauthn dependencies to version 4.1.0
  * @simplewebauthn/browser
  * @simplewebauthn/server

* Update changelog
2021-09-11 21:23:06 +02:00
39f315aba0 Feature/add annualized performance (#364)
* Add annualized performance

* Update changelog
2021-09-11 20:16:53 +02:00
df2dfc20a1 Feature/add slack channel (#363)
* Add Slack channel

* Update changelog
2021-09-11 12:06:28 +02:00
81e83d4cea Release 1.51.0 (#362) 2021-09-11 11:25:07 +02:00
5d4156ecec Feature/refactor position detail dialog (#355)
* Add name to portfolio position endpoint

* Update changelog
2021-09-11 11:23:47 +02:00
4693a8baa2 Release 1.50.0 (#361) 2021-09-11 11:21:53 +02:00
773444b1e2 Bugfix/fix home button overlap on ios (#360)
* Fix overlap

* Update changelog
2021-09-11 11:17:49 +02:00
3c46bde8d5 Bugfix/fix fear and greed index (#359)
* Fix fear and greed index
* Refactor fear and greed index symbol
   * GF.FEAR_AND_GREED_INDEX -> _GF_FEAR_AND_GREED_INDEX

* Update changelog
2021-09-11 11:14:55 +02:00
63ee33b685 Use 'import type' to import types, eliminate webpack warnings (#358) 2021-09-11 09:27:22 +02:00
bc87c0a3e1 Add slack (#357) 2021-09-10 18:11:35 +02:00
caa9fc3efa Release 1.49.0 (#354) 2021-09-08 22:19:53 +02:00
9ed82ac82b Feature/improve labels of allocation chart by symbol (#353)
* Improve labels

* Update changelog
2021-09-08 22:03:33 +02:00
9c9ca4ab1e Add labels to allocation piecharts (#337) 2021-09-08 21:31:34 +02:00
b0b0942162 Release 1.48.0 (#352)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:23:07 +02:00
9cbf789c22 Bugfix/fix values in position detail dialog (#351)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:11:38 +02:00
ee5ab05d8a Release 1.47.1 (#350) 2021-09-06 22:55:08 +02:00
20731c67cb Release 1.47.0 (#349) 2021-09-06 22:34:17 +02:00
bf8856ad19 Bugfix/fix search for cryptocurrencies (#348)
* Fix the search for cryptocurrency symbols

* Update changelog
2021-09-06 22:02:49 +02:00
a31d79821d Release 1.46.0 (#347) 2021-09-05 22:15:21 +02:00
48ab862bb6 net performance for current positions (#330)
* implement fees for transaction points #324

* add net performance to current positions #324

* add net performance to calculate timeline #324

* make timeline fee accumulated by default #324

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-05 21:21:22 +02:00
ba234a470e Feature/add storybook story for trend indicator component (#346)
* Add storybook story for trend indicator component

* Update changelog
2021-09-05 08:49:06 +02:00
ccae660104 Feature/add storybook story for no transactions info component (#345)
* Add storybook story for no transactions info component

* Update changelog
2021-09-05 08:34:02 +02:00
21ed91d184 Feature/add storybook story for logo component (#344)
* Add storybook story for logo component

* Update changelog
2021-09-05 08:03:32 +02:00
5fd413e57e Feature/setup storybook (#332)
* Setup ui library with storybook

* Add value component with story

* Update changelog
2021-09-04 22:12:54 +02:00
4c194c938a Feature/add contributors count to statistics (#342)
* Add contributors count to statistics

* Update changelog
2021-09-04 19:46:24 +02:00
a4d049e53d Release 1.45.0 (#340) 2021-09-04 19:20:09 +02:00
f9c4408126 Update yarn start:server to watch for changes (#338) 2021-09-04 19:05:04 +02:00
d046f1d498 Feature/upgrade nx to version 12.8.0 (#331)
* Upgrade angular and Nx

* Update changelog
2021-09-04 11:25:40 +02:00
ad96d6e53e Feature/upgrade prisma from version 2.24.1 to 2.30.2 (#325)
* Upgrade prisma

* Update changelog
2021-09-04 10:49:09 +02:00
747e5b63fa Feature/restructure allocations page including allocations by symbol (#333)
* Restructure allocations page

* Update changelog
2021-09-03 23:20:30 +02:00
b1187cf880 Add a new symbol allocation chart (#326)
* Add a new symbol allocation chart

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-03 17:48:35 +02:00
ba9e6eab58 Feature/add link to transactions below holdings (#329)
* Add link: Manage transactions

* Update changelog
2021-09-02 21:17:01 +02:00
01feead017 Show decimal transactionCount and singular for 1 transaction (#327)
* Show decimal `transactionCount` and singular for 1 transaction

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-02 20:47:27 +02:00
6a0cfb8f77 Release 1.44.0 (#323) 2021-08-30 18:22:41 +02:00
6386786ac0 Bugfix/improve symbol lookup (#322)
* Improve symbol lookup

* Update changelog
2021-08-30 18:08:21 +02:00
d3be6577c8 Add feature: time-weighted rate of return (TWR) (#321) 2021-08-29 17:09:11 +02:00
73a967a7e5 Feature/add cash as asset sub class (#319)
* Add cash as asset sub class

* Update changelog
2021-08-26 21:49:02 +02:00
836ff6ec13 Feature/upgrade svgmap to version 2.6.0 (#318)
* Upgrade svgmap

* Update changelog
2021-08-26 21:23:49 +02:00
c5bb3023d3 Bugfix/filter out positions without quantity (#317)
* Filter out positions without any quantity

* Update changelog
2021-08-26 17:45:04 +02:00
695c378b48 Release 1.43.0 (#316) 2021-08-24 21:31:19 +02:00
fe975945d1 Feature/add fallback for loading currencies (#315)
* Add fallback for loading currencies

* Update changelog
2021-08-24 21:09:02 +02:00
d8782b0d4c Feature/automate countries for stocks in symbol profile data (#314)
* Automate countries for stocks in symbol profile data

* Update changelog
2021-08-24 20:24:18 +02:00
e14f08a8fb Release 1.42.0 (#313) 2021-08-22 22:37:44 +02:00
72c065a59d Feature/introduce asset sub class (#312)
* Introduce asset sub class

* Update changelog
2021-08-22 22:19:10 +02:00
98dac4052a Feature/add subscription type to the admin user table (#311)
* Add the subscription type to the user table in the admin control panel

* Update changelog
2021-08-22 22:11:05 +02:00
2083d28d02 Feature/minor improvements in the page components (#310)
* Move permissions to constructor

* Sort imports
2021-08-22 10:25:34 +02:00
addd5c36d9 Release 1.41.0 (#309) 2021-08-21 15:35:59 +02:00
aad8f77093 Feature/improve allocations by account (#308)
* Improve allocations by account

* Eliminate accounts from PortfolioPosition

* Ignore cash assets in the allocation chart by sector, continent and country

* Add missing accounts to portfolio details

* Update changelog
2021-08-21 15:03:55 +02:00
a904208d06 Feature/improve table styling (#307)
* Improve table styling

* Update changelog
2021-08-21 14:56:50 +02:00
2733b78044 Minor improvement (#306) 2021-08-21 14:56:11 +02:00
b43b515df1 Feature/add link to system status page (#305)
* Add link to system status page

* Update changelog
2021-08-21 08:57:12 +02:00
70e14b4d3c Feature/improve restricted view mode (#304)
* Improve wording and padding

* Update changelog
2021-08-20 20:58:33 +02:00
0f7d1b7d59 Release 1.40.0 (#303) 2021-08-19 21:56:21 +02:00
c2ab6a6c44 Feature/improve portfolio details endpoint (#302)
* Make details endpoint fault tolerant (do not throw error)

* Update changelog
2021-08-19 21:44:10 +02:00
c71a4c078e Bugfix/convert g bp to gbp in yahoo finance service (#301)
* Fix currency inconsistency in the yahoo finance service (GBp to GBP)

* Update changelog
2021-08-18 18:22:01 +02:00
e17b217032 Bugfix/fix issue on buy date in position detail dialog (#297)
* Fix issue on buy date

* Update changelog
2021-08-17 21:31:32 +02:00
408e08d43c Bugfix/fix node engine version mismatch (#299)
* Fix node engine version mismatch

* Update changelog
2021-08-17 20:32:12 +02:00
2fa324702f Release 1.39.0 (#296) 2021-08-16 21:57:51 +02:00
05b0efef82 Feature/add restricted view (#295)
* Add restricted view

* Update changelog
2021-08-16 21:40:29 +02:00
7c91727eb1 Feature/restructure allocations page (#294)
* Restructure allocations page

* Update changelog
2021-08-15 09:55:46 +02:00
0ee2258af8 Feature/improve impersonation mode (#293)
* Improve the impersonation mode

* Update changelog
2021-08-14 19:15:26 +02:00
308b218487 introduce basic module structure for data provider (#278)
* introduce basic module structure for data provider

* introduce DataGatheringModule

* introduce ExchangeRateDataModule

* introduce ImpersonationModule

* move RulesService

* cleanup portfolio module

* Sort imports

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-14 16:55:40 +02:00
26694adb8e Release 1.38.0 (#292) 2021-08-14 11:25:50 +02:00
77936e3bf3 Feature/improve users table (#291)
* Improve users table
  * Engagement / Day
  * Registration

* Update changelog
2021-08-14 11:12:08 +02:00
13090bf6b5 Feature/add overview menu item on mobile (#290)
* Add overview menu item

* Update changelog
2021-08-14 11:09:53 +02:00
b898c0678d Feature/refactor exchange rate service (#289)
* Refactor exchange rate service

* Update changelog
2021-08-14 11:06:21 +02:00
3330ae70b6 Release 1.37.0 (#288) 2021-08-13 20:49:05 +02:00
96a615dc5d Bugfix/only allow supported currencies in symbol search (#287)
* Only allow supported currencies in symbol search

* Update changelog
2021-08-13 20:29:57 +02:00
98f44323da Feature/improve usability of tabs on home page (#283)
* Improve usability: lazy load endpoints on tab change

* Feature/improve portfolio summary (#285)

* Update changelog
2021-08-13 19:26:48 +02:00
8adacd9760 Feature/upgrade angular material css vars to version 2.1.2 (#282)
* Upgrade angular-material-css-vars from version 2.1.0 to 2.1.2

* Update changelog
2021-08-13 19:05:22 +02:00
908aba170d Fix position chart for missing historical data (#284)
* Fix position chart for missing historical data

* Update changelog
2021-08-12 23:30:04 +02:00
d599797a65 Release 1.36.0 (#281) 2021-08-09 22:06:19 +02:00
8ac1272a9d Feature/eliminate name from scraper config (#277)
* Eliminate name from scraper config

* Update changelog
2021-08-09 21:33:58 +02:00
0a85a56c67 Respect cash balance in allocations, do not hide cryptocurrency holdings (#280)
* Respect cash balance in allocations, do not hide cryptocurrency holdings

* Update changelog
2021-08-09 21:26:41 +02:00
4ad5590838 Feature/improve data gathering (#276)
* Improve data gathering
  * Refactoring
  * On server restart, only reset if hanging in LOCKED_DATA_GATHERING state

* Update changelog
2021-08-09 21:11:35 +02:00
5b8af68e71 Release 1.35.0 (#279) 2021-08-08 21:23:48 +02:00
80d043729d Feature/replace type with asset class (#274)
* Improved the asset classification
  * Add assetClass to symbolProfile
  * Remove type from position

* Update changelog
2021-08-08 19:27:58 +02:00
178166d86b Extend investment chart by three months (#273) 2021-08-08 19:25:38 +02:00
37358fb480 Bugfix/add fallback if exchange service is not initialized (#264)
* Add fallback and log error

* Update changelog
2021-08-08 19:24:51 +02:00
616d601cf6 Improve wording (#275) 2021-08-08 12:20:30 +02:00
e88b889fdd Feature/optimize accounts table for mobile (#271)
* Optimize accounts table

* Update changelog
2021-08-08 09:24:47 +02:00
f6cdc4ff47 Feature/disable pagination of tabs (#272)
* Disable pagination

* Update changelog
2021-08-08 09:24:30 +02:00
818c40fc61 Feature/upgrade chart.js to version 3.5.0 (#268)
* Upgrade chart.js

* Update changelog
2021-08-08 08:33:55 +02:00
3589e72aea Harmonize prisma service (#266) 2021-08-07 22:38:07 +02:00
e68aa1fa68 Clean up imports (#267) 2021-08-07 22:37:39 +02:00
bb76ace95d Feature/improve support for draft transactions (#265)
* Improve support for draft transactions

* Update changelog
2021-08-07 20:52:55 +02:00
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
425 changed files with 21695 additions and 9126 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

@ -1 +1,2 @@
/dist /dist
/test/import

11
.storybook/main.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
stories: [],
addons: ['@storybook/addon-essentials']
// uncomment the property below if you want to apply some webpack config globally
// webpackFinal: async (config, { configType }) => {
// // Make whatever fine-grained changes you need that should apply to all storybook configs
// // Return the altered config
// return config;
// },
};

10
.storybook/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"exclude": [
"../**/*.spec.js",
"../**/*.spec.ts",
"../**/*.spec.tsx",
"../**/*.spec.jsx"
],
"include": ["../**/*"]
}

3
.vscode/launch.json vendored
View File

@ -26,7 +26,8 @@
"skipFiles": [ "skipFiles": [
"${workspaceFolder}/node_modules/**/*.js", "${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js" "<node_internals>/**/*.js"
] ],
"console": "integratedTerminal"
} }
] ]
} }

View File

@ -5,6 +5,615 @@ 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.74.0 - 11.11.2021
### Changed
- Adapted the decimal places for cryptocurrencies in the position detail dialog
- Moved the _Fear & Greed Index_ (market mood) to a new tab on the home page
## 1.73.0 - 10.11.2021
### Changed
- Improved the info messages to add the first transaction
### Fixed
- Fixed the skeleton loader of the portfolio holdings
## 1.72.0 - 08.11.2021
### Changed
- Cached the statistics section on the about page
## 1.71.0 - 07.11.2021
### Changed
- Changed the logger from `console.log()` to `Logger.log()`
### Fixed
- Fixed an exception in the scraper configuration
## 1.70.0 - 07.11.2021
### Changed
- Improved the validation of `json` files in the import functionality for transactions
- Moved the scraper configuration to the symbol profile model
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.69.0 - 07.11.2021
### Added
- Added the symbol mapping attribute to the symbol profile model
### Changed
- Improved the registration page
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.68.0 - 01.11.2021
### Changed
- Prettified the generic scraper symbols in the portfolio proportion chart component
- Extended the statistics section on the about page by the active users count (7d)
- Extended the statistics section on the about page by the new users count
## 1.67.0 - 31.10.2021
### Added
- Added more details to the public page (currencies, sectors, continents and regions)
- Added a `Dockerfile` and documentation to build a _Docker_ image
## 1.66.0 - 30.10.2021
### Changed
- Improved the landing page
- Ordered the granted accesses by type
## 1.65.0 - 25.10.2021
### Added
- Added the user interface for granting and revoking public access to share the portfolio
### Changed
- Moved the data enhancer calls from the data provider (`get()`) to the data gathering service to reduce traffic to 3rd party data providers
- Changed the profile data gathering from every 12 hours to once every weekend
## 1.64.0 - 21.10.2021
### Added
- Added support for more cryptocurrency symbols like _Avalanche_, _Polygon_, _Shiba Inu_ etc.
### Changed
- Changed the data provider service to handle a dynamic list of services
## 1.63.0 - 19.10.2021
### Added
- Added a public page to share the portfolio
### Changed
- Improved the skeleton loader size of the portfolio proportion chart component
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.62.0 - 17.10.2021
### Added
- Extended the validation message of the import functionality for transactions
## 1.61.0 - 15.10.2021
### Added
- Extended the import functionality for transactions by `csv` files
- Introduced the primary data source
### Changed
- Restricted the file selector of the import functionality for transactions to `csv` and `json`
## 1.60.0 - 13.10.2021
### Added
- Extended the validation of the import functionality for transactions
- Valid data types
- Maximum number of orders
- No duplicate orders
- Data provider service returns data for the `dataSource` / `symbol` pair
### Changed
- Harmonized the page layouts
### Fixed
- Fixed the broken line charts showing value labels
## 1.59.0 - 11.10.2021
### Added
- Added a data enhancer for symbol profile data (countries and sectors) via _Trackinsight_
### Changed
- Changed the values of the global heat map to fixed-point notation
### Fixed
- Fixed the links of cryptocurrency assets in the positions table
- Fixed various values in the impersonation mode which have not been nullified
## 1.58.1 - 03.10.2021
### Fixed
- Fixed an issue in the symbol conversion for _Yahoo Finance_ (for a cryptocurrency with the same code as a currency)
## 1.58.0 - 02.10.2021
### Changed
- Improved the symbol conversion for _Yahoo Finance_: Support for _Solana USD_ (`SOL1-USD`)
- Improved the tooltips of the allocations page
- Upgraded `envalid` from version `7.1.0` to `7.2.1`
## 1.57.0 - 29.09.2021
### Added
- Added a protection for endpoints (subscriptions)
### Changed
- Reformatted the exchange rates table in the admin control panel
## 1.56.0 - 25.09.2021
### Added
- Added a story for the line chart component
- Added a story for the portfolio proportion chart component
### Changed
- Changed the navigation to always show the portfolio page
- Migrated the data type of currencies from `enum` to `string` in the database
- Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`)
- Respected the accounts' currencies in the exchange rate service
### Fixed
- Hid the actions from the accounts table in the _Presenter View_
- Hid the actions from the transactions table in the _Presenter View_
- Fixed the data gathering of the initial project setup (database seeding)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.55.0 - 20.09.2021
### Changed
- Removed the default value of the data source attribute
- Upgraded `@storybook` dependencies
### Fixed
- Fixed an issue in the create or edit transaction dialog
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.54.0 - 18.09.2021
### Added
- Added the data source attribute to the symbol profile model
### Changed
- Respected the data source attribute in the data provider service
- Respected the data source attribute in the symbol data endpoint
- Improved the search functionality of the data management (multiple data sources)
### Fixed
- Hid the net performance in the _Presenter View_ (portfolio holdings and summary tab on the home page)
- Hid the sign if the performance is zero in the value component
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.53.0 - 13.09.2021
### Changed
- Optimized the annualized performance calculation
- Changed the data gathering selection from distinct orders to symbol profiles
## 1.52.0 - 11.09.2021
### Added
- Added the annualized performance to the portfolio summary tab on the home page
- Added the Ghostfolio Slack channel to the about page
### Changed
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `3.0.0` to `4.1.0`
### Fixed
- Fixed the sign in with fingerprint for some android devices
## 1.51.0 - 11.09.2021
### Changed
- Provided the name in the portfolio position endpoint
## 1.50.0 - 11.09.2021
### Fixed
- Fixed the _Fear & Greed Index_ (market mood)
- Fixed the overlap of the home button with tabs on iOS (_Add to Home Screen_)
## 1.49.0 - 08.09.2021
### Added
- Added labels to the allocation chart by symbol on desktop
## 1.48.0 - 07.09.2021
### Added
- Added the attribute `precision` in the value component
### Fixed
- Hid the performance in the _Presenter View_
## 1.47.1 - 06.09.2021
### Fixed
- Fixed the search functionality for cryptocurrency symbols
## 1.46.0 - 05.09.2021
### Added
- Extended the statistics section on the about page by the _GitHub_ contributors count
- Set up _Storybook_
- Added a story for the logo component
- Added a story for the no transactions info component
- Added a story for the trend indicator component
- Added a story for the value component
### Changed
- Switched from gross to net performance
- Restructured the portfolio summary tab on the home page (fees and net performance)
## 1.45.0 - 04.09.2021
### Added
- Added a link below the holdings to manage the transactions
- Added the allocation chart by symbol
### Changed
- Restructured the allocations page
- Upgraded `angular` from version `12.0.4` to `12.2.4`
- Upgraded `@angular/cdk` and `@angular/material` from version `12.0.6` to `12.2.4`
- Upgraded `Nx` from version `12.5.4` to `12.8.0`
- Upgraded `prisma` from version `2.24.1` to `2.30.2`
### Fixed
- Fixed the value formatting for integers (transactions count)
## 1.44.0 - 30.08.2021
### Changed
- Extended the sub classification of assets by cash
- Upgraded `svgmap` from version `2.1.1` to `2.6.0`
### Fixed
- Filtered out positions without any quantity in the positions table
- Improved the symbol lookup: allow saving with valid symbol in create or edit transaction dialog
## 1.43.0 - 24.08.2021
### Added
- Extended the data management of symbol profile data by countries (automated for stocks)
- Added a fallback for initially loading currencies if historical data is not yet available
## 1.42.0 - 22.08.2021
### Added
- Added the subscription type to the users table of the admin control panel
- Introduced the sub classification of assets
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.41.0 - 21.08.2021
### Added
- Added a link to the system status page
### Changed
- Improved the wording for the _Restricted View_: _Presenter View_
- Improved the styling of the tables
- Ignored cash assets in the allocation chart by sector, continent and country
### Fixed
- Fixed an issue in the allocation chart by account (wrong calculation)
- Fixed an issue in the allocation chart by account (missing cash accounts)
## 1.40.0 - 19.08.2021
### Changed
- Improved the fault tolerance of the portfolio details endpoint
### Fixed
- Fixed the node engine version mismatch in `package.json`
- Fixed an issue on the buy date in the position detail dialog
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `GBp` to `GBP`)
## 1.39.0 - 16.08.2021
### Added
- Added an option to hide absolute values like performances and quantities (_Restricted View_)
### Changed
- Restructured the allocations page
### Fixed
- Fixed an issue with the performance in the portfolio summary tab on the home page (impersonation mode)
- Fixed various values in the impersonation mode which have not been nullified
### Removed
- Removed the current net performance
- Removed the read foreign portfolio permission
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.38.0 - 14.08.2021
### Added
- Added the overview menu item on mobile
### Changed
- Refactored the exchange rate service
- Improved the users table in the admin control panel
## 1.37.0 - 13.08.2021
### Added
- Added the calculated net worth to the portfolio summary tab on the home page
- Added the calculated time in market to the portfolio summary tab on the home page
### Changed
- Improved the usability of the tabs on the home page
- Restructured the portfolio summary tab on the home page
- Upgraded `angular-material-css-vars` from version `2.1.0` to `2.1.2`
### Fixed
- Fixed the position detail chart if there are missing historical data around the first buy date
- Fixed the snack bar background color in dark mode
- Fixed the search functionality for symbols (filter for supported currencies)
## 1.36.0 - 09.08.2021
### Changed
- Improved the data gathering handling on server restart
- Respected the cash balance on the allocations page
- Eliminated the name from the scraper configuration
### Fixed
- Fixed hidden cryptocurrency holdings
## 1.35.0 - 08.08.2021
### Changed
- Hid the pagination of tabs
- Improved the classification of assets
- Improved the support for future transactions (drafts)
- Optimized the accounts table for mobile
- Upgraded `chart.js` from version `3.3.2` to `3.5.0`
### Fixed
- Added a fallback if the exchange rate service has not been initialized correctly
### Todo
- Apply data migration (`yarn database:migrate`)
## 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 ## 1.24.0 - 07.07.2021
### Added ### Added

51
Dockerfile Normal file
View File

@ -0,0 +1,51 @@
FROM node:14-alpine as builder
# Build application and add additional files
WORKDIR /ghostfolio
# Only add basic files without the application itself to avoid rebuilding
# layers when files (package.json etc.) have not changed
COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN yarn
# See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js
COPY ./angular.json angular.json
COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js
COPY ./jest.preset.js jest.preset.js
COPY ./jest.config.js jest.config.js
COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps
RUN yarn build:all
# Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# yarn.lock needs to be used to ensure the same versions
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
RUN yarn
COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
# Image to run, copy everything needed from builder
FROM node:14-alpine
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api
EXPOSE 3333
CMD [ "node", "main" ]

View File

@ -1,13 +1,22 @@
<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://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</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">
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow"> <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> <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">
@ -15,7 +24,17 @@
</p> </p>
</div> </div>
**Ghostfolio** is an open source portfolio tracker built with web technology. The software empowers busy people to have a sharp look of their financial assets and to make solid, data-driven investment decisions. **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 further instructions in the section [Run with Docker](#run-with-docker).
## Why Ghostfolio? ## Why Ghostfolio?
@ -43,7 +62,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ Multi account management
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`) - ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Dark Mode - ✅ Dark Mode
@ -62,13 +81,52 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com). The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Getting Started ## Run with Docker
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
### Setup Docker Image
Run the following commands to build and start the Docker image:
```bash
docker-compose -f docker/docker-compose-build-local.yml build
docker-compose -f docker/docker-compose-build-local.yml up
```
### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
```
### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
### Migrate Database
With the following command you can keep your database schema in sync after a Ghostfolio version update:
```bash
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
```
## Development
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+) - [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- [Docker](https://www.docker.com/products/docker-desktop)
### Setup ### Setup
@ -76,27 +134,27 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
1. Run `cd docker` 1. Run `cd docker`
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `cd -` to go back to the project root directory 1. Run `cd -` to go back to the project root directory
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data 1. Run `yarn database:setup` 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 click _Gather All Data_ to fetch historical data 1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
## Development ### Start Server
Please make sure you have completed the instructions from [_Setup_](#Setup).
### Start server
<ol type="a"> <ol type="a">
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li> <li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
<li>Serve: Run <code>yarn start:server</code></li> <li>Serve: Run <code>yarn start:server</code></li>
</ol> </ol>
### Start client ### Start Client
Run `yarn start:client` Run `yarn start:client`
### Start _Storybook_
Run `yarn start:storybook`
## Testing ## Testing
Run `yarn test` Run `yarn test`
@ -105,7 +163,7 @@ Run `yarn test`
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. 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. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), 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

View File

@ -6,13 +6,16 @@
"defaultProject": "api", "defaultProject": "api",
"schematics": { "schematics": {
"@nrwl/angular:application": { "@nrwl/angular:application": {
"linter": "eslint",
"unitTestRunner": "jest", "unitTestRunner": "jest",
"e2eTestRunner": "cypress" "e2eTestRunner": "cypress"
}, },
"@nrwl/angular:library": { "@nrwl/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest" "unitTestRunner": "jest"
}, },
"@nrwl/nest": {} "@nrwl/nest": {},
"@nrwl/angular:component": {}
}, },
"projects": { "projects": {
"api": { "api": {
@ -32,6 +35,7 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"generatePackageJson": true,
"optimization": true, "optimization": true,
"extractLicenses": true, "extractLicenses": true,
"inspect": false, "inspect": false,
@ -96,11 +100,16 @@
{ {
"glob": "CHANGELOG.md", "glob": "CHANGELOG.md",
"input": "", "input": "",
"output": "./" "output": "./assets"
}, },
{ {
"glob": "LICENSE", "glob": "LICENSE",
"input": "", "input": "",
"output": "./assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./" "output": "./"
}, },
{ {
@ -234,6 +243,90 @@
} }
} }
} }
},
"ui": {
"projectType": "library",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "libs/ui",
"sourceRoot": "libs/ui/src",
"prefix": "gf",
"architect": {
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"],
"options": {
"jestConfig": "libs/ui/jest.config.js",
"passWithNoTests": true
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
}
},
"storybook": {
"builder": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/angular",
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"builder": "@nrwl/storybook:build",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/angular",
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
},
"ui-e2e": {
"root": "apps/ui-e2e",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/ui-e2e/cypress.json",
"devServerTarget": "ui:storybook",
"tsConfig": "apps/ui-e2e/tsconfig.json"
},
"configurations": {
"ci": {
"devServerTarget": "ui:storybook:ci"
}
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
}
}
}
} }
} }
} }

View File

@ -1,10 +1,29 @@
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types'; import {
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessModule } from './access.module';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {
@ -20,13 +39,69 @@ export class AccessController {
include: { include: {
GranteeUser: true GranteeUser: true
}, },
orderBy: { granteeUserId: 'asc' },
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
return accessesWithGranteeUser.map((access) => { return accessesWithGranteeUser.map((access) => {
if (access.GranteeUser) {
return {
granteeAlias: access.GranteeUser?.alias,
id: access.id,
type: 'RESTRICTED_VIEW'
};
}
return { return {
granteeAlias: access.GranteeUser.alias granteeAlias: 'Public',
id: access.id,
type: 'PUBLIC'
}; };
}); });
} }
@Post()
@UseGuards(AuthGuard('jwt'))
public async createAccess(
@Body() data: CreateAccessDto
): Promise<AccessModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.createAccess
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accessService.createAccess({
User: { connect: { id: this.request.user.id } }
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAccess
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accessService.deleteAccess({
id_userId: {
id,
userId: this.request.user.id
}
});
}
} }

View File

@ -5,8 +5,9 @@ import { AccessController } from './access.controller';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
@Module({ @Module({
imports: [],
controllers: [AccessController], controllers: [AccessController],
exports: [AccessService],
imports: [],
providers: [AccessService, PrismaService] providers: [AccessService, PrismaService]
}) })
export class AccessModule {} export class AccessModule {}

View File

@ -1,11 +1,22 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Access, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class AccessService { export class AccessService {
public constructor(private prisma: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async access(
accessWhereInput: Prisma.AccessWhereInput
): Promise<AccessWithGranteeUser | null> {
return this.prismaService.access.findFirst({
include: {
GranteeUser: true
},
where: accessWhereInput
});
}
public async accesses(params: { public async accesses(params: {
include?: Prisma.AccessInclude; include?: Prisma.AccessInclude;
@ -17,7 +28,7 @@ export class AccessService {
}): Promise<AccessWithGranteeUser[]> { }): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.access.findMany({ return this.prismaService.access.findMany({
cursor, cursor,
include, include,
orderBy, orderBy,
@ -26,4 +37,18 @@ export class AccessService {
where where
}); });
} }
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
return this.prismaService.access.create({
data
});
}
public async deleteAccess(
where: Prisma.AccessWhereUniqueInput
): Promise<Access> {
return this.prismaService.access.delete({
where
});
}
} }

View File

@ -0,0 +1 @@
export class CreateAccessDto {}

View File

@ -1,3 +1,4 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { import {
@ -5,7 +6,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -33,7 +34,8 @@ export class AccountController {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -84,25 +86,22 @@ export class AccountController {
public async getAllAccounts( public async getAllAccounts(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> { ): Promise<AccountModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId( const impersonationUserId =
impersonationId, await this.impersonationService.validateImpersonationId(
this.request.user.id impersonationId,
this.request.user.id
);
let accounts = await this.accountService.getAccounts(
impersonationUserId || this.request.user.id
); );
let accounts = await this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: impersonationUserId || this.request.user.id }
});
if ( if (
impersonationUserId && impersonationUserId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
accounts = nullifyValuesInObjects(accounts, [ accounts = nullifyValuesInObjects(accounts, [
'balance',
'fee', 'fee',
'quantity', 'quantity',
'unitPrice' 'unitPrice'

View File

@ -1,32 +1,26 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { AccountController } from './account.controller'; import { AccountController } from './account.controller';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
RedisCacheModule,
PrismaModule,
UserModule
],
controllers: [AccountController], controllers: [AccountController],
providers: [ providers: [AccountService]
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class AccountModule {} export class AccountModule {}

View File

@ -1,22 +1,21 @@
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 { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Account, Currency, Order, Prisma } from '@prisma/client'; import { Account, Order, Platform, Prisma } from '@prisma/client';
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 exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService, private readonly prismaService: PrismaService
private prisma: PrismaService
) {} ) {}
public async account( public async account(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
): Promise<Account | null> { ): Promise<Account | null> {
return this.prisma.account.findUnique({ return this.prismaService.account.findUnique({
where: accountWhereUniqueInput where: accountWhereUniqueInput
}); });
} }
@ -29,7 +28,7 @@ export class AccountService {
Order?: Order[]; Order?: Order[];
} }
> { > {
return this.prisma.account.findUnique({ return this.prismaService.account.findUnique({
include: accountInclude, include: accountInclude,
where: accountWhereUniqueInput where: accountWhereUniqueInput
}); });
@ -42,10 +41,15 @@ export class AccountService {
cursor?: Prisma.AccountWhereUniqueInput; cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput; where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput; orderBy?: Prisma.AccountOrderByInput;
}): Promise<Account[]> { }): Promise<
(Account & {
Order?: Order[];
Platform?: Platform;
})[]
> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.account.findMany({ return this.prismaService.account.findMany({
cursor, cursor,
include, include,
orderBy, orderBy,
@ -55,7 +59,44 @@ export class AccountService {
}); });
} }
public async calculateCashBalance(aUserId: string, aCurrency: Currency) { public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
): Promise<Account> {
return this.prismaService.account.create({
data
});
}
public async deleteAccount(
where: Prisma.AccountWhereUniqueInput,
aUserId: string
): Promise<Account> {
return this.prismaService.account.delete({
where
});
}
public async getAccounts(aUserId: string) {
const accounts = await this.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: aUserId }
});
return accounts.map((account) => {
const result = { ...account, transactionCount: account.Order.length };
delete result.Order;
return result;
});
}
public async getCashDetails(
aUserId: string,
aCurrency: string
): Promise<CashDetails> {
let totalCashBalance = 0; let totalCashBalance = 0;
const accounts = await this.accounts({ const accounts = await this.accounts({
@ -70,27 +111,7 @@ export class AccountService {
); );
}); });
return totalCashBalance; return { accounts, balance: totalCashBalance };
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
): Promise<Account> {
return this.prisma.account.create({
data
});
}
public async deleteAccount(
where: Prisma.AccountWhereUniqueInput,
aUserId: string
): Promise<Account> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
return this.prisma.account.delete({
where
});
} }
public async updateAccount( public async updateAccount(
@ -101,7 +122,7 @@ export class AccountService {
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
const { data, where } = params; const { data, where } = params;
return this.prisma.account.update({ return this.prismaService.account.update({
data, data,
where where
}); });

View File

@ -1,4 +1,4 @@
import { AccountType, Currency } from '@prisma/client'; import { AccountType } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto { export class CreateAccountDto {
@ -9,7 +9,7 @@ export class CreateAccountDto {
balance: number; balance: number;
@IsString() @IsString()
currency: Currency; currency: string;
@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,4 +1,4 @@
import { AccountType, Currency } from '@prisma/client'; import { AccountType } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto { export class UpdateAccountDto {
@ -9,7 +9,7 @@ export class UpdateAccountDto {
balance: number; balance: number;
@IsString() @IsString()
currency: Currency; currency: string;
@IsString() @IsString()
id: string; id: string;

View File

@ -5,7 +5,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -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,31 +1,25 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
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 { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
@Module({ @Module({
imports: [], imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
SubscriptionModule
],
controllers: [AdminController], controllers: [AdminController],
providers: [ providers: [AdminService],
AdminService, exports: [AdminService]
AlphaVantageService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -1,84 +1,58 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { baseCurrency } from '@ghostfolio/common/config';
import { AdminData } from '@ghostfolio/common/interfaces'; import { AdminData } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private exchangeRateDataService: ExchangeRateDataService, private readonly configurationService: ConfigurationService,
private prisma: PrismaService private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {} ) {}
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
return { return {
exchangeRates: [ exchangeRates: this.exchangeRateDataService
{ .getCurrencies()
label1: Currency.EUR, .filter((currency) => {
label2: Currency.CHF, return currency !== baseCurrency;
value: await this.exchangeRateDataService.toCurrency( })
1, .map((currency) => {
Currency.EUR, return {
Currency.CHF label1: baseCurrency,
) label2: currency,
}, value: this.exchangeRateDataService.toCurrency(
{ 1,
label1: Currency.GBP, baseCurrency,
label2: Currency.CHF, currency
value: await this.exchangeRateDataService.toCurrency( )
1, };
Currency.GBP, }),
Currency.CHF
)
},
{
label1: Currency.USD,
label2: Currency.CHF,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.USD,
Currency.CHF
)
},
{
label1: Currency.USD,
label2: Currency.EUR,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.USD,
Currency.EUR
)
},
{
label1: Currency.USD,
label2: Currency.GBP,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.USD,
Currency.GBP
)
}
],
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
transactionCount: await this.prisma.order.count(), transactionCount: await this.prismaService.order.count(),
userCount: await this.prisma.user.count(), userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics() users: await this.getUsersWithAnalytics()
}; };
} }
private async getLastDataGathering() { private async getLastDataGathering() {
const lastDataGathering = await this.prisma.property.findUnique({ const lastDataGathering =
where: { key: 'LAST_DATA_GATHERING' } await this.dataGatheringService.getLastDataGathering();
});
if (lastDataGathering?.value) { if (lastDataGathering) {
return new Date(lastDataGathering.value); return lastDataGathering;
} }
const dataGatheringInProgress = await this.prisma.property.findUnique({ const dataGatheringInProgress =
where: { key: 'LOCKED_DATA_GATHERING' } await this.dataGatheringService.getIsInProgress();
});
if (dataGatheringInProgress) { if (dataGatheringInProgress) {
return 'IN_PROGRESS'; return 'IN_PROGRESS';
@ -87,8 +61,8 @@ export class AdminService {
return null; return null;
} }
private async getUsersWithAnalytics() { private async getUsersWithAnalytics(): Promise<AdminData['users']> {
return await this.prisma.user.findMany({ const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy: { orderBy: {
Analytics: { Analytics: {
updatedAt: 'desc' updatedAt: 'desc'
@ -106,7 +80,8 @@ export class AdminService {
} }
}, },
createdAt: true, createdAt: true,
id: true id: true,
Subscription: true
}, },
take: 30, take: 30,
where: { where: {
@ -115,5 +90,30 @@ export class AdminService {
} }
} }
}); });
return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration;
const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.subscriptionService.getSubscription(Subscription)
: undefined;
return {
alias,
createdAt,
engagement,
id,
subscription,
accountCount: _count.Account || 0,
lastActivity: Analytics.updatedAt,
transactionCount: _count.Order || 0
};
}
);
} }
} }

View File

@ -1,12 +1,12 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Controller } from '@nestjs/common'; import { Controller } from '@nestjs/common';
import { PrismaService } from '../services/prisma.service';
import { RedisCacheService } from './redis-cache/redis-cache.service'; import { RedisCacheService } from './redis-cache/redis-cache.service';
@Controller() @Controller()
export class AppController { export class AppController {
public constructor( public constructor(
private prisma: PrismaService, private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService
) { ) {
this.initialize(); this.initialize();
@ -15,17 +15,12 @@ export class AppController {
private async initialize() { private async initialize() {
this.redisCacheService.reset(); this.redisCacheService.reset();
const isDataGatheringLocked = await this.prisma.property.findUnique({ const isDataGatheringInProgress =
where: { key: 'LOCKED_DATA_GATHERING' } await this.dataGatheringService.getIsInProgress();
});
if (!isDataGatheringLocked) { if (isDataGatheringInProgress) {
// Prepare for automatical data gather if not locked // Prepare for automatical data gathering, if hung up in progress state
await this.prisma.property.deleteMany({ await this.dataGatheringService.reset();
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
} }
} }
} }

View File

@ -1,21 +1,18 @@
import { join } from 'path'; import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module'; import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.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';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { ConfigurationService } from '../services/configuration.service';
import { CronService } from '../services/cron.service';
import { DataGatheringService } from '../services/data-gathering.service';
import { DataProviderService } from '../services/data-provider.service';
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { PrismaService } from '../services/prisma.service';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
@ -23,10 +20,11 @@ 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 { 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 { SubscriptionModule } from './subscription/subscription.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';
@ -40,10 +38,17 @@ import { UserModule } from './user/user.module';
AuthModule, AuthModule,
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ExperimentalModule, ExperimentalModule,
ExportModule,
ImportModule,
InfoModule, InfoModule,
OrderModule, OrderModule,
PortfolioModule, PortfolioModule,
PrismaModule,
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
@ -65,17 +70,6 @@ import { UserModule } from './user/user.module';
UserModule UserModule
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [CronService]
AlphaVantageService,
ConfigurationService,
CronService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class AppModule {} export class AppModule {}

View File

@ -4,7 +4,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Delete, Delete,

View File

@ -7,13 +7,13 @@ import { AuthDevice, Prisma } from '@prisma/client';
export class AuthDeviceService { export class AuthDeviceService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private prisma: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async authDevice( public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice | null> { ): Promise<AuthDevice | null> {
return this.prisma.authDevice.findUnique({ return this.prismaService.authDevice.findUnique({
where where
}); });
} }
@ -26,7 +26,7 @@ export class AuthDeviceService {
orderBy?: Prisma.AuthDeviceOrderByInput; orderBy?: Prisma.AuthDeviceOrderByInput;
}): Promise<AuthDevice[]> { }): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prisma.authDevice.findMany({ return this.prismaService.authDevice.findMany({
skip, skip,
take, take,
cursor, cursor,
@ -38,7 +38,7 @@ export class AuthDeviceService {
public async createAuthDevice( public async createAuthDevice(
data: Prisma.AuthDeviceCreateInput data: Prisma.AuthDeviceCreateInput
): Promise<AuthDevice> { ): Promise<AuthDevice> {
return this.prisma.authDevice.create({ return this.prismaService.authDevice.create({
data data
}); });
} }
@ -49,7 +49,7 @@ export class AuthDeviceService {
}): Promise<AuthDevice> { }): Promise<AuthDevice> {
const { data, where } = params; const { data, where } = params;
return this.prisma.authDevice.update({ return this.prismaService.authDevice.update({
data, data,
where where
}); });
@ -58,7 +58,7 @@ export class AuthDeviceService {
public async deleteAuthDevice( public async deleteAuthDevice(
where: Prisma.AuthDeviceWhereUniqueInput where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice> { ): Promise<AuthDevice> {
return this.prisma.authDevice.delete({ return this.prismaService.authDevice.delete({
where where
}); });
} }

View File

@ -62,10 +62,10 @@ export class AuthController {
} }
} }
@Get('webauthn/generate-attestation-options') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async generateAttestationOptions() { public async generateRegistrationOptions() {
return this.webAuthService.generateAttestationOptions(); return this.webAuthService.generateRegistrationOptions();
} }
@Post('webauthn/verify-attestation') @Post('webauthn/verify-attestation')

View File

@ -1,11 +1,12 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.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 { 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';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { UserService } from '../user/user.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy'; import { GoogleStrategy } from './google.strategy';
@ -17,7 +18,8 @@ import { JwtStrategy } from './jwt.strategy';
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}) }),
SubscriptionModule
], ],
providers: [ providers: [
AuthDeviceService, AuthDeviceService,

View File

@ -1,8 +1,8 @@
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 { Injectable, InternalServerErrorException } from '@nestjs/common'; import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { UserService } from '../user/user.service';
import { ValidateOAuthLoginParams } from './interfaces/interfaces'; import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@Injectable() @Injectable()

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
import { Strategy } from 'passport-google-oauth20'; import { Strategy } from 'passport-google-oauth20';
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
}; };
done(null, user); done(null, user);
} catch (err) { } catch (error) {
console.error(err); Logger.error(error);
done(err, false); done(error, false);
} }
} }
} }

View File

@ -1,16 +1,15 @@
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 { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserService } from '../user/user.service';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
public constructor( public constructor(
readonly configurationService: ConfigurationService, readonly configurationService: ConfigurationService,
private prisma: PrismaService, private readonly prismaService: PrismaService,
private readonly userService: UserService private readonly userService: UserService
) { ) {
super({ super({
@ -24,7 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
const user = await this.userService.user({ id }); const user = await this.userService.user({ id });
if (user) { if (user) {
await this.prisma.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } }, create: { User: { connect: { id: user.id } } },
update: { activityCount: { increment: 1 }, updatedAt: new Date() }, update: { activityCount: { increment: 1 }, updatedAt: new Date() },
where: { userId: user.id } where: { userId: user.id }

View File

@ -1,28 +1,29 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
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 { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Inject, Inject,
Injectable, Injectable,
InternalServerErrorException InternalServerErrorException,
Logger
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { import {
GenerateAssertionOptionsOpts, GenerateAuthenticationOptionsOpts,
GenerateAttestationOptionsOpts, GenerateRegistrationOptionsOpts,
VerifiedAssertion, VerifiedAuthenticationResponse,
VerifiedAttestation, VerifiedRegistrationResponse,
VerifyAssertionResponseOpts, VerifyAuthenticationResponseOpts,
VerifyAttestationResponseOpts, VerifyRegistrationResponseOpts,
generateAssertionOptions, generateAuthenticationOptions,
generateAttestationOptions, generateRegistrationOptions,
verifyAssertionResponse, verifyAuthenticationResponse,
verifyAttestationResponse verifyRegistrationResponse
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { UserService } from '../user/user.service';
import { import {
AssertionCredentialJSON, AssertionCredentialJSON,
AttestationCredentialJSON AttestationCredentialJSON
@ -46,10 +47,10 @@ export class WebAuthService {
return this.configurationService.get('ROOT_URL'); return this.configurationService.get('ROOT_URL');
} }
public async generateAttestationOptions() { public async generateRegistrationOptions() {
const user = this.request.user; const user = this.request.user;
const opts: GenerateAttestationOptionsOpts = { const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio', rpName: 'Ghostfolio',
rpID: this.rpID, rpID: this.rpID,
userID: user.id, userID: user.id,
@ -63,7 +64,7 @@ export class WebAuthService {
} }
}; };
const options = generateAttestationOptions(opts); const options = generateRegistrationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -84,27 +85,27 @@ export class WebAuthService {
const user = this.request.user; const user = this.request.user;
const expectedChallenge = user.authChallenge; const expectedChallenge = user.authChallenge;
let verification: VerifiedAttestation; let verification: VerifiedRegistrationResponse;
try { try {
const opts: VerifyAttestationResponseOpts = { const opts: VerifyRegistrationResponseOpts = {
credential, credential,
expectedChallenge, expectedChallenge,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID expectedRPID: this.rpID
}; };
verification = await verifyAttestationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
const { verified, attestationInfo } = verification; const { registrationInfo, verified } = verification;
const devices = await this.deviceService.authDevices({ const devices = await this.deviceService.authDevices({
where: { userId: user.id } where: { userId: user.id }
}); });
if (verified && attestationInfo) { if (registrationInfo && verified) {
const { credentialPublicKey, credentialID, counter } = attestationInfo; const { counter, credentialID, credentialPublicKey } = registrationInfo;
let existingDevice = devices.find( let existingDevice = devices.find(
(device) => device.credentialId === credentialID (device) => device.credentialId === credentialID
@ -115,9 +116,9 @@ export class WebAuthService {
* Add the returned device to the user's list of devices * Add the returned device to the user's list of devices
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialPublicKey, credentialPublicKey,
credentialId: credentialID, credentialId: credentialID,
counter,
User: { connect: { id: user.id } } User: { connect: { id: user.id } }
}); });
} }
@ -138,20 +139,20 @@ export class WebAuthService {
throw new Error('Device not found'); throw new Error('Device not found');
} }
const opts: GenerateAssertionOptionsOpts = { const opts: GenerateAuthenticationOptionsOpts = {
timeout: 60000,
allowCredentials: [ allowCredentials: [
{ {
id: device.credentialId, id: device.credentialId,
type: 'public-key', transports: ['internal'],
transports: ['internal'] type: 'public-key'
} }
], ],
userVerification: 'preferred', rpID: this.rpID,
rpID: this.rpID timeout: 60000,
userVerification: 'preferred'
}; };
const options = generateAssertionOptions(opts); const options = generateAuthenticationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -177,29 +178,29 @@ export class WebAuthService {
const user = await this.userService.user({ id: device.userId }); const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAssertion; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAssertionResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
credential, credential,
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
authenticator: { authenticator: {
credentialID: device.credentialId, credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey, credentialPublicKey: device.credentialPublicKey,
counter: device.counter counter: device.counter
} },
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
}; };
verification = verifyAssertionResponse(opts); verification = verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }
const { verified, assertionInfo } = verification; const { verified, authenticationInfo } = verification;
if (verified) { if (verified) {
device.counter = assertionInfo.newCounter; device.counter = authenticationInfo.newCounter;
await this.deviceService.updateAuthDevice({ await this.deviceService.updateAuthDevice({
data: device, data: device,

View File

@ -1,11 +1,10 @@
import { RequestWithUser } from '@ghostfolio/common/types'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CacheService } from './cache.service';
@Controller('cache') @Controller('cache')
export class CacheController { export class CacheController {
public constructor( public constructor(
@ -21,6 +20,6 @@ export class CacheController {
public async flushCache(): Promise<void> { public async flushCache(): Promise<void> {
this.redisCacheService.reset(); this.redisCacheService.reset();
return this.cacheService.flush(this.request.user.id); return this.cacheService.flush();
} }
} }

View File

@ -1,13 +1,30 @@
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 { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { CacheController } from './cache.controller'; import { CacheController } from './cache.controller';
import { CacheService } from './cache.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule,
SymbolProfileModule
],
controllers: [CacheController], controllers: [CacheController],
providers: [CacheService, PrismaService] providers: [
CacheService,
ConfigurationService,
DataGatheringService,
PrismaService
]
}) })
export class CacheModule {} export class CacheModule {}

View File

@ -1,16 +1,14 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class CacheService { export class CacheService {
public constructor(private prisma: PrismaService) {} public constructor(
private readonly dataGaterhingService: DataGatheringService
) {}
public async flush(aUserId: string): Promise<void> { public async flush(): Promise<void> {
await this.prisma.property.deleteMany({ await this.dataGaterhingService.reset();
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
return; return;
} }

View File

@ -1,9 +1,9 @@
import { Currency, Type } from '@prisma/client'; import { 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()
currency: Currency; currency: string;
@IsISO8601() @IsISO8601()
date: string; date: string;

View File

@ -1,6 +1,7 @@
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 type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -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,31 +1,23 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExperimentalController } from './experimental.controller'; import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service'; import { ExperimentalService } from './experimental.service';
@Module({ @Module({
imports: [], imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule,
PrismaModule
],
controllers: [ExperimentalController], controllers: [ExperimentalController],
providers: [ providers: [AccountService, ExperimentalService]
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
ExperimentalService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
RulesService,
YahooFinanceService
]
}) })
export class ExperimentalModule {} export class ExperimentalModule {}

View File

@ -1,66 +1,23 @@
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/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 { 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 readonly prismaService: PrismaService
private readonly rulesService: RulesService
) {} ) {}
public async getBenchmark(aSymbol: string) { public async getBenchmark(aSymbol: string) {
return this.prisma.marketData.findMany({ return this.prismaService.marketData.findMany({
orderBy: { date: 'asc' }, orderBy: { date: 'asc' },
select: { date: true, marketPrice: true }, select: { date: true, marketPrice: true },
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

@ -1,6 +1,4 @@
import { Currency } from '@prisma/client';
export interface Data { export interface Data {
currency: Currency; currency: string;
value: number; value: number;
} }

View File

@ -0,0 +1,23 @@
import { Export } from '@ghostfolio/common/interfaces';
import type { 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,22 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
PrismaModule,
RedisCacheModule
],
controllers: [ExportController],
providers: [ExportService]
})
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 readonly prismaService: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prismaService.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,11 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Order } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
export class ImportDataDto {
@IsArray()
@Type(() => CreateOrderDto)
@ValidateNested({ each: true })
orders: Order[];
}

View File

@ -0,0 +1,54 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
HttpException,
Inject,
Logger,
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) {
Logger.error(error);
throw new HttpException(
{
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
message: [error.message]
},
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -0,0 +1,24 @@
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
import { ImportService } from './import.service';
@Module({
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
PrismaModule,
RedisCacheModule
],
controllers: [ImportController],
providers: [CacheService, ImportService, OrderService]
})
export class ImportModule {}

View File

@ -0,0 +1,103 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { isSameDay, parseISO } from 'date-fns';
@Injectable()
export class ImportService {
private static MAX_ORDERS_TO_IMPORT = 20;
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService
) {}
public async import({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
await this.validateOrders({ orders, userId });
for (const {
accountId,
currency,
dataSource,
date,
fee,
quantity,
symbol,
type,
unitPrice
} of orders) {
await this.orderService.createOrder({
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
date: parseISO(<string>(<unknown>date)),
User: { connect: { id: userId } }
});
}
}
private async validateOrders({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}) {
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
throw new Error('Too many transactions');
}
const existingOrders = await this.orderService.orders({
orderBy: { date: 'desc' },
where: { userId }
});
for (const [
index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
] of orders.entries()) {
const duplicateOrder = existingOrders.find((order) => {
return (
order.currency === currency &&
order.dataSource === dataSource &&
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
order.fee === fee &&
order.quantity === quantity &&
order.symbol === symbol &&
order.type === type &&
order.unitPrice === unitPrice
);
});
if (duplicateOrder) {
throw new Error(`orders.${index} is a duplicate transaction`);
}
const result = await this.dataProviderService.get([
{ dataSource, symbol }
]);
if (result[symbol] === undefined) {
throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
}
}
}

View File

@ -1,5 +1,11 @@
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 { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -8,12 +14,22 @@ import { InfoService } from './info.service';
@Module({ @Module({
imports: [ imports: [
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}) }),
RedisCacheModule,
SymbolProfileModule
], ],
controllers: [InfoController], controllers: [InfoController],
providers: [ConfigurationService, InfoService, PrismaService] providers: [
ConfigurationService,
DataGatheringService,
InfoService,
PrismaService
]
}) })
export class InfoModule {} export class InfoModule {}

View File

@ -1,32 +1,50 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; 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, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f'; private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
private static CACHE_KEY_STATISTICS = 'STATISTICS';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private jwtService: JwtService, private readonly dataProviderService: DataProviderService,
private prisma: PrismaService private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly redisCacheService: RedisCacheService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const platforms = await this.prisma.platform.findMany({ const info: Partial<InfoItem> = {};
const platforms = await this.prismaService.platform.findMany({
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { id: true, name: true } select: { id: true, name: true }
}); });
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);
} }
@ -37,21 +55,25 @@ export class InfoService {
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: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
statistics: await this.getStatistics(), statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions() subscriptions: await this.getSubscriptions()
}; };
} }
private async countActiveUsers(aDays: number) { private async countActiveUsers(aDays: number) {
return await this.prisma.user.count({ return await this.prismaService.user.count({
orderBy: { orderBy: {
Analytics: { Analytics: {
updatedAt: 'desc' updatedAt: 'desc'
@ -76,6 +98,27 @@ export class InfoService {
}); });
} }
private async countGitHubContributors(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
'GET',
'json',
200,
{
'User-Agent': 'request'
}
);
const contributors = await get();
return contributors?.length;
} catch (error) {
Logger.error(error);
return undefined;
}
}
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const get = bent( const get = bent(
@ -91,12 +134,34 @@ export class InfoService {
const { stargazers_count } = await get(); const { stargazers_count } = await get();
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
return undefined; return undefined;
} }
} }
private async countNewUsers(aDays: number) {
return await this.prismaService.user.count({
orderBy: {
createdAt: 'desc'
},
where: {
AND: [
{
NOT: {
Analytics: null
}
},
{
createdAt: {
gt: subDays(new Date(), aDays)
}
}
]
}
});
}
private getDemoAuthToken() { private getDemoAuthToken() {
return this.jwtService.sign({ return this.jwtService.sign({
id: InfoService.DEMO_USER_ID id: InfoService.DEMO_USER_ID
@ -104,11 +169,10 @@ export class InfoService {
} }
private async getLastDataGathering() { private async getLastDataGathering() {
const lastDataGathering = await this.prisma.property.findUnique({ const lastDataGathering =
where: { key: 'LAST_DATA_GATHERING' } await this.dataGatheringService.getLastDataGathering();
});
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null; return lastDataGathering ?? null;
} }
private async getStatistics() { private async getStatistics() {
@ -116,15 +180,40 @@ export class InfoService {
return undefined; return undefined;
} }
let statistics: Statistics;
try {
statistics = JSON.parse(
await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS)
);
if (statistics) {
return statistics;
}
} catch {}
const activeUsers1d = await this.countActiveUsers(1); const activeUsers1d = await this.countActiveUsers(1);
const activeUsers7d = await this.countActiveUsers(7);
const activeUsers30d = await this.countActiveUsers(30); const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30);
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers(); const gitHubStargazers = await this.countGitHubStargazers();
return { statistics = {
activeUsers1d, activeUsers1d,
activeUsers7d,
activeUsers30d, activeUsers30d,
gitHubStargazers gitHubContributors,
gitHubStargazers,
newUsers30d
}; };
await this.redisCacheService.set(
InfoService.CACHE_KEY_STATISTICS,
JSON.stringify(statistics)
);
return statistics;
} }
private async getSubscriptions(): Promise<Subscription[]> { private async getSubscriptions(): Promise<Subscription[]> {
@ -132,7 +221,7 @@ export class InfoService {
return undefined; return undefined;
} }
const stripeConfig = await this.prisma.property.findUnique({ const stripeConfig = await this.prismaService.property.findUnique({
where: { key: 'STRIPE_CONFIG' } where: { key: 'STRIPE_CONFIG' }
}); });

View File

@ -1,14 +1,14 @@
import { Currency, DataSource, Type } from '@prisma/client'; import { DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator'; import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto { export class CreateOrderDto {
@IsString() @IsString()
accountId: string; accountId: string;
@IsString() @IsString()
currency: Currency; currency: string;
@IsString() @IsEnum(DataSource, { each: true })
dataSource: DataSource; dataSource: DataSource;
@IsISO8601() @IsISO8601()
@ -23,7 +23,7 @@ export class CreateOrderDto {
@IsString() @IsString()
symbol: string; symbol: string;
@IsString() @IsEnum(Type, { each: true })
type: Type; type: Type;
@IsNumber() @IsNumber()

View File

@ -1,3 +1,4 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { import {
@ -5,7 +6,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -34,7 +35,8 @@ export class OrderController {
public constructor( public constructor(
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -52,15 +54,12 @@ export class OrderController {
); );
} }
return this.orderService.deleteOrder( return this.orderService.deleteOrder({
{ id_userId: {
id_userId: { id,
id, userId: this.request.user.id
userId: this.request.user.id }
} });
},
this.request.user.id
);
} }
@Get() @Get()
@ -80,6 +79,11 @@ export class OrderController {
include: { include: {
Platform: true Platform: true
} }
},
SymbolProfile: {
select: {
name: true
}
} }
}, },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
@ -87,11 +91,8 @@ export class OrderController {
}); });
if ( if (
impersonationUserId && impersonationUserId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']); orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
} }
@ -130,33 +131,30 @@ export class OrderController {
const accountId = data.accountId; const accountId = data.accountId;
delete data.accountId; delete data.accountId;
return this.orderService.createOrder( return this.orderService.createOrder({
{ ...data,
...data, Account: {
Account: { connect: {
connect: { id_userId: { id: accountId, userId: this.request.user.id }
id_userId: { id: accountId, userId: this.request.user.id } }
} },
}, date,
date, SymbolProfile: {
SymbolProfile: { connectOrCreate: {
connectOrCreate: { where: {
where: { dataSource_symbol: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
} }
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
} }
}, }
User: { connect: { id: this.request.user.id } }
}, },
this.request.user.id User: { connect: { id: this.request.user.id } }
); });
} }
@Put(':id') @Put(':id')
@ -193,26 +191,23 @@ export class OrderController {
const accountId = data.accountId; const accountId = data.accountId;
delete data.accountId; delete data.accountId;
return this.orderService.updateOrder( return this.orderService.updateOrder({
{ data: {
data: { ...data,
...data, date,
date, Account: {
Account: { connect: {
connect: { id_userId: { id: accountId, userId: this.request.user.id }
id_userId: { id: accountId, userId: this.request.user.id }
}
},
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
} }
} },
User: { connect: { id: this.request.user.id } }
}, },
this.request.user.id where: {
); id_userId: {
id,
userId: this.request.user.id
}
}
});
} }
} }

View File

@ -1,34 +1,28 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheService } from '../cache/cache.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { OrderController } from './order.controller'; import { OrderController } from './order.controller';
import { OrderService } from './order.service'; import { OrderService } from './order.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ImpersonationModule,
PrismaModule,
RedisCacheModule,
UserModule
],
controllers: [OrderController], controllers: [OrderController],
providers: [ providers: [CacheService, OrderService],
AlphaVantageService, exports: [OrderService]
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
ImpersonationService,
OrderService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class OrderModule {} export class OrderModule {}

View File

@ -1,3 +1,4 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
@ -5,22 +6,18 @@ 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 { endOfToday, isAfter } from 'date-fns';
import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
public constructor( public constructor(
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService, private readonly prismaService: PrismaService
private prisma: PrismaService
) {} ) {}
public async order( public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> { ): Promise<Order | null> {
return this.prisma.order.findUnique({ return this.prismaService.order.findUnique({
where: orderWhereUniqueInput where: orderWhereUniqueInput
}); });
} }
@ -35,7 +32,7 @@ export class OrderService {
}): Promise<OrderWithAccount[]> { }): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.order.findMany({ return this.prismaService.order.findMany({
cursor, cursor,
include, include,
orderBy, orderBy,
@ -45,13 +42,10 @@ export class OrderService {
}); });
} }
public async createOrder( public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
data: Prisma.OrderCreateInput, const isDraft = isAfter(data.date as Date, endOfToday());
aUserId: string
): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
if (!isAfter(data.date as Date, endOfToday())) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
@ -62,48 +56,79 @@ export class OrderService {
]); ]);
} }
await this.cacheService.flush(aUserId); this.dataGatheringService.gatherProfileData([
{ dataSource: data.dataSource, symbol: data.symbol }
]);
return this.prisma.order.create({ await this.cacheService.flush();
data
return this.prismaService.order.create({
data: {
...data,
isDraft
}
}); });
} }
public async deleteOrder( public async deleteOrder(
where: Prisma.OrderWhereUniqueInput, where: Prisma.OrderWhereUniqueInput
aUserId: string
): Promise<Order> { ): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`); return this.prismaService.order.delete({
return this.prisma.order.delete({
where where
}); });
} }
public async updateOrder( public getOrders({
params: { includeDrafts = false,
where: Prisma.OrderWhereUniqueInput; userId
data: Prisma.OrderUpdateInput; }: {
}, includeDrafts?: boolean;
aUserId: string userId: string;
): Promise<Order> { }) {
const where: Prisma.OrderWhereInput = { userId };
if (includeDrafts === false) {
where.isDraft = false;
}
return this.orders({
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' }
});
}
public async updateOrder(params: {
where: Prisma.OrderWhereUniqueInput;
data: Prisma.OrderUpdateInput;
}): Promise<Order> {
const { data, where } = params; const { data, where } = params;
this.redisCacheService.remove(`${aUserId}.portfolio`); const isDraft = isAfter(data.date as Date, endOfToday());
// Gather symbol data of order in the background if (!isDraft) {
this.dataGatheringService.gatherSymbols([ // Gather symbol data of order in the background, if not draft
{ this.dataGatheringService.gatherSymbols([
dataSource: <DataSource>data.dataSource, {
date: <Date>data.date, dataSource: <DataSource>data.dataSource,
symbol: <string>data.symbol date: <Date>data.date,
} symbol: <string>data.symbol
]); }
]);
}
await this.cacheService.flush(aUserId); await this.cacheService.flush();
return this.prisma.order.update({ return this.prismaService.order.update({
data, data: {
...data,
isDraft
},
where where
}); });
} }

View File

@ -1,12 +1,12 @@
import { Currency, DataSource, Type } from '@prisma/client'; import { DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class UpdateOrderDto { export class UpdateOrderDto {
@IsString() @IsString()
accountId: string; accountId: string;
@IsString() @IsString()
currency: Currency; currency: string;
@IsString() @IsString()
dataSource: DataSource; dataSource: DataSource;

View File

@ -0,0 +1,125 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
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,
dataSource: DataSource.YAHOO,
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,
dataSource: DataSource.YAHOO,
date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
symbol: symbols[0]
},
{
createdAt: dateRangeEnd,
dataSource: DataSource.YAHOO,
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);
exchangeRateDataService = new ExchangeRateDataService(null, null);
marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize();
currentRateService = new CurrentRateService(
dataProviderService,
exchangeRateDataService,
marketDataService
);
});
it('getValue', async () => {
expect(
await currentRateService.getValue({
currency: 'USD',
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
symbol: 'AMZN',
userCurrency: 'CHF'
})
).toMatchObject({
marketPrice: 1847.839966
});
});
it('getValues', async () => {
expect(
await currentRateService.getValues({
currencies: { AMZN: 'USD' },
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
dateQuery: {
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
},
userCurrency: 'CHF'
})
).toMatchObject([
{
date: undefined,
marketPrice: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPrice: 1847.839966,
symbol: 'AMZN'
}
]);
});
});

View File

@ -0,0 +1,140 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/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 { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
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,
dataSource: this.dataProviderService.getPrimaryDataSource()
}
]);
return {
symbol,
date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
};
}
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,
dataGatheringItems,
dateQuery,
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(dataGatheringItems)
.then((dataResultProvider) => {
const result = [];
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date: today,
marketPrice: this.exchangeRateDataService.toCurrency(
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
0,
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency
),
symbol: dataGatheringItem.symbol
});
}
return result;
})
);
}
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
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,14 @@
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
export interface CurrentPositions {
hasErrors: boolean;
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;
netAnnualizedPerformance: Big;
netPerformance: Big;
netPerformancePercentage: 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,6 @@
export interface GetValueParams {
currency: string;
date: Date;
symbol: string;
userCurrency: string;
}

View File

@ -0,0 +1,10 @@
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DateQuery } from './date-query.interface';
export interface GetValuesParams {
currencies: { [symbol: string]: string };
dataGatheringItems: IDataGatheringItem[];
dateQuery: DateQuery;
userCurrency: string;
}

View File

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

View File

@ -1,8 +1,10 @@
import { Currency } from '@prisma/client'; import { AssetClass, AssetSubClass } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
averagePrice: number; averagePrice: number;
currency: Currency; currency: string;
firstBuyDate: string; firstBuyDate: string;
grossPerformance: number; grossPerformance: number;
grossPerformancePercent: number; grossPerformancePercent: number;
@ -11,6 +13,9 @@ export interface PortfolioPositionDetail {
marketPrice: number; marketPrice: number;
maxPrice: number; maxPrice: number;
minPrice: number; minPrice: number;
name: string;
netPerformance: number;
netPerformancePercent: number;
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number; transactionCount: number;

View File

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

View File

@ -0,0 +1,9 @@
import Big from 'big.js';
export interface TimelinePeriod {
date: string;
grossPerformance: Big;
investment: Big;
netPerformance: 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,13 @@
import { DataSource } from '@prisma/client';
import Big from 'big.js';
export interface TransactionPointSymbol {
currency: string;
dataSource: DataSource;
fee: Big;
firstBuyDate: string;
investment: Big;
quantity: Big;
symbol: string;
transactionCount: number;
}

View File

@ -0,0 +1,6 @@
import { TransactionPointSymbol } from './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 readonly prismaService: PrismaService) {}
public async get({
date,
symbol
}: {
date: Date;
symbol: string;
}): Promise<MarketData> {
return await this.prismaService.marketData.findFirst({
where: {
symbol,
date: resetHours(date)
}
});
}
public async getRange({
dateQuery,
symbols
}: {
dateQuery: DateQuery;
symbols: string[];
}): Promise<MarketData[]> {
return await this.prismaService.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,648 @@
import { OrderType } from '@ghostfolio/api/models/order-type';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common';
import Big from 'big.js';
import {
addDays,
addMonths,
addYears,
differenceInDays,
endOfDay,
format,
isAfter,
isBefore,
max,
min
} from 'date-fns';
import { flatten, isNumber } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
import {
Accuracy,
TimelineSpecification
} from './interfaces/timeline-specification.interface';
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator {
private transactionPoints: TransactionPoint[];
public constructor(
private currentRateService: CurrentRateService,
private currency: string
) {}
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,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
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,
dataSource: order.dataSource,
fee: order.fee,
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 getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
}: {
daysInMarket: number;
netPerformancePercent: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
).minus(1);
}
return new Big(0);
}
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 {
currentValue: new Big(0),
hasErrors: false,
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
netAnnualizedPerformance: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
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 dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: 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,
dataGatheringItems,
dateQuery: {
in: dates
},
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 netHoldingPeriodReturns: { [symbol: string]: Big } = {};
const grossPerformance: { [symbol: string]: Big } = {};
const netPerformance: { [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 lastFees: { [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) {
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol);
hasErrors = true;
Logger.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);
const isFirstOrderAndIsStartBeforeCurrentDate =
i === firstIndex &&
isBefore(parseDate(this.transactionPoints[i].date), start);
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
const fee = isFirstOrderAndIsStartBeforeCurrentDate
? new Big(0)
: item.fee.minus(lastFee);
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;
Logger.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] = (
holdingPeriodReturns[item.symbol] ?? new Big(1)
).mul(holdingPeriodReturn);
grossPerformance[item.symbol] = (
grossPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue));
const netHoldingPeriodReturn = endValue.div(
initialValue.plus(cashFlow).plus(fee)
);
netHoldingPeriodReturns[item.symbol] = (
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
).mul(netHoldingPeriodReturn);
netPerformance[item.symbol] = (
netPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue).minus(fee));
}
lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity;
lastFees[item.symbol] = item.fee;
}
}
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,
dataSource: item.dataSource,
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,
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
netPerformancePercentage:
isValid && netHoldingPeriodReturns[item.symbol]
? netHoldingPeriodReturns[item.symbol].minus(1)
: null,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
}
const overall = this.calculateOverallPerformance(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 calculateOverallPerformance(
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 netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0);
let netAnnualizedPerformance = new Big(0);
// use Date.now() to use the mock for today
const today = new Date(Date.now());
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
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} 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)
);
netAnnualizedPerformance = netAnnualizedPerformance.plus(
this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
today,
parseDate(currentPosition.firstBuyDate)
),
netPerformancePercent: currentPosition.netPerformancePercentage
}).mul(currentInitialValue)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue)
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.error(
`Initial value is missing for symbol ${currentPosition.symbol}`
);
hasErrors = true;
}
}
if (!completeInitialValue.eq(0)) {
grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue);
netPerformancePercentage =
netPerformancePercentage.div(completeInitialValue);
netAnnualizedPerformance =
netAnnualizedPerformance.div(completeInitialValue);
}
return {
currentValue,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netAnnualizedPerformance,
netPerformance,
netPerformancePercentage,
totalInvestment
};
}
private async getTimePeriodForDate(
j: number,
startDate: Date,
endDate: Date
): Promise<TimelinePeriod[]> {
let investment: Big = new Big(0);
let fees: Big = new Big(0);
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
if (j >= 0) {
const currencies: { [name: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency;
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.add(item.investment);
fees = fees.add(item.fee);
}
let marketSymbols: GetValueObject[] = [];
if (dataGatheringItems.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
gte: startDate,
lt: endOfDay(endDate)
},
userCurrency: this.currency
});
} catch (error) {
Logger.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: TimelinePeriod[] = [];
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 grossPerformance = value.minus(investment);
const result = {
grossPerformance,
investment,
value,
date: currentDateAsString,
netPerformance: grossPerformance.minus(fees)
};
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,22 +1,21 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { baseCurrency } from '@ghostfolio/common/config';
import { import {
PortfolioItem, PortfolioDetails,
PortfolioOverview,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition, PortfolioPublicDetails,
PortfolioReport PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
getPermissions, import type { RequestWithUser } from '@ghostfolio/common/types';
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -37,50 +36,54 @@ 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')
export class PortfolioController { export class PortfolioController {
public constructor( public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService,
private portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser,
@Inject(REQUEST) private readonly request: RequestWithUser private readonly userService: UserService
) {} ) {}
@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[]> { @Res() res: Response
let portfolio = await this.portfolioService.findAll(impersonationId); ): Promise<InvestmentItem[]> {
if ( if (
impersonationId && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
!hasPermission( this.request.user.subscription.type === 'Basic'
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
portfolio = portfolio.map((portfolioItem) => { res.status(StatusCodes.FORBIDDEN);
Object.keys(portfolioItem.positions).forEach((symbol) => { return <any>res.json([]);
portfolioItem.positions[symbol].investment =
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;
return portfolioItem;
});
} }
return portfolio; let investments = await this.portfolioService.getInvestments(
impersonationId
);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
investments = investments.map((item) => ({
date: item.date,
investment: item.investment / maxInvestment
}));
}
return <any>res.json(investments);
} }
@Get('chart') @Get('chart')
@ -108,11 +111,8 @@ export class PortfolioController {
} }
if ( if (
impersonationId && impersonationId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
let maxValue = 0; let maxValue = 0;
@ -139,45 +139,37 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<{ [symbol: string]: PortfolioPosition }> { ): Promise<PortfolioDetails> {
let details: { [symbol: string]: PortfolioPosition } = {}; if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
const impersonationUserId = this.request.user.subscription.type === 'Basic'
await this.impersonationService.validateImpersonationId( ) {
impersonationId, res.status(StatusCodes.FORBIDDEN);
this.request.user.id return <any>res.json({ accounts: {}, holdings: {} });
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
);
try {
details = await portfolio.getDetails(range);
} catch (error) {
console.error(error);
res.status(StatusCodes.ACCEPTED);
} }
if (hasNotDefinedValuesInObject(details)) { const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
range
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
res.status(StatusCodes.ACCEPTED); res.status(StatusCodes.ACCEPTED);
} }
if ( if (
impersonationId && impersonationId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
const totalInvestment = Object.values(details) const totalInvestment = Object.values(holdings)
.map((portfolioPosition) => { .map((portfolioPosition) => {
return portfolioPosition.investment; return portfolioPosition.investment;
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
const totalValue = Object.values(details) const totalValue = Object.values(holdings)
.map((portfolioPosition) => { .map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.quantity * portfolioPosition.marketPrice,
@ -187,50 +179,22 @@ export class PortfolioController {
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(details)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.grossPerformance = null; portfolioPosition.grossPerformance = null;
portfolioPosition.investment = portfolioPosition.investment =
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
portfolioPosition.netPerformance = null;
for (const [account, { current, original }] of Object.entries(
portfolioPosition.accounts
)) {
portfolioPosition.accounts[account].current = current / totalValue;
portfolioPosition.accounts[account].original =
original / totalInvestment;
}
portfolioPosition.quantity = null; portfolioPosition.quantity = null;
portfolioPosition.value = portfolioPosition.value / totalValue;
}
for (const [name, { current, original }] of Object.entries(accounts)) {
accounts[name].current = current / totalValue;
accounts[name].original = original / totalInvestment;
} }
} }
return <any>res.json(details); return <any>res.json({ accounts, holdings });
}
@Get('overview')
@UseGuards(AuthGuard('jwt'))
public async getOverview(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioOverview> {
let overview = await this.portfolioService.getOverview(impersonationId);
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
overview = nullifyValuesInObject(overview, [
'cash',
'committedFunds',
'fees',
'totalBuy',
'totalSell'
]);
}
return overview;
} }
@Get('performance') @Get('performance')
@ -240,32 +204,22 @@ export class PortfolioController {
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioPerformance> { ): Promise<PortfolioPerformance> {
const impersonationUserId = const performanceInformation = await this.portfolioService.getPerformance(
await this.impersonationService.validateImpersonationId( impersonationId,
impersonationId, range
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
); );
let performance = await portfolio.getPerformance(range); if (performanceInformation?.hasErrors) {
if (hasNotDefinedValuesInObject(performance)) {
res.status(StatusCodes.ACCEPTED); res.status(StatusCodes.ACCEPTED);
} }
let performance = performanceInformation.performance;
if ( if (
impersonationId && impersonationId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
performance = nullifyValuesInObject(performance, [ performance = nullifyValuesInObject(performance, [
'currentGrossPerformance', 'currentGrossPerformance',
'currentNetPerformance',
'currentValue' 'currentValue'
]); ]);
} }
@ -273,6 +227,125 @@ 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);
}
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
result.positions = result.positions.map((position) => {
return nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'quantity'
]);
});
}
return <any>res.json(result);
}
@Get('public/:accessId')
public async getPublic(
@Param('accessId') accessId,
@Res() res: Response
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
id: access.userId
});
if (!access) {
res.status(StatusCodes.NOT_FOUND);
return <any>res.json({ accounts: {}, holdings: {} });
}
let hasDetails = true;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioService.getDetails(
access.userId,
access.userId
);
const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
holdings: {}
};
const totalValue = Object.values(holdings)
.filter((holding) => {
return holding.assetClass === 'EQUITY';
})
.map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user?.Settings?.currency ?? baseCurrency
);
})
.reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
if (portfolioPosition.assetClass === 'EQUITY') {
portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.allocationCurrent,
countries: hasDetails ? portfolioPosition.countries : [],
currency: portfolioPosition.currency,
name: portfolioPosition.name,
sectors: hasDetails ? portfolioPosition.sectors : [],
value: portfolioPosition.value / totalValue
};
}
}
return <any>res.json(portfolioPublicDetails);
}
@Get('summary')
@UseGuards(AuthGuard('jwt'))
public async getSummary(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> {
let summary = await this.portfolioService.getSummary(impersonationId);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
summary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'fees',
'netWorth',
'totalBuy',
'totalSell'
]);
}
return summary;
}
@Get('position/:symbol') @Get('position/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPosition( public async getPosition(
@ -286,13 +359,15 @@ export class PortfolioController {
if (position) { if (position) {
if ( if (
impersonationId && impersonationId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
position = nullifyValuesInObject(position, ['grossPerformance']); position = nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'quantity'
]);
} }
return position; return position;
@ -307,18 +382,19 @@ export class PortfolioController {
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getReport( public async getReport(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId,
@Res() res: Response
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {
const impersonationUserId = if (
await this.impersonationService.validateImpersonationId( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
impersonationId, this.request.user.subscription.type === 'Basic'
this.request.user.id ) {
); res.status(StatusCodes.FORBIDDEN);
return <any>res.json({ rules: [] });
}
const portfolio = await this.portfolioService.createPortfolio( return <any>(
impersonationUserId || this.request.user.id res.json(await this.portfolioService.getReport(impersonationId))
); );
return await portfolio.getReport();
} }
} }

View File

@ -1,44 +1,42 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
AccessModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
OrderModule,
PrismaModule,
SymbolProfileModule,
UserModule
],
controllers: [PortfolioController], controllers: [PortfolioController],
providers: [ providers: [
AccountService, AccountService,
AlphaVantageService, CurrentRateService,
CacheService, MarketDataService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
OrderService,
PortfolioService, PortfolioService,
PrismaService, RulesService
RakutenRapidApiService,
RulesService,
UserService,
YahooFinanceService
] ]
}) })
export class PortfolioModule {} export class PortfolioModule {}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { Injectable } from '@nestjs/common';
@Injectable()
export class RulesService {
public constructor() {}
public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[],
aUserSettings: { baseCurrency: string }
) {
return aRules
.filter((rule) => {
return rule.getSettings(aUserSettings)?.isActive;
})
.map((rule) => {
const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings));
return { ...evaluationResult, name: rule.getName() };
});
}
}

View File

@ -21,9 +21,9 @@ export class RedisCacheService {
await this.cache.reset(); await this.cache.reset();
} }
public async set(key: string, value: string) { public async set(key: string, value: string, ttlInSeconds?: number) {
await this.cache.set(key, value, { await this.cache.set(key, value, {
ttl: this.configurationService.get('CACHE_TTL') ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
}); });
} }
} }

View File

@ -1,11 +1,12 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Logger,
Post, Post,
Req, Req,
Res, Res,
@ -46,7 +47,7 @@ export class SubscriptionController {
userId: this.request.user.id userId: this.request.user.id
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),

View File

@ -8,6 +8,7 @@ import { SubscriptionService } from './subscription.service';
@Module({ @Module({
imports: [], imports: [],
controllers: [SubscriptionController], controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService] providers: [ConfigurationService, PrismaService, SubscriptionService],
exports: [SubscriptionService]
}) })
export class SubscriptionModule {} export class SubscriptionModule {}

View File

@ -1,7 +1,9 @@
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 { Injectable } from '@nestjs/common'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { addDays } from 'date-fns'; import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
import { addDays, isBefore } from 'date-fns';
import Stripe from 'stripe'; import Stripe from 'stripe';
@Injectable() @Injectable()
@ -10,7 +12,7 @@ export class SubscriptionService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private prisma: PrismaService private readonly prismaService: PrismaService
) { ) {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
@ -68,7 +70,7 @@ export class SubscriptionService {
aCheckoutSessionId aCheckoutSessionId
); );
await this.prisma.subscription.create({ await this.prismaService.subscription.create({
data: { data: {
expiresAt: addDays(new Date(), 365), expiresAt: addDays(new Date(), 365),
User: { User: {
@ -83,7 +85,26 @@ export class SubscriptionService {
description: session.client_reference_id description: session.client_reference_id
}); });
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
}
}
public getSubscription(aSubscriptions: Subscription[]) {
if (aSubscriptions.length > 0) {
const latestSubscription = aSubscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
});
return {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
return {
type: SubscriptionType.Basic
};
} }
} }
} }

View File

@ -1,6 +1,7 @@
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface LookupItem { export interface LookupItem {
currency: string;
dataSource: DataSource; dataSource: DataSource;
name: string; name: string;
symbol: string; symbol: string;

View File

@ -1,7 +1,7 @@
import { Currency, DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem {
currency: Currency; currency: string;
dataSource: DataSource; dataSource: DataSource;
marketPrice: number; marketPrice: number;
} }

View File

@ -1,4 +1,4 @@
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -10,7 +10,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -45,9 +47,28 @@ export class SymbolController {
/** /**
* Must be after /lookup * Must be after /lookup
*/ */
@Get(':symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> { public async getSymbolData(
return this.symbolService.get(symbol); @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<SymbolItem> {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const result = await this.symbolService.get({ dataSource, symbol });
if (!result || isEmpty(result)) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return result;
} }
} }

View File

@ -1,27 +1,14 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
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 { Module } from '@nestjs/common';
import { SymbolController } from './symbol.controller'; import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Module({ @Module({
imports: [], imports: [ConfigurationModule, DataProviderModule, PrismaModule],
controllers: [SymbolController], controllers: [SymbolController],
providers: [ providers: [SymbolService]
AlphaVantageService,
ConfigurationService,
DataProviderService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
SymbolService,
YahooFinanceService
]
}) })
export class SymbolModule {} export class SymbolModule {}

View File

@ -1,8 +1,8 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Currency, DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -11,18 +11,22 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService { export class SymbolService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService private readonly prismaService: PrismaService
) {} ) {}
public async get(aSymbol: string): Promise<SymbolItem> { public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> {
const response = await this.dataProviderService.get([aSymbol]); const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, dataSource, marketPrice } = response[aSymbol]; const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
return { if (dataGatheringItem.dataSource && marketPrice) {
dataSource, return {
marketPrice, currency,
currency: <Currency>(<unknown>currency) marketPrice,
}; dataSource: dataGatheringItem.dataSource
};
}
return undefined;
} }
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> { public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
@ -37,20 +41,33 @@ export class SymbolService {
results.items = items; results.items = items;
// Add custom symbols // Add custom symbols
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations(); const ghostfolioSymbolProfiles =
scraperConfigurations.forEach((scraperConfiguration) => { await this.prismaService.symbolProfile.findMany({
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) { select: {
results.items.push({ currency: true,
dataSource: DataSource.GHOSTFOLIO, dataSource: true,
name: scraperConfiguration.name, name: true,
symbol: scraperConfiguration.symbol symbol: true
}); },
} where: {
}); AND: [
{
dataSource: DataSource.GHOSTFOLIO,
name: {
startsWith: aQuery
}
}
]
}
});
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
results.items.push(ghostfolioSymbolProfile);
}
return results; return results;
} catch (error) { } catch (error) {
console.error(error); Logger.error(error);
throw error; throw error;
} }

View File

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

View File

@ -0,0 +1,3 @@
export interface UserSettings {
isRestrictedView?: boolean;
}

View File

@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class UpdateUserSettingDto {
@IsBoolean()
isRestrictedView?: boolean;
}

View File

@ -1,9 +1,9 @@
import { Currency, ViewMode } from '@prisma/client'; import { ViewMode } from '@prisma/client';
import { IsString } from 'class-validator'; import { IsString } from 'class-validator';
export class UpdateUserSettingsDto { export class UpdateUserSettingsDto {
@IsString() @IsString()
baseCurrency: Currency; baseCurrency: string;
@IsString() @IsString()
viewMode: ViewMode; viewMode: ViewMode;

View File

@ -4,7 +4,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -26,6 +26,8 @@ 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 { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UpdateUserSettingsDto } from './update-user-settings.dto'; import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -78,6 +80,32 @@ export class UserController {
}; };
} }
@Put('setting')
@UseGuards(AuthGuard('jwt'))
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.updateUserSettings
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const userSettings: UserSettings = {
...(<UserSettings>this.request.user.Settings.settings),
...data
};
return await this.userService.updateUserSetting({
userSettings,
userId: this.request.user.id
});
}
@Put('settings') @Put('settings')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) { public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {

View File

@ -1,3 +1,4 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
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';
@ -11,9 +12,11 @@ import { UserService } from './user.service';
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}) }),
SubscriptionModule
], ],
controllers: [UserController], controllers: [UserController],
providers: [ConfigurationService, PrismaService, UserService] providers: [ConfigurationService, PrismaService, UserService],
exports: [UserService]
}) })
export class UserModule {} export class UserModule {}

View File

@ -1,24 +1,26 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.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 { locale } from '@ghostfolio/common/config'; import { baseCurrency, locale } from '@ghostfolio/common/config';
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 { Prisma, Provider, User, ViewMode } from '@prisma/client';
import { isBefore } from 'date-fns';
import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
const crypto = require('crypto'); const crypto = require('crypto');
@Injectable() @Injectable()
export class UserService { export class UserService {
public static DEFAULT_CURRENCY = Currency.USD; public static DEFAULT_CURRENCY = 'USD';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private prisma: PrismaService private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {} ) {}
public async getUser({ public async getUser({
@ -29,7 +31,7 @@ export class UserService {
Settings, Settings,
subscription subscription
}: UserWithSettings): Promise<IUser> { }: UserWithSettings): Promise<IUser> {
const access = await this.prisma.access.findMany({ const access = await this.prismaService.access.findMany({
include: { include: {
User: true User: true
}, },
@ -50,6 +52,7 @@ export class UserService {
}), }),
accounts: Account, accounts: Account,
settings: { settings: {
...(<UserSettings>Settings.settings),
locale, locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
@ -57,10 +60,14 @@ export class UserService {
}; };
} }
public isRestrictedView(aUser: UserWithSettings) {
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
}
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
const userFromDatabase = await this.prisma.user.findUnique({ const userFromDatabase = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true }, include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput where: userWhereUniqueInput
}); });
@ -84,6 +91,7 @@ export class UserService {
// Set default settings if needed // Set default settings if needed
userFromDatabase.Settings = { userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY, currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(), updatedAt: new Date(),
userId: userFromDatabase?.id, userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT viewMode: ViewMode.DEFAULT
@ -91,24 +99,9 @@ export class UserService {
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (userFromDatabase?.Subscription?.length > 0) { user.subscription = this.subscriptionService.getSubscription(
const latestSubscription = userFromDatabase.Subscription.reduce( userFromDatabase?.Subscription
(a, b) => { );
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}
);
user.subscription = {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
user.subscription = {
type: SubscriptionType.Basic
};
}
if (user.subscription.type === SubscriptionType.Basic) { if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => { user.permissions = user.permissions.filter((permission) => {
@ -129,7 +122,7 @@ export class UserService {
orderBy?: Prisma.UserOrderByInput; orderBy?: Prisma.UserOrderByInput;
}): Promise<User[]> { }): Promise<User[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prisma.user.findMany({ return this.prismaService.user.findMany({
skip, skip,
take, take,
cursor, cursor,
@ -146,14 +139,20 @@ export class UserService {
} }
public async createUser(data?: Prisma.UserCreateInput): Promise<User> { public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
let user = await this.prisma.user.create({ let user = await this.prismaService.user.create({
data: { data: {
...data, ...data,
Account: { Account: {
create: { create: {
currency: baseCurrency,
isDefault: true, isDefault: true,
name: 'Default Account' name: 'Default Account'
} }
},
Settings: {
create: {
currency: baseCurrency
}
} }
} }
}); });
@ -169,7 +168,7 @@ export class UserService {
process.env.ACCESS_TOKEN_SALT process.env.ACCESS_TOKEN_SALT
); );
user = await this.prisma.user.update({ user = await this.prismaService.user.update({
data: { accessToken: hashedAccessToken }, data: { accessToken: hashedAccessToken },
where: { id: user.id } where: { id: user.id }
}); });
@ -185,46 +184,75 @@ export class UserService {
data: Prisma.UserUpdateInput; data: Prisma.UserUpdateInput;
}): Promise<User> { }): Promise<User> {
const { where, data } = params; const { where, data } = params;
return this.prisma.user.update({ return this.prismaService.user.update({
data, data,
where where
}); });
} }
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> { public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
await this.prisma.access.deleteMany({ await this.prismaService.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] } where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
}); });
await this.prisma.account.deleteMany({ await this.prismaService.account.deleteMany({
where: { userId: where.id } where: { userId: where.id }
}); });
await this.prisma.analytics.delete({ await this.prismaService.analytics.delete({
where: { userId: where.id } where: { userId: where.id }
}); });
await this.prisma.order.deleteMany({ await this.prismaService.order.deleteMany({
where: { userId: where.id } where: { userId: where.id }
}); });
try { try {
await this.prisma.settings.delete({ await this.prismaService.settings.delete({
where: { userId: where.id } where: { userId: where.id }
}); });
} catch {} } catch {}
return this.prisma.user.delete({ return this.prismaService.user.delete({
where where
}); });
} }
public async updateUserSetting({
userId,
userSettings
}: {
userId: string;
userSettings: UserSettings;
}) {
const settings = userSettings as Prisma.JsonObject;
await this.prismaService.settings.upsert({
create: {
settings,
User: {
connect: {
id: userId
}
}
},
update: {
settings
},
where: {
userId: userId
}
});
return;
}
public async updateUserSettings({ public async updateUserSettings({
currency, currency,
userId, userId,
viewMode viewMode
}: UserSettingsParams) { }: UserSettingsParams) {
await this.prisma.settings.upsert({ await this.prismaService.settings.upsert({
create: { create: {
currency, currency,
User: { User: {

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,3 @@
export interface UserSettings {
baseCurrency: string;
}

View File

@ -1,5 +1,4 @@
import { Account, Currency, SymbolProfile } from '@prisma/client'; import { Account, 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';
@ -7,10 +6,11 @@ import { OrderType } from './order-type';
export class Order { export class Order {
private account: Account; private account: Account;
private currency: Currency; private currency: string;
private fee: number; private fee: number;
private date: string; private date: string;
private id: string; private id: string;
private isDraft: boolean;
private quantity: number; private quantity: number;
private symbol: string; private symbol: string;
private symbolProfile: SymbolProfile; private symbolProfile: SymbolProfile;
@ -24,6 +24,7 @@ export class Order {
this.fee = data.fee; this.fee = data.fee;
this.date = data.date; this.date = data.date;
this.id = data.id || uuidv4(); this.id = data.id || uuidv4();
this.isDraft = data.isDraft;
this.quantity = data.quantity; this.quantity = data.quantity;
this.symbol = data.symbol; this.symbol = data.symbol;
this.symbolProfile = data.symbolProfile; this.symbolProfile = data.symbolProfile;
@ -54,7 +55,7 @@ export class Order {
} }
public getIsDraft() { public getIsDraft() {
return isAfter(parseISO(this.date), endOfToday()); return this.isDraft;
} }
public getQuantity() { public getQuantity() {

View File

@ -1,650 +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,
balance: 0,
createdAt: new Date(),
currency: Currency.USD,
id: DEFAULT_ACCOUNT_ID,
isDefault: true,
name: 'Default Account',
platformId: null,
updatedAt: new Date(),
userId: USER_ID
}
],
alias: 'Test',
authChallenge: null,
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([]);
expect(portfolio.getSymbols(new Date())).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,908 +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 { Sector } from '@ghostfolio/common/interfaces/sector.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);
const 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 async addFuturePortfolioItems() {
let investment = this.getInvestment(new Date());
this.getOrders()
.filter((order) => order.getIsDraft() === true)
.forEach((order) => {
investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
const portfolioItem = this.portfolioItems.find((item) => {
return item.date === order.getDate();
});
if (portfolioItem) {
portfolioItem.investment = investment;
} else {
this.portfolioItems.push({
investment,
date: order.getDate(),
grossPerformancePercent: 0,
positions: {},
value: 0
});
}
});
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 [];
}
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[];
let sectorsOfSymbol: Sector[];
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
};
});
sectorsOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.sectors as Prisma.JsonArray) ?? []
).map((sector) => {
const { name, weight } = sector as Prisma.JsonObject;
return {
name: (name as string) ?? 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,
sectors: sectorsOfSymbol,
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() {
const orders = this.getOrders().filter(
(order) => order.getIsDraft() === false
);
if (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
.filter((order) => order.getIsDraft() === false)
.map((order) => {
return order.getSymbol();
});
}
// unique values
return Array.from(new Set(symbols));
}
public getTotalBuy() {
return this.orders
.filter(
(order) => order.getIsDraft() === false && 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.getIsDraft() === false && 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({
positions,
date: yesterday.toISOString(),
grossPerformancePercent: 0,
investment: 0,
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() {
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) => {
if (order.getIsDraft() === false) {
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;
}
}
});
}
}

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