Compare commits

...

328 Commits

Author SHA1 Message Date
c046b4b8f8 Release 1.119.0 2022-02-21 21:00:33 +01:00
02809a529e Reorder (#718) 2022-02-21 20:06:39 +01:00
fd60569716 Release 1.118.0 (#717) 2022-02-20 17:01:06 +01:00
fed771525e Feature/extend market data in admin panel (#716)
* Extend market data view

* Update changelog
2022-02-20 16:57:46 +01:00
a5771f601d Improve calculation of overall performance percentage (#701)
* Improve calculation of overall performance percentage

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-02-20 16:39:43 +01:00
2a2a5f4da5 Feature/display features based on permissions (#714)
* Display features based on permissions

* Update changelog
2022-02-20 14:16:46 +01:00
06d5ec9182 Release 1.117.0 (#713) 2022-02-19 19:48:51 +01:00
122107c8a1 Feature/distinguish today s data point in admin panel (#711)
* Distinguish today's data point

* Update changelog
2022-02-19 19:46:36 +01:00
ca46a9827a Do not tweet on Sunday (#712) 2022-02-19 19:29:49 +01:00
4ec351369b Bugfix/add fallback to default account in import (#709)
* Add fallback to default account if account id is invalid

* Update changelog
2022-02-19 18:51:16 +01:00
dced06ebb5 Bugfix/improve allocations (#710)
* Fix allocations by account for non-unique account names

* Refactor calculations with big.js

* Update changelog
2022-02-19 18:41:12 +01:00
baa6a3d0f0 Feature/restructure api modules (#706)
* Restructure modules

* Update changelog
2022-02-18 19:32:25 +01:00
d3382f0809 Update time (#707) 2022-02-17 21:31:08 +01:00
1eb4041837 Feature/move countries and sectors chart (#704)
* Move countries and sectors charts

* Update changelog
2022-02-17 20:40:28 +01:00
5a869a90da Release 1.116.0 (#703) 2022-02-16 21:23:13 +01:00
280030ae7f Feature/add twitter bot for fear and greed index (#702)
* Add twitter bot for fear and greed index

* Update changelog
2022-02-16 21:17:11 +01:00
52e4504de9 Fix time in market (#700)
* Fix time in market

* Update changelog
2022-02-15 09:47:56 +01:00
20356f6931 Bugfix/fix max items attribute (#698)
* Fix maxItems

* Update changelog
2022-02-15 09:36:55 +01:00
e0bb2b1c78 Feature/improve position detail dialog (#697)
* Improve mobile layout

* Update changelog
2022-02-14 21:20:07 +01:00
ec806be45f Add detached mode (#696) 2022-02-14 18:51:16 +01:00
809ee97f6f Add tests (#693) 2022-02-14 18:50:47 +01:00
893ca83d3a Release 1.115.0 (#695) 2022-02-13 18:14:55 +01:00
23da1bd293 Feature/add feature page (#694)
* Add feature page

* Update changelog
2022-02-13 18:12:59 +01:00
fa66cd5bce Feature/add countries and sectors to position detail dialog (#692)
* Add asset and asset sub class

* Add countries and sectors to position detail dialog

* Update changelog
2022-02-12 11:22:03 +01:00
9344dcd26e Feature/upgrade nx to 13.8.1 (#691)
* Upgrade angular, nx and storybook dependencies

* Update changelog
2022-02-11 10:01:15 +01:00
90ad22cccf Add import and export (#690) 2022-02-11 10:00:49 +01:00
dcc7ef89fe Release/1.114.1 (#689)
* Fix creation of wealth items

* Release 1.114.1
2022-02-10 11:16:25 +01:00
e355847f40 Release 1.114.0 (#688) 2022-02-10 10:33:58 +01:00
76f70598e2 Feature/add support for wealth items (#666)
* Add support for wealth items

* Update changelog
2022-02-10 09:39:10 +01:00
7af5cd244a Release 1.113.0 (#687) 2022-02-09 09:58:46 +01:00
86943a5f5b Feature/harmonize big.js operators (#686)
* Harmonize big.js operators

* Update changelog
2022-02-09 09:36:54 +01:00
6eb4eae4a9 Feature/fix twr performance 2 (#684)
* Fix TWR performance
* Weight holding period returns according to their investment value

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-02-09 09:29:43 +01:00
6ac693dd39 Feature/improve position of currency column (#685)
* Move position of currency column

* Update changelog
2022-02-09 09:25:22 +01:00
e29f7f8976 Release 1.112.1 (#683) 2022-02-06 21:41:51 +01:00
82069da4e2 Bugfix/fix user account creation (#682)
* Fix the user account creation

* Update changelog
2022-02-06 21:40:26 +01:00
07656c6a95 Release 1.112.0 (#681) 2022-02-06 17:18:28 +01:00
16f0743353 Bugfix/fix total value of activities table (#680)
* Fix total value (absolute value)

* Update changelog
2022-02-06 17:14:04 +01:00
9b5ec0c56d Feature/fix twr performance (#679)
* Fix TWR performance

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-02-06 16:54:14 +01:00
8d2fcc6b42 Feature/upgrade prisma to version 3.9.1 (#677)
* Upgrade prisma to version 3.9.1

* Update changelog
2022-02-06 15:47:08 +01:00
e625e55784 Move currency column (#678) 2022-02-06 15:46:14 +01:00
bed3e5aae2 Bugfix/fix horizontal overflow in activities table (#676)
* Fix horizontal overflow in tables

* Update changelog
2022-02-06 15:45:39 +01:00
65bfe52db4 Feature/simplify admin user sign up (#675)
* Simplify admin user sign up

* Update changelog
2022-02-06 09:32:41 +01:00
48b524de5a Feature/add export functionality to position detail dialog (#672)
* Add export functionality to the position detail dialog

* Respect filters in activities export

* Update changelog
2022-02-05 20:26:10 +01:00
67d40333f6 Move currency column (#674) 2022-02-05 10:17:09 +01:00
48f6b8d353 Release 1.111.0 (#671) 2022-02-03 21:00:53 +01:00
f369996912 Bugfix/fix symbol selection of 7d data gathering (#670)
* Fix symbol selection of 7d data gathering

* Update changelog
2022-02-03 20:58:59 +01:00
dc424a86ec Feature/support deleting symbol profile data (#669)
* Add support for deleting symbol profile data

* Update changelog
2022-02-03 20:56:39 +01:00
5d8bde5a70 Feature/access data source and symbol from symbol profile (#668)
* Access dataSource and symbol from SymbolProfile

* Update changelog
2022-02-03 19:21:55 +01:00
16360c0c67 Feature/minor code cleanup (#667)
* Sort imports

* Update changelog
2022-02-02 22:06:34 +01:00
526a6b2030 Release 1.110.0 (#665) 2022-02-02 20:31:23 +01:00
5000e9c79b Feature/update database schema of order (#664)
* Add schema migrations

* Update changelog
2022-02-02 20:29:19 +01:00
161cb82820 Bugfix/fix data source of fear and greed index (#663)
* Encode data source

* Update changelog
2022-02-02 20:07:33 +01:00
fed28f29d1 Release 1.109.0 (#662) 2022-02-01 21:05:14 +01:00
8bd9330acc Feature/improve usability of create or edit transaction dialog (#661)
* Move the fee to the bottom

* Update changelog
2022-02-01 20:35:44 +01:00
155c08d665 Transform data source (#658)
* Transform data source

* Update changelog
2022-02-01 20:35:25 +01:00
b8ad6d6662 Feature/improve import (#657)
* Improve import

* Update changelog

Co-Authored-By: Ronald Konjer <ronaldkonjer@gmail.com>
2022-02-01 19:12:00 +01:00
9d6977e3f7 Feature/support cryptocurrency mina protocol (#659)
* Support Mina Protocol (MINA-USD)

* Update changelog
2022-02-01 10:58:34 +01:00
919b20197f import csv with account name or id (#654)
* import csv with account id
2022-01-29 17:27:33 +01:00
62885ea890 Feature/improve consistent use of symbol with data source (#656)
* Improve the consistent use of symbol with dataSource

* Update changelog
2022-01-29 16:51:37 +01:00
035d8ad9eb Update copyright (#655) 2022-01-29 09:15:11 +01:00
9676f96e97 Release 1.108.0 (#653) 2022-01-27 21:33:59 +01:00
65e151151b Feature/increase fear and greed index to 90 days (#652)
* Increase fear and greed index to 90 days

* Update changelog
2022-01-27 21:01:38 +01:00
5d3bbb8f30 Feature/improve annualized performance (#651)
* Improve annualized performance calculation

* Update changelog
2022-01-27 20:56:20 +01:00
b464fefc57 Release 1.107.0 (#650) 2022-01-24 21:43:37 +01:00
bcb7f5f522 Feature/add feature toggle for new calculation engine (#649)
* Add feature toggle for new calculation engine

* Update changelog
2022-01-24 21:38:59 +01:00
f15b33e950 Portfolio calculator rework (#632)
* Portfolio calculator rework

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-01-24 20:35:13 +01:00
ca64492e77 Bugfix/fix styling in activities table footer (#648)
* Fix styling

* Update changelog
2022-01-24 20:12:54 +01:00
761376d72d Release 1.106.0 (#647) 2022-01-23 17:41:54 +01:00
9c086edffe Feature/extend historical data view in admin control (#646)
* Extend market data view

* Update changelog
2022-01-23 17:02:12 +01:00
585f99e4df Feature/add summary row to activities table (#645)
* Add summary row to activities table

* Update changelog
2022-01-23 11:39:30 +01:00
9d907b5eb5 Bugfix/improve the redirection on logout (#642)
* Improve logout

* Update changelog
2022-01-22 09:38:01 +01:00
ba05f5ba30 Feature/upgrade prisma to version 3.8.1 (#640)
* Upgrade prisma to version 3.8.1

* Update changelog
2022-01-22 09:36:58 +01:00
3261e3ee59 Feature/upgrade stripe dependencies (#641)
* Upgrade stripe dependencies

* Update changelog
2022-01-21 20:30:41 +01:00
5607c6bb52 Update blog url (#639) 2022-01-21 20:07:56 +01:00
1c6050d3e3 Release 1.105.0 (#638) 2022-01-20 21:35:56 +01:00
38f2930ec6 Feature/improve data provider service (#637)
* Improve data provider service

* Update changelog
2022-01-20 21:34:23 +01:00
556be61fff Bugfix/fix unresolved account names in reports (#636)
* Fix unresolved account names

* Update changelog
2022-01-19 21:28:15 +01:00
651b4bcff7 Release 1.104.0 (#631) 2022-01-16 15:45:28 +01:00
0a8d159f78 Bugfix/fix missing symbol profile data connection in import (#630)
* Fix missing symbol profile data connection in import

* Update changelog
2022-01-16 15:31:56 +01:00
1a4109ebaa Bugfix/fix fallback to load currencies directly from data provider (#629)
* Fix fallback

* Update changelog
2022-01-16 13:46:00 +01:00
92e502e1c2 Release 1.103.0 (#628) 2022-01-13 20:33:31 +01:00
e344c43a5a Bugfix/fix currency of value in position detail dialog (#627)
* Fix currency

* Update changelog
2022-01-13 20:25:21 +01:00
d6b78f3457 Feature/add links to statistics section (#626)
* Add links and clean up style

* Update changelog
2022-01-13 19:07:23 +01:00
9bbb856f66 Release 1.102.0 (#625) 2022-01-11 19:53:23 +01:00
d3707bbb87 Bugfix/fix preselected default account in create activity dialog (#624)
* Fix preselected default account

* Update changelog
2022-01-11 19:50:22 +01:00
7df53896f3 Feature/start eliminating data source from order (#622)
* Start eliminating dataSource from order

* Update changelog
2022-01-11 19:49:45 +01:00
b2b3fde80e Bugfix/support multiple accounts with the same name (#623)
* Support multiple accounts with the same name

* Update changelog
2022-01-10 21:23:47 +01:00
a83441b3ba Release 1.101.0 (#621) 2022-01-08 18:21:33 +01:00
075431d868 Feature/add google sheets as data source (#620)
* Add google sheets as data source

* Update changelog
2022-01-08 18:19:25 +01:00
0168c1c4e8 Feature/exclude url pattern of shared portfolios in robots.txt (#619)
* Exclude shared portfolios

* Update changelog
2022-01-08 09:37:54 +01:00
07de8f87fc Set market prices explicitly (#618)
* Set market prices explicitly

* Set comments explicitly
2022-01-07 08:09:12 +01:00
3e16041c16 Release 1.100.0 (#617) 2022-01-05 20:24:34 +01:00
5882b7914d Feature/add first months in open source blog post (#616)
* Add blog post

* Update changelog
2022-01-05 20:22:59 +01:00
69c9e259b1 Bugfix/fix routing of create activity dialog (#615)
* Fix routing of create activity dialog

* Update changelog
2022-01-03 21:31:55 +01:00
aca37a27f9 Feature/add top performers to analysis page (#613)
* Add Top 3 / Bottom 3 performers

* Update changelog
2022-01-02 13:29:45 +01:00
313d2a2f79 Release 1.99.0 (#612) 2022-01-01 16:40:55 +01:00
9ac67b0af2 Feature/expose profile data gathering by symbol endpoint (#611)
* Expose profile data gathering by symbol endpoint

* Update changelog
2022-01-01 16:18:18 +01:00
1e526852a7 Bugfix/fix mapping for russia in trackinsight data enhancer (#610)
* Fix mapping for Russia

* Update changelog
2022-01-01 13:55:53 +01:00
e54638a684 Feature/improve analysis page (#609)
* Improve analysis page (show y-axis, extend chart in relation to days in market)

* Update changelog
2022-01-01 12:09:49 +01:00
0179823ad9 Feature/restructure about page (#608)
* Restructure about page: introduce pages for blog and changelog

* Update changelog
2022-01-01 10:10:37 +01:00
029b7bed9a Bugfix/improve error handling in position api endpoint (#607)
* Add guards

* Update changelog
2021-12-31 22:00:58 +01:00
635f10e2d0 Bugfix/hide data provider warning while loading (#605)
* Hide data provider warning while loading

* Update changelog
2021-12-31 10:21:41 +01:00
cebf879d67 Feature/refactor demo user (#604)
* Refactor demo user id

* Update changelog
2021-12-31 09:52:03 +01:00
124bdc028d Bugfix/fix reload of position detail dialog (#603)
* Fix reload of position detail dialog

* Update changelog
2021-12-31 09:51:30 +01:00
d69a69ce18 Bugfix/fix exception with market state (#602)
* Fix exception with market state

* Update changelog
2021-12-30 22:19:08 +01:00
15344513ce Feature/upgrade chart.js to version 3.7.0 (#601)
* Upgrade chart.js from version 3.5.0 to 3.7.0

* Update changelog
2021-12-30 22:18:39 +01:00
b291d9e031 Feature/refactor transactions to activities table (#600)
* Refactor transactions to activities table with attributes and routes

* Update changelog
2021-12-30 18:56:51 +01:00
bee702302f Upgrade angular and Nx dependencies (#599) 2021-12-30 17:31:07 +01:00
bb56e09a13 Clean up preview feature flag (#598) 2021-12-30 10:15:30 +01:00
0873f539c5 Release 1.98.0 (#597) 2021-12-29 18:40:18 +01:00
6dcd801d05 Feature/extend statistics section with users in slack community (#596)
* Extend statistics with users in Slack community

* Update changelog
2021-12-29 18:38:55 +01:00
77065dac50 Feature/add date range selector to holdings tab (#595)
* Add date range selector to holdings tab

* Update changelog
2021-12-29 18:14:24 +01:00
438484879d Bugfix/fix creation of historical data (#594)
* Fix creation of historical data (upsert instead of update)

* Update changelog
2021-12-29 17:03:37 +01:00
e37a650c70 Bugfix/fix scrolling issue in position detail dialog on mobile (#593)
* Fix scrolling in position detail dialog on mobile

* Update changelog
2021-12-29 10:51:11 +01:00
6e8c90b3fc Release 1.97.0 (#592) 2021-12-28 21:40:10 +01:00
9e1a7fc981 Feature/dividend (#547)
* Add dividend to order type

* Support dividend in transactions table

* Support dividend in transaction dialog

* Extend import file with dividend

* Add dividend to portfolio summary

* Update changelog

Co-authored-by: Fly Man <fly.man.opensim@gmail.com>
2021-12-28 21:12:12 +01:00
ff638adf03 Feature/add transactions to position detail dialog (#591)
* Add transactions to position detail dialog

* Update changelog
2021-12-28 17:45:04 +01:00
fa44cee781 Release 1.96.0 (#590) 2021-12-27 21:08:33 +01:00
db1d474ddf Feature/more discreet data provider warning (#589)
* Upgrade http-status-codes to version 2.2.0

* Make the data provider warning more discreet

* Update changelog
2021-12-27 12:14:41 +01:00
994275e093 Feature/upgrade angular 3rd party dependencies (#588)
* Upgrade angular 3rd party dependencies
  * ngx-device-detector
  * ngx-markdown
  * ngx-stripe

* Update changelog
2021-12-26 21:58:56 +01:00
ee397c8047 Bugfix/fix file type detection for import (#587)
* Fix file type detection ("application/vnd.ms-excel" instead of "text/csv")

* Update changelog
2021-12-26 20:54:53 +01:00
7203939c42 Feature/upgrade prisma to version 3.7.0 (#586)
* Upgrade prisma from version 3.6.0 to 3.7.0

* Update changelog
2021-12-26 17:30:26 +01:00
9725f16c81 Clean up schema.prisma (#584) 2021-12-26 14:33:18 +01:00
bb8b1e4f43 Release 1.95.0 (#583) 2021-12-26 10:14:13 +01:00
9d3610331a Add guard (#582) 2021-12-26 10:07:51 +01:00
0043b44670 Feature/improve data gathering for currencies (#581)
* Improve data gathering for currencies, add warning if it fails

* Update changelog
2021-12-26 09:15:10 +01:00
bbc4e64cb4 Bugfix/filter currencies with null value (#579)
* Filter currencies with null value

* Update changelog
2021-12-25 17:08:56 +01:00
c7f4825499 Release 1.94.0 (#578) 2021-12-25 14:45:58 +01:00
8f583709ef Feature/add support for cosmos and polkadot (#577)
* Add support for cryptocurrencies ATOM and DOT

* Update changelog
2021-12-25 14:23:07 +01:00
4c30212a72 Feature/improve data gathering (#576)
* Eliminate benchmarks to gather

* Optimize 7d data gathering

* Update changelog
2021-12-25 14:18:46 +01:00
cade2f6a5e Feature/upgrade prettier to version 2.5.1 (#575)
* Upgrade prettier from version 2.3.2 to 2.5.1

* Update changelog
2021-12-25 10:29:56 +01:00
3b9a8fabb5 Clean up (#574) 2021-12-24 18:42:30 +01:00
3435b3a348 Feature/make the csv import more flexible (#573)
* Make the csv import more flexible

* Update changelog
2021-12-24 18:21:27 +01:00
5d39b267ab write portfolio calculator test case for symbol BALN.SW (refs #554) (#572) 2021-12-24 17:52:08 +01:00
ffaaa14dba Feature/increase fear and greed index to 30 days (#571)
* Increase Fear & Greed index to 30 days

* Update changelog
2021-12-24 09:40:24 +01:00
c65746d119 Simplify instructions for development setup (#570) 2021-12-22 18:09:00 +01:00
1a6840f1f6 Fix instruction for database setup (#568) 2021-12-21 20:59:01 +01:00
fb7fb886f6 Fix anchor link (#567) 2021-12-21 20:53:15 +01:00
127abb8f4e Release 1.93.0 (#566) 2021-12-21 20:24:24 +01:00
ed1136999a Feature/extend documentation for self hosting (#565)
* Extend documentation for self-hosting

* Add tag "latest" to docker image

* Update changelog
2021-12-21 20:22:58 +01:00
9f545e3e2b Feature/add cryptocurrency solana (#563)
* Add support for Solana and clean up symbol conversion (SOL1USD)

* Update changelog
2021-12-20 21:24:58 +01:00
1602f976f0 Feature/convert errors to warnings in portfolio calculator (#562)
* Convert errors to warnings

* Update changelog
2021-12-20 21:03:12 +01:00
4bf4c1a8a3 Release 1.92.0 (#561) 2021-12-19 16:57:22 +01:00
e78755c280 Replace OrderType with Type (prisma) (#560) 2021-12-19 16:52:35 +01:00
7772684413 Bugfix/fix permission for system status page (#559)
* Fix permission

* Update changelog
2021-12-19 15:55:03 +01:00
955302666e Bugfix/improve redirection on logout (#558)
* Improve redirection on logout

* Update changelog
2021-12-19 13:45:28 +01:00
ddce8cc7f9 Feature/support update of historical data (#557)
* Support update of historical data

* Update changelog
2021-12-19 10:57:50 +01:00
aca0d77e91 Feature/add line chart to historical data view (#555)
* Add line chart

* Update changelog
2021-12-18 16:43:34 +01:00
8b9379f5ce Release 1.91.0 (#553) 2021-12-18 11:41:13 +01:00
0806d0dc92 Bugfix/fix hidden statistics values (#552)
* Fix hidden values (0)

* Update changelog
2021-12-18 11:13:32 +01:00
e518bc3779 Feature/clean up all time high and low from performance endpoint (#551)
* Remove isAllTimeHigh / isAllTimeLow

* Update changelog
2021-12-18 09:20:42 +01:00
eff807dd9a Add tests for yahoo Finance symbol conversion (#550)
* Add tests for yahoo Finance symbol conversion

* Refactoring
2021-12-18 09:04:52 +01:00
155bf67f60 Bugfix/fix symbol conversion from yahoo finance with hyphen (#549)
* Fix symbol conversion from Yahoo Finance

* Update changelog
2021-12-16 18:44:08 +01:00
9aefe3747e Feature/migrate database schema to prisma 3 (#546)
* Add migration after upgrade to prisma 3

* Update changelog
2021-12-15 21:56:29 +01:00
0878febded build(prisma): fix populating database when attributes have null value (#545) 2021-12-15 21:04:26 +01:00
f953c6ea64 Release/1.90.0 (#544)
* Update stage labels

* Release 1.90.0
2021-12-14 20:04:17 +01:00
76dbf78279 implement docker build on tags (#542)
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>

Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-12-14 19:45:55 +01:00
f12866b9ec Update changelog (#543) 2021-12-13 22:31:49 +01:00
83ba5f3d9f Feature/add intro image for dark mode (#540)
* Add intro image for dark mode

* Update changelog
2021-12-13 22:26:16 +01:00
7439c1bf54 fix(custom-cryptocurrencies): add uniswap (uni3) (#541) 2021-12-13 22:25:09 +01:00
e255b76053 Feature/check currency in import validation (#538)
* Check currency in import validation (avoid importing transactions in wrong currency)

* Update changelog
2021-12-13 19:45:23 +01:00
ed7209fb53 Feature/remove transactions import limit (#536)
* Remove default transactions import limit

* Update changelog
2021-12-11 21:44:54 +01:00
008a2ab123 Support arm64 (#465)
* Support arm64

* Move prisma from devDependencies to dependencies (Fix "/bin/sh: prisma: not found" in docker build)

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
2021-12-11 20:58:09 +01:00
2aedd74480 Release 1.89.0 (#534) 2021-12-11 17:23:29 +01:00
11076592d1 Do not log coupon code (#533) 2021-12-11 17:12:24 +01:00
ebee851b23 Feature/support data gathering by symbol and date (#532)
* Support data gathering by symbol and date

* Update changelog
2021-12-11 11:45:13 +01:00
7d3f1832b4 Feature/upgrade nx and storybook dependencies (#531)
* Upgrade dependencies
  * nx
  * storybook

* Update changelog
2021-12-10 19:50:46 +01:00
39e6abfc8c Clean up preview features (#530) 2021-12-10 19:43:53 +01:00
78e0fdb0ca Add coupon system (#529)
* Add coupon system

* Update changelog
2021-12-09 21:14:44 +01:00
606350b2ff Release 1.87.0 (#526) 2021-12-07 20:34:52 +01:00
d09cad4e05 Feature/read only mode (#520)
* Setup read only mode and update permissions dynamically

* Update changelog
2021-12-07 20:24:15 +01:00
069660afe4 Feature/increase fear and greed index to 10 days (#525)
* Increase to 10 days

* Update changelog
2021-12-07 19:10:40 +01:00
4d9a223491 Feature/add system message (#519)
* Add system message

* Update changelog
2021-12-06 20:51:38 +01:00
aed8f5cf04 Feature/upgrade prisma to version 3.6.0 (#518)
* Upgrade prisma from version 2.30.2 to 3.6.0

* Update changelog
2021-12-05 16:52:24 +01:00
1beb4de62f Feature/support additional currencies (#517)
* Support additional currencies

* Update changelog
2021-12-04 21:05:11 +01:00
9bc3505ded Release 1.86.0 (#516) 2021-12-04 11:51:06 +01:00
3e82de6b21 Feature/add historical data chart of fear and greed index (#515)
* Add historical data chart of market mood

* Update changelog
2021-12-04 11:49:00 +01:00
563f354e7e Feature/symbol to uppercase to avoid duplicates (#514)
* Convert the symbol to uppercase to avoid case-sensitive duplicates

* Update changelog
2021-12-04 11:40:12 +01:00
e96e6c717c Feature/enable import by default (#513)
* Enable import by default

* Update changelog
2021-12-04 08:57:22 +01:00
961774ce9f Feature/improve market data detail (#511)
* Improve historical data view (hide invalid and future dates)

* Update changelog
2021-12-03 21:24:05 +01:00
49f46e1a1e Bugfix/improve allocations by currency with cash balances (#508)
* Improve allocations by currency in combination with cash balances

* Update changelog
2021-12-02 21:53:38 +01:00
050c0a4da7 Release 1.85.0 (#506) 2021-12-01 21:20:33 +01:00
4908e6d35d Bugfix/fix data gathering of fear and greed index (#505)
* Fix data gathering of fear and greed index

* Update changelog
2021-12-01 21:18:46 +01:00
fe4013830d Release 1.84.0 (#504) 2021-11-30 21:07:17 +01:00
11be6f630f Feature/expose data gathering by symbol (#503)
* Expose data gathering by symbol as endpoint

* Update changelog
2021-11-30 21:06:10 +01:00
85d123e1b1 Fix colspan (#502) 2021-11-29 21:39:54 +01:00
c5e9804c25 Release 1.83.0 (#501) 2021-11-29 21:17:20 +01:00
1f042ee791 Feature/eliminate redundant storage of historical exchange rates (#500)
* Eliminate redundant storage of historical exchange rates

* Clean up experimental API

* Update changelog
2021-11-29 21:08:58 +01:00
da6eaa0d77 Harmonize error log (#499) 2021-11-29 21:01:53 +01:00
3f31cec859 Release 1.82.0 (#498) 2021-11-28 19:58:00 +01:00
6c07759eb7 Feature/add market data tab to admin control panel (#497)
* Add market data tab

* Update changelog
2021-11-28 19:46:34 +01:00
fcf07a0fd1 Clean up (#496) 2021-11-28 13:26:26 +01:00
2f402c0c8e Feature/introduce tabs with routing to home page (#495)
* Introduce tabs with routing

* Update changelog
2021-11-28 12:52:37 +01:00
a24a094407 Feature/introduce tabs to admin control panel (#494)
* Add tabs

* Update changelog
2021-11-28 12:34:10 +01:00
dc9b2ce194 Release 1.81.0 (#493) 2021-11-27 20:57:32 +01:00
72067459d6 Feature/add value to position detail dialog (#492)
* Add value to position detail dialog

* Update changelog
2021-11-27 09:51:08 +01:00
705441ecf8 Bugfix/fix line chart labels (#491)
* Fix line chart labels

* Fix click event for drafts

* Update changelog
2021-11-26 20:41:44 +01:00
fbd1475402 Feature/upgrade core dependencies (#490)
* Upgrade core dependencies

  * angular
  * nestjs
  * Nx
  * rxjs
  * storybook

* Temporarily fix imports for storybook

* Update changelog
2021-11-25 18:05:02 +01:00
4dc4f13f40 fix storybook after currency changed to string (#488) 2021-11-23 20:58:41 +01:00
3b857aa8bb Release 1.80.0 (#487) 2021-11-23 20:28:50 +01:00
1c2ca5b96b Feature/accentuate all time high and low (#428)
* Accentuate all time high and all time low

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
2021-11-22 21:28:32 +01:00
572bfc59b8 Add guards (#486) 2021-11-22 20:35:25 +01:00
147f0162b7 Release 1.79.0 (#485) 2021-11-21 18:04:28 +01:00
f6acf5207b Feature/add value to positions table (#484)
* Add value column

* Update changelog
2021-11-21 17:55:58 +01:00
80782f1098 add support for euro cryptocurrencies, ALGO and remove unknown crypto… (#480)
* add support for euro cryptocurrencies, ALGO and remove unknown cryptocurrencies from list

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-11-21 17:38:48 +01:00
bc58ee86ca Feature/usability improvements in the create or edit transaction dialog (#483)
* Usability improvements
  * Disable the symbol input in edit mode
  * Filter accounts by type (SECURITIES)

* Update changelog
2021-11-20 20:41:33 +01:00
0cb632b165 Improve wording (#482) 2021-11-20 10:38:30 +01:00
fca3a659d0 Release 1.78.0 (#481) 2021-11-20 10:31:45 +01:00
904dec040e Feature/add testimonial (#479)
* Add testimonials

* Update changelog
2021-11-20 10:28:05 +01:00
fc6c81fe02 Bugfix/fix footer row border in dark mode (#478)
* Fix border color in dark mode

* Update changelog
2021-11-17 23:23:32 +01:00
634171e4e3 Release 1.77.0 (#476) 2021-11-16 21:58:41 +01:00
f8f36e4f4e Bugfix/fix accounts table footer on mobile (#475)
* Fix footer on mobile

* Update changelog
2021-11-16 21:32:04 +01:00
5e7cf9d0b6 Feature/hide get started button on registration page (#474)
* Hide button

* Update changelog
2021-11-15 20:49:03 +01:00
e1932eb5a1 Bugfix/exclude drafts from transaction count (#473)
* Fix transactions count (exclude drafts)

* Improve wording (summary page)

* Update changelog
2021-11-14 19:06:54 +01:00
dba47d59e3 Release 1.76.0 (#472) 2021-11-14 17:16:06 +01:00
3032126508 Feature/add footer row to accounts table (#471)
* Add footer row to accounts table with total balance and value

* Update changelog
2021-11-14 17:04:52 +01:00
a50b55da75 Clean up (#470) 2021-11-14 16:57:46 +01:00
5422df05b3 Release 1.75.0 (#469) 2021-11-13 20:50:02 +01:00
d2fabe7ce4 Feature/add value column to accounts table (#468)
* Add value column

* Update changelog
2021-11-13 20:38:29 +01:00
a42700b9fe Feature/introduce data gathering progress (#467)
* Add data gathering progress

* Update changelog
2021-11-13 11:32:28 +01:00
9df8541145 Feature/log logo on server start (#466)
* Log logo on server start

* Update changelog
2021-11-12 22:50:40 +01:00
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
442 changed files with 21542 additions and 9208 deletions

1
.gitignore vendored
View File

@ -23,6 +23,7 @@
!.vscode/settings.json
# misc
/.angular/cache
/.sass-cache
/connect.lock
/coverage

View File

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

View File

@ -3,9 +3,28 @@ git:
depth: false
node_js:
- 14
before_script:
- yarn
script:
- yarn format:check
- yarn test
- yarn build:all
services:
- docker
cache: yarn
if: (type = pull_request) OR (tag IS present)
jobs:
include:
- stage: Install dependencies
if: type = pull_request
script: yarn --frozen-lockfile
- stage: Check formatting
if: type = pull_request
script: yarn format:check
- stage: Execute tests
if: type = pull_request
script: yarn test
- stage: Build application
if: type = pull_request
script: yarn build:all
- stage: Build and publish docker image
if: tag IS present
script: ./publish-docker-image.sh

3
.vscode/launch.json vendored
View File

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

View File

@ -5,6 +5,871 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.119.0 - 21.02.2022
### Added
- Added a trial for the subscription
## 1.118.0 - 20.02.2022
### Changed
- Improved the calculation of the overall performance percentage in the new calculation engine
- Displayed features in features overview page based on permissions
- Extended the data points of historical data in the admin control panel
## 1.117.0 - 19.02.2022
### Changed
- Moved the countries and sectors charts in the position detail dialog
- Distinguished today's data point of historical data in the admin control panel
- Restructured the server modules
### Fixed
- Fixed the allocations by account for non-unique account names
- Added a fallback to the default account if the `accountId` is invalid in the import functionality for activities
## 1.116.0 - 16.02.2022
### Added
- Added a service to tweet the current _Fear & Greed Index_ (market mood)
### Changed
- Improved the mobile layout of the position detail dialog (countries and sectors charts)
### Fixed
- Fixed the `maxItems` attribute of the portfolio proportion chart component
- Fixed the time in market display of the portfolio summary tab on the home page
## 1.115.0 - 13.02.2022
### Added
- Added a feature overview page
- Added the asset and asset sub class to the position detail dialog
- Added the countries and sectors to the position detail dialog
### Changed
- Upgraded `angular` from version `13.1.2` to `13.2.3`
- Upgraded `Nx` from version `13.4.1` to `13.8.1`
- Upgraded `storybook` from version `6.4.9` to `6.4.18`
## 1.114.1 - 10.02.2022
### Fixed
- Fixed the creation of (wealth) items
## 1.114.0 - 10.02.2022
### Added
- Added support for (wealth) items
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.113.0 - 09.02.2022
### Changed
- Improved the position of the currency column in the accounts table
- Improved the position of the currency column in the activities table
### Fixed
- Fixed an issue with the performance calculation in connection with fees in the new calculation engine
## 1.112.1 - 06.02.2022
### Fixed
- Fixed the creation of the user account (missing access token)
## 1.112.0 - 06.02.2022
### Added
- Added the export functionality to the position detail dialog
### Changed
- Improved the export functionality for activities (respect filtering)
- Removed the _Admin_ user from the database seeding
- Assigned the role `ADMIN` on sign up (only if there is no admin yet)
- Upgraded `prisma` from version `3.8.1` to `3.9.1`
### Fixed
- Fixed an issue with the performance calculation in connection with a sell activity in the new calculation engine
- Fixed the horizontal overflow in the accounts table
- Fixed the horizontal overflow in the activities table
- Fixed the total value of the activities table in the position detail dialog (absolute value)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.111.0 - 03.02.2022
### Added
- Added support for deleting symbol profile data in the admin control panel
### Changed
- Used `dataSource` and `symbol` from `SymbolProfile` instead of the `order` object (in `ExportService` and `PortfolioService`)
### Fixed
- Fixed the symbol selection of the 7d data gathering
## 1.110.0 - 02.02.2022
### Fixed
- Fixed the data source of the _Fear & Greed Index_ (market mood)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.109.0 - 01.02.2022
### Added
- Added support for the (optional) `accountId` in the import functionality for activities
- Added support for the (optional) `dataSource` in the import functionality for activities
- Added support for the data source transformation
- Added support for the cryptocurrency _Mina Protocol_ (`MINA-USD`)
### Changed
- Improved the usability of the form in the create or edit transaction dialog
- Improved the consistent use of `symbol` in combination with `dataSource`
- Removed the primary data source from the client
### Removed
- Removed the unused endpoint `GET api/order/:id`
## 1.108.0 - 27.01.2022
### Changed
- Improved the annualized performance in the new calculation engine
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 90 days
## 1.107.0 - 24.01.2022
### Added
- Added a new calculation engine (experimental)
### Fixed
- Fixed the styling in the footer row of the activities table
## 1.106.0 - 23.01.2022
### Added
- Added the footer row with total fees and total value to the activities table
### Changed
- Extended the historical data view in the admin control panel
- Upgraded _Stripe_ dependencies
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
### Fixed
- Improved the redirection on logout
## 1.105.0 - 20.01.2022
### Added
- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
### Changed
- Improved the data provider with grouping by data source and thereby reducing the number of requests
### Fixed
- Fixed the unresolved account names in the _X-ray_ section
- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
## 1.104.0 - 16.01.2022
### Fixed
- Fixed the fallback to load currencies directly from the data provider
- Fixed the missing symbol profile data connection in the import functionality for activities
## 1.103.0 - 13.01.2022
### Changed
- Added links to the statistics section on the about page
### Fixed
- Fixed the currency of the value in the position detail dialog
## 1.102.0 - 11.01.2022
### Changed
- Start eliminating `dataSource` from activity
### Fixed
- Fixed the support for multiple accounts with the same name
- Fixed the preselected default account of the create activity dialog
## 1.101.0 - 08.01.2022
### Added
- Added `GOOGLE_SHEETS` as a new data source type
### Changed
- Excluded the url pattern of shared portfolios in the `robots.txt` file
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.100.0 - 05.01.2022
### Added
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
- Added a blog post
### Fixed
- Fixed the routing of the create activity dialog
- Fixed the link color in the blog posts
## 1.99.0 - 01.01.2022
### Added
- Exposed the profile data gathering by symbol as an endpoint
### Changed
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
- Restructured the about page
- Start refactoring _transactions_ to _activities_
- Refactored the demo user id
- Upgraded `angular` from version `13.0.2` to `13.1.1`
- Upgraded `chart.js` from version `3.5.0` to `3.7.0`
- Upgraded `Nx` from version `13.3.0` to `13.4.1`
### Fixed
- Hid the data provider warning while loading
- Fixed an exception with the market state caused by a failed data provider request
- Fixed an exception in the portfolio position endpoint
- Fixed the reload of the position detail dialog (with query parameters)
- Fixed the missing mapping for Russia in the data enhancer for symbol profile data via _Trackinsight_
## 1.98.0 - 29.12.2021
### Added
- Added the date range component to the holdings tab
### Changed
- Extended the statistics section on the about page (users in Slack community)
### Fixed
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
- Fixed the scrolling issue in the position detail dialog on mobile
## 1.97.0 - 28.12.2021
### Added
- Added the transactions to the position detail dialog
- Added support for dividend
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.96.0 - 27.12.2021
### Changed
- Made the data provider warning more discreet
- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
- Upgraded `prisma` from version `3.6.0` to `3.7.0`
### Fixed
- Fixed the file type detection in the import functionality for transactions
## 1.95.0 - 26.12.2021
### Added
- Added a warning to the log if the data gathering fails
### Fixed
- Filtered potential `null` currencies
- Improved the 7d data gathering optimization for currencies
## 1.94.0 - 25.12.2021
### Added
- Added support for cryptocurrencies _Cosmos_ (`ATOM-USD`) and _Polkadot_ (`DOT-USD`)
### Changed
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 30 days
- Made the import functionality for transactions by `csv` files more flexible
- Optimized the 7d data gathering (only consider symbols with incomplete market data)
- Upgraded `prettier` from version `2.3.2` to `2.5.1`
## 1.93.0 - 21.12.2021
### Added
- Added support for the cryptocurrency _Solana_ (`SOL-USD`)
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
### Fixed
- Converted errors to warnings in portfolio calculator
## 1.92.0 - 19.12.2021
### Added
- Added a line chart to the historical data view in the admin control panel
- Supported the update of historical data in the admin control panel
### Fixed
- Improved the redirection on logout
- Fixed the permission for the system status page
## 1.91.0 - 18.12.2021
### Changed
- Removed the redundant all time high and all time low from the performance endpoint
### Fixed
- Fixed the symbol conversion from _Yahoo Finance_ including a hyphen
- Fixed hidden values (`0`) in the statistics section on the about page
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.90.0 - 14.12.2021
### Added
- Extended the validation in the import functionality for transactions by checking the currency of the data provider service
- Added support for cryptocurrency _Uniswap_
- Set up pipeline for docker build
### Changed
- Removed the default transactions import limit
- Improved the landing page in dark mode
### Fixed
- Fixed `/bin/sh: prisma: not found` in docker build
- Added `apk` in `Dockerfile` (`python3 g++ make openssl`)
## 1.89.0 - 11.12.2021
### Added
- Extended the data gathering by symbol endpoint with an optional date
### Changed
- Upgraded `Nx` from version `13.2.2` to `13.3.0`
- Upgraded `storybook` from version `6.4.0-rc.3` to `6.4.9`
## 1.88.0 - 09.12.2021
### Added
- Added a coupon system
## 1.87.0 - 07.12.2021
### Added
- Supported the management of additional currencies in the admin control panel
- Introduced the system message
- Introduced the read only mode
### Changed
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 10 days
- Upgraded `prisma` from version `2.30.2` to `3.6.0`
## 1.86.0 - 04.12.2021
### Added
- Added the historical data chart of the _Fear & Greed Index_ (market mood)
### Changed
- Improved the historical data view in the admin control panel (hide invalid and future dates)
- Enabled the import functionality for transactions by default
- Converted the symbols to uppercase to avoid case-sensitive duplicates in the symbol profile model
### Fixed
- Improved the allocations by currency in combination with cash balances
## 1.85.0 - 01.12.2021
### Fixed
- Fixed the data gathering of the _Fear & Greed Index_ (market mood)
## 1.84.0 - 30.11.2021
### Added
- Exposed the data gathering by symbol as an endpoint
## 1.83.0 - 29.11.2021
### Changed
- Removed the experimental API
### Fixed
- Eliminated the redundant storage of historical exchange rates
## 1.82.0 - 28.11.2021
### Added
- Added tabs with routing to the admin control panel
- Added a new tab to manage historical data to the admin control panel
### Changed
- Introduced tabs with routing to the home page
## 1.81.0 - 27.11.2021
### Added
- Added the value to the position detail dialog
### Changed
- Upgraded `angular` from version `12.2.4` to `13.0.2`
- Upgraded `angular-material-css-vars` from version `2.1.2` to `3.0.0`
- Upgraded `nestjs` from version `7.6.18` to `8.2.3`
- Upgraded `Nx` from version `12.8.0` to `13.2.2`
- Upgraded `rxjs` from version `6.6.7` to `7.4.0`
- Upgraded `storybook` from version `6.3.8` to `6.4.0-rc.3`
### Fixed
- Fixed the broken line charts showing value labels if openend from the allocations page
- Fixed the click event for drafts in the transactions table
## 1.80.0 - 23.11.2021
### Added
- Accentuated the all time high and the all time low
## 1.79.0 - 21.11.2021
### Added
- Added the value column to the positions table
- Added support for cryptocurrency _Algorand_
### Changed
- Locked the symbol input in the edit transaction dialog
- Filtered the account selector by account type (`SECURITIES`) in the create or edit transaction dialog
### Fixed
- Fixed the search functionality for cryptocurrency symbols (do not show unsupported symbols)
## 1.78.0 - 20.11.2021
### Added
- Added a testimonial section to the landing page
### Fixed
- Fixed the footer row border of the accounts table in dark mode
## 1.77.0 - 16.11.2021
### Changed
- Hid the _Get Started_ button on the registration page
### Fixed
- Fixed the footer row of the accounts table on mobile
- Fixed the transactions count calculation in the accounts table (exclude drafts)
## 1.76.0 - 14.11.2021
### Added
- Added the footer row with buying power and net worth to the accounts table
## 1.75.0 - 13.11.2021
### Added
- Added a logo to the log on the server start
- Added the data gathering progress to the log and the admin control panel
- Added the value column to the accounts table
## 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
@ -68,7 +933,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo
- Apply data migration (`yarn database:push`)
- Apply data migration (`yarn database:migrate`)
## 1.41.0 - 21.08.2021
@ -121,7 +986,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo
- Apply data migration (`yarn database:push`)
- Apply data migration (`yarn database:migrate`)
## 1.38.0 - 14.08.2021
@ -181,7 +1046,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo
- Apply data migration (`yarn database:push`)
- Apply data migration (`yarn database:migrate`)
## 1.34.0 - 07.08.2021

52
Dockerfile Normal file
View File

@ -0,0 +1,52 @@
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 apk add --no-cache python3 g++ make openssl
RUN yarn install
# 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

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
<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/blog"><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>
<a href="#contributing">
@ -34,28 +34,20 @@
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
## Why Ghostfolio?
Ghostfolio is for you if you are...
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
- 🏦 pursuing a buy & hold strategy
- 🎯 interested in getting insights of your portfolio composition
- 👻 valuing privacy and data ownership
- 🧘 into minimalism
- 🧺 caring about diversifying your financial resources
- 🆓 interested in financial independence
- 🙅 saying no to spreadsheets in 2021
- 😎 still reading this list
## Features
@ -65,6 +57,7 @@ Ghostfolio is for you if you are...
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions
- ✅ Dark Mode
- ✅ Zen Mode
- ✅ Mobile-first design
@ -81,38 +74,87 @@ 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).
## Getting Started
## Run with Docker (self-hosting)
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
### a. Run environment
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
```bash
docker-compose -f docker/docker-compose.yml up -d
```
#### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
### b. Build and run environment
Run the following commands to build and start the Docker images:
```bash
docker-compose -f docker/docker-compose.build.yml build
docker-compose -f docker/docker-compose.build.yml up -d
```
#### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
```
### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
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+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- [Docker](https://www.docker.com/products/docker-desktop)
### Setup
1. Run `yarn install`
1. Run `cd docker`
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 `yarn setup:database` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start the server and the client (see [_Development_](#Development))
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
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_
## Development
Please make sure you have completed the instructions from [_Setup_](#Setup).
### Start server
### Start Server
<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>
</ol>
### Start client
### Start Client
Run `yarn start:client`
@ -128,10 +170,10 @@ 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.
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
© 2021 [Ghostfolio](https://ghostfol.io)
© 2022 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -1,22 +1,5 @@
{
"version": 1,
"cli": {
"defaultCollection": "@nrwl/nest"
},
"defaultProject": "api",
"schematics": {
"@nrwl/angular:application": {
"linter": "eslint",
"unitTestRunner": "jest",
"e2eTestRunner": "cypress"
},
"@nrwl/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest"
},
"@nrwl/nest": {},
"@nrwl/angular:component": {}
},
"projects": {
"api": {
"root": "apps/api",
@ -35,6 +18,7 @@
},
"configurations": {
"production": {
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
@ -68,7 +52,8 @@
},
"outputs": ["coverage/apps/api"]
}
}
},
"tags": []
},
"client": {
"projectType": "application",
@ -99,12 +84,12 @@
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./"
"output": "./assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./"
"output": "./assets"
},
{
"glob": "robots.txt",
@ -200,7 +185,8 @@
},
"outputs": ["coverage/apps/client"]
}
}
},
"tags": []
},
"client-e2e": {
"root": "apps/client-e2e",
@ -220,7 +206,9 @@
}
}
}
}
},
"tags": [],
"implicitDependencies": ["client"]
},
"common": {
"root": "libs/common",
@ -241,7 +229,8 @@
"passWithNoTests": true
}
}
}
},
"tags": []
},
"ui": {
"projectType": "library",
@ -275,7 +264,8 @@
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"projectBuildConfig": "ui:build-storybook"
},
"configurations": {
"ci": {
@ -291,7 +281,8 @@
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"projectBuildConfig": "ui:build-storybook"
},
"configurations": {
"ci": {
@ -299,7 +290,8 @@
}
}
}
}
},
"tags": []
},
"ui-e2e": {
"root": "apps/ui-e2e",
@ -325,7 +317,9 @@
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
}
}
}
},
"tags": [],
"implicitDependencies": ["ui"]
}
}
}

View File

@ -1,10 +1,25 @@
import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { 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 { 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 { CreateAccessDto } from './create-access.dto';
@Controller('access')
export class AccessController {
@ -20,13 +35,63 @@ export class AccessController {
include: {
GranteeUser: true
},
orderBy: { granteeUserId: 'asc' },
where: { userId: this.request.user.id }
});
return accessesWithGranteeUser.map((access) => {
if (access.GranteeUser) {
return {
granteeAlias: access.GranteeUser?.alias,
id: access.id,
type: 'RESTRICTED_VIEW'
};
}
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(this.request.user.permissions, 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(this.request.user.permissions, permissions.deleteAccess)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accessService.deleteAccess({
id_userId: {
id,
userId: this.request.user.id
}
});
}
}

View File

@ -1,12 +1,13 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { AccessController } from './access.controller';
import { AccessService } from './access.service';
@Module({
imports: [],
controllers: [AccessController],
providers: [AccessService, PrismaService]
exports: [AccessService],
imports: [PrismaModule],
providers: [AccessService]
})
export class AccessModule {}

View File

@ -1,19 +1,30 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Access, Prisma } from '@prisma/client';
@Injectable()
export class AccessService {
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: {
include?: Prisma.AccessInclude;
skip?: number;
take?: number;
cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByInput;
orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params;
@ -26,4 +37,18 @@ export class AccessService {
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,12 +1,13 @@
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
nullifyValuesInObject,
nullifyValuesInObjects
} from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -34,6 +35,7 @@ export class AccountController {
public constructor(
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -42,10 +44,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAccount
)
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -85,30 +84,38 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> {
): Promise<Accounts> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accounts = await this.accountService.getAccounts(
impersonationUserId || this.request.user.id
);
let accountsWithAggregations = await this.portfolioServiceStrategy
.get()
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accounts = nullifyValuesInObjects(accounts, [
'balance',
'fee',
'quantity',
'unitPrice'
]);
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalance',
'totalValue'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value'
])
};
}
return accounts;
return accountsWithAggregations;
}
@Get(':id')
@ -128,10 +135,7 @@ export class AccountController {
@Body() data: CreateAccountDto
): Promise<AccountModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.createAccount
)
!hasPermission(this.request.user.permissions, permissions.createAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -168,10 +172,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.updateAccount
)
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -1,3 +1,4 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
@ -11,16 +12,18 @@ import { AccountController } from './account.controller';
import { AccountService } from './account.service';
@Module({
controllers: [AccountController],
exports: [AccountService],
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
RedisCacheModule,
PortfolioModule,
PrismaModule,
RedisCacheModule,
UserModule
],
controllers: [AccountController],
providers: [AccountService]
})
export class AccountModule {}

View File

@ -1,7 +1,7 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Currency, Order, Platform, Prisma } from '@prisma/client';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import { CashDetails } from './interfaces/cash-details.interface';
@ -40,7 +40,7 @@ export class AccountService {
take?: number;
cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput;
orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise<
(Account & {
Order?: Order[];
@ -85,7 +85,15 @@ export class AccountService {
});
return accounts.map((account) => {
const result = { ...account, transactionCount: account.Order.length };
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
transactionCount += 1;
}
}
const result = { ...account, transactionCount };
delete result.Order;
@ -95,7 +103,7 @@ export class AccountService {
public async getCashDetails(
aUserId: string,
aCurrency: Currency
aCurrency: string
): Promise<CashDetails> {
let totalCashBalance = 0;

View File

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

View File

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

View File

@ -1,30 +1,40 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AdminData } from '@ghostfolio/common/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
AdminData,
AdminMarketData,
AdminMarketDataDetails
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client';
import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin')
export class AdminController {
public constructor(
private readonly adminService: AdminService,
private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -33,7 +43,7 @@ export class AdminController {
public async getAdminData(): Promise<AdminData> {
if (
!hasPermission(
getPermissions(this.request.user.role),
this.request.user.permissions,
permissions.accessAdminControl
)
) {
@ -51,7 +61,7 @@ export class AdminController {
public async gatherMax(): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
this.request.user.permissions,
permissions.accessAdminControl
)
) {
@ -72,7 +82,7 @@ export class AdminController {
public async gatherProfileData(): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
this.request.user.permissions,
permissions.accessAdminControl
)
) {
@ -86,4 +96,199 @@ export class AdminController {
return;
}
@Post('gather/profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async gatherProfileDataForSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
return;
}
@Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return;
}
@Post('gather/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<MarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = new Date(dateString);
if (!isDate(date)) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
return this.dataGatheringService.gatherSymbolForDate({
dataSource,
date,
symbol
});
}
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
public async getMarketData(): Promise<AdminMarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketData();
}
@Get('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async update(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = new Date(dateString);
return this.marketDataService.updateMarketData({
data: { ...data, dataSource },
where: {
date_symbol: {
date,
symbol
}
}
});
}
@Delete('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async deleteProfileData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.deleteProfileData({ dataSource, symbol });
}
@Put('settings/:key')
@UseGuards(AuthGuard('jwt'))
public async updateProperty(
@Param('key') key: string,
@Body() data: PropertyDto
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return await this.adminService.putSetting(key, data.value);
}
}

View File

@ -3,7 +3,10 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration.modu
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 { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
@ -15,8 +18,11 @@ import { AdminService } from './admin.service';
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
SubscriptionModule
PropertyModule,
SubscriptionModule,
SymbolProfileModule
],
controllers: [AdminController],
providers: [AdminService],

View File

@ -2,10 +2,19 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
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 { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AdminData } from '@ghostfolio/common/interfaces';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { DataSource, Property } from '@prisma/client';
import { differenceInDays } from 'date-fns';
@Injectable()
@ -14,66 +23,154 @@ export class AdminService {
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async deleteProfileData({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
}
public async get(): Promise<AdminData> {
return {
exchangeRates: [
{
label1: Currency.EUR,
label2: Currency.CHF,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.EUR,
Currency.CHF
)
},
{
label1: Currency.GBP,
label2: Currency.CHF,
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
)
}
],
dataGatheringProgress:
await this.dataGatheringService.getDataGatheringProgress(),
exchangeRates: this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== baseCurrency;
})
.map((currency) => {
return {
label1: baseCurrency,
label2: currency,
value: this.exchangeRateDataService.toCurrency(
1,
baseCurrency,
currency
)
};
}),
lastDataGathering: await this.getLastDataGathering(),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics()
};
}
public async getMarketData(): Promise<AdminMarketData> {
const marketData = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const currencyPairsToGather: AdminMarketDataItem[] =
this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol
};
});
const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
},
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
})
).map((symbolProfile) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === symbolProfile.dataSource &&
marketDataItem.symbol === symbolProfile.symbol
);
})?._count ?? 0;
return {
marketDataItemCount,
activityCount: symbolProfile._count.Order,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol
};
});
return {
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
};
}
public async getMarketDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}): Promise<AdminMarketDataDetails> {
return {
marketData: await this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol
}
})
};
}
public async putSetting(key: string, value: string) {
let response: Property;
if (value === '') {
response = await this.propertyService.delete({ key });
} else {
response = await this.propertyService.put({ key, value });
}
if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize();
await this.dataGatheringService.reset();
}
return response;
}
private async getLastDataGathering() {
const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();
@ -89,7 +186,7 @@ export class AdminService {
return 'IN_PROGRESS';
}
return null;
return undefined;
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> {

View File

@ -0,0 +1,6 @@
import { IsNumber } from 'class-validator';
export class UpdateMarketDataDto {
@IsNumber()
marketPrice: number;
}

View File

@ -8,6 +8,7 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
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 { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
@ -19,7 +20,6 @@ import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.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';
@ -42,7 +42,6 @@ import { UserModule } from './user/user.module';
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ExperimentalModule,
ExportModule,
ImportModule,
InfoModule,
@ -67,6 +66,7 @@ import { UserModule } from './user/user.module';
}),
SubscriptionModule,
SymbolModule,
TwitterBotModule,
UserModule
],
controllers: [AppController],

View File

@ -1,10 +1,6 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
@ -29,7 +25,7 @@ export class AuthDeviceController {
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
this.request.user.permissions,
permissions.deleteAuthDevice
)
) {

View File

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

View File

@ -23,7 +23,7 @@ export class AuthDeviceService {
take?: number;
cursor?: Prisma.AuthDeviceWhereUniqueInput;
where?: Prisma.AuthDeviceWhereInput;
orderBy?: Prisma.AuthDeviceOrderByInput;
orderBy?: Prisma.AuthDeviceOrderByWithRelationInput;
}): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.authDevice.findMany({

View File

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

View File

@ -1,9 +1,9 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@ -15,20 +15,20 @@ import { JwtStrategy } from './jwt.strategy';
@Module({
controllers: [AuthController],
imports: [
ConfigurationModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
}),
SubscriptionModule
PrismaModule,
SubscriptionModule,
UserModule
],
providers: [
AuthDeviceService,
AuthService,
ConfigurationService,
GoogleStrategy,
JwtStrategy,
PrismaService,
UserService,
WebAuthService
]
})

View File

@ -1,5 +1,5 @@
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 { Provider } from '@prisma/client';
import { Strategy } from 'passport-google-oauth20';
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
};
done(null, user);
} catch (err) {
console.error(err);
done(err, false);
} catch (error) {
Logger.error(error);
done(error, false);
}
}
}

View File

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

View File

@ -1,6 +1,6 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';

View File

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

View File

@ -1,22 +0,0 @@
import { Currency, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateOrderDto {
@IsString()
currency: Currency;
@IsISO8601()
date: string;
@IsNumber()
quantity: number;
@IsString()
symbol: string;
@IsString()
type: Type;
@IsNumber()
unitPrice: number;
}

View File

@ -1,69 +0,0 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
Headers,
HttpException,
Inject,
Param,
Post
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { parse } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { ExperimentalService } from './experimental.service';
import { Data } from './interfaces/data.interface';
@Controller('experimental')
export class ExperimentalController {
public constructor(
private readonly experimentalService: ExperimentalService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('benchmarks')
public async getBenchmarks(
@Headers('Authorization') apiToken: string
): Promise<string[]> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return benchmarks.map(({ symbol }) => {
return symbol;
});
}
@Get('benchmarks/:symbol')
public async getBenchmark(
@Headers('Authorization') apiToken: string,
@Param('symbol') symbol: string
): Promise<{ date: Date; marketPrice: number }[]> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const marketData = await this.experimentalService.getBenchmark(symbol);
if (marketData?.length === 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return marketData;
}
}

View File

@ -1,23 +0,0 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.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 { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service';
@Module({
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule,
PrismaModule
],
controllers: [ExperimentalController],
providers: [AccountService, ExperimentalService]
})
export class ExperimentalModule {}

View File

@ -1,23 +0,0 @@
import { AccountService } from '@ghostfolio/api/app/account/account.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 { Injectable } from '@nestjs/common';
@Injectable()
export class ExperimentalService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async getBenchmark(aSymbol: string) {
return this.prismaService.marketData.findMany({
orderBy: { date: 'asc' },
select: { date: true, marketPrice: true },
where: { symbol: aSymbol }
});
}
}

View File

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

View File

@ -1,6 +1,13 @@
import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
Headers,
Inject,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -15,8 +22,11 @@ export class ExportController {
@Get()
@UseGuards(AuthGuard('jwt'))
public async export(): Promise<Export> {
return await this.exportService.export({
public async export(
@Query('activityIds') activityIds?: string[]
): Promise<Export> {
return this.exportService.export({
activityIds,
userId: this.request.user.id
});
}

View File

@ -1,4 +1,3 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.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';
@ -18,6 +17,6 @@ import { ExportService } from './export.service';
RedisCacheModule
],
controllers: [ExportController],
providers: [CacheService, ExportService]
providers: [ExportService]
})
export class ExportModule {}

View File

@ -7,25 +7,62 @@ import { Injectable } from '@nestjs/common';
export class ExportService {
public constructor(private readonly prismaService: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prismaService.order.findMany({
public async export({
activityIds,
userId
}: {
activityIds?: string[];
userId: string;
}): Promise<Export> {
let orders = await this.prismaService.order.findMany({
orderBy: { date: 'desc' },
select: {
accountId: true,
currency: true,
dataSource: true,
date: true,
fee: true,
id: true,
quantity: true,
symbol: true,
SymbolProfile: true,
type: true,
unitPrice: true
},
where: { userId }
});
if (activityIds) {
orders = orders.filter((order) => {
return activityIds.includes(order.id);
});
}
return {
meta: { date: new Date().toISOString(), version: environment.version },
orders
orders: orders.map(
({
accountId,
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice
}) => {
return {
accountId,
currency,
date,
fee,
quantity,
type,
unitPrice,
dataSource: SymbolProfile.dataSource,
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
};
}
)
};
}
}

View File

@ -1,7 +1,11 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Order } from '@prisma/client';
import { IsArray } from 'class-validator';
import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
export class ImportDataDto {
@IsArray()
orders: Partial<Order>[];
@Type(() => CreateOrderDto)
@ValidateNested({ each: true })
orders: Order[];
}

View File

@ -1,10 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
HttpException,
Inject,
Logger,
Post,
UseGuards
} from '@nestjs/common';
@ -39,10 +40,13 @@ export class ImportController {
userId: this.request.user.id
});
} catch (error) {
console.error(error);
Logger.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
{
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
message: [error.message]
},
StatusCodes.BAD_REQUEST
);
}

View File

@ -1,5 +1,6 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
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';
@ -11,14 +12,17 @@ import { ImportController } from './import.controller';
import { ImportService } from './import.service';
@Module({
controllers: [ImportController],
imports: [
AccountModule,
CacheModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
OrderModule,
PrismaModule,
RedisCacheModule
],
controllers: [ImportController],
providers: [CacheService, ImportService, OrderService]
providers: [ImportService]
})
export class ImportModule {}

View File

@ -1,11 +1,19 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
import { isSameDay, parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(private readonly orderService: OrderService) {}
public constructor(
private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService
) {}
public async import({
orders,
@ -14,7 +22,26 @@ export class ImportService {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
for (const order of orders) {
if (!order.dataSource) {
if (order.type === 'ITEM') {
order.dataSource = 'MANUAL';
} else {
order.dataSource = this.dataProviderService.getPrimaryDataSource();
}
}
}
await this.validateOrders({ orders, userId });
const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => {
return account.id;
}
);
for (const {
accountId,
currency,
dataSource,
date,
@ -32,9 +59,88 @@ export class ImportService {
symbol,
type,
unitPrice,
userId,
accountId: accountIds.includes(accountId) ? accountId : undefined,
date: parseISO(<string>(<unknown>date)),
SymbolProfile: {
connectOrCreate: {
create: {
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
},
User: { connect: { id: userId } }
});
}
}
private async validateOrders({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}) {
if (
orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) {
throw new Error(
`Too many transactions (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT'
)} at most)`
);
}
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`);
}
if (dataSource !== 'MANUAL') {
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}")`
);
}
if (result[symbol].currency !== currency) {
throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
);
}
}
}
}
}

View File

@ -1,11 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.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 { 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 { 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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@ -13,23 +13,21 @@ import { InfoController } from './info.controller';
import { InfoService } from './info.service';
@Module({
controllers: [InfoController],
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
})
}),
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
controllers: [InfoController],
providers: [
AlphaVantageService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
InfoService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
providers: [InfoService]
})
export class InfoModule {}

View File

@ -1,32 +1,49 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE,
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client';
import * as bent from 'bent';
import { subDays } from 'date-fns';
@Injectable()
export class InfoService {
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
private static CACHE_KEY_STATISTICS = 'STATISTICS';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService
) {}
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
const platforms = await this.prismaService.platform.findMany({
orderBy: { name: 'asc' },
select: { id: true, name: true }
});
let systemMessage: string;
const globalPermissions: string[] = [];
@ -34,10 +51,22 @@ export class InfoService {
globalPermissions.push(permissions.enableBlog);
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource
);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin);
}
@ -52,11 +81,21 @@ export class InfoService {
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
globalPermissions.push(permissions.enableSystemMessage);
systemMessage = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as string;
}
return {
...info,
globalPermissions,
isReadOnlyMode,
platforms,
currencies: Object.values(Currency),
systemMessage,
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(),
@ -105,7 +144,7 @@ export class InfoService {
const contributors = await get();
return contributors?.length;
} catch (error) {
console.error(error);
Logger.error(error);
return undefined;
}
@ -126,15 +165,43 @@ export class InfoService {
const { stargazers_count } = await get();
return stargazers_count;
} catch (error) {
console.error(error);
Logger.error(error);
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 async countSlackCommunityUsers() {
return (await this.propertyService.getByKey(
PROPERTY_SLACK_COMMUNITY_USERS
)) as string;
}
private getDemoAuthToken() {
return this.jwtService.sign({
id: InfoService.DEMO_USER_ID
id: DEMO_USER_ID
});
}
@ -150,17 +217,40 @@ export class InfoService {
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 activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30);
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
return {
statistics = {
activeUsers1d,
activeUsers30d,
gitHubContributors,
gitHubStargazers
gitHubStargazers,
newUsers30d,
slackCommunityUsers
};
await this.redisCacheService.set(
InfoService.CACHE_KEY_STATISTICS,
JSON.stringify(statistics)
);
return statistics;
}
private async getSubscriptions(): Promise<Subscription[]> {
@ -169,7 +259,7 @@ export class InfoService {
}
const stripeConfig = await this.prismaService.property.findUnique({
where: { key: 'STRIPE_CONFIG' }
where: { key: PROPERTY_STRIPE_CONFIG }
});
if (stripeConfig) {

View File

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

View File

@ -0,0 +1,10 @@
import { OrderWithAccount } from '@ghostfolio/common/types';
export interface Activities {
activities: Activity[];
}
export interface Activity extends OrderWithAccount {
feeInBaseCurrency: number;
valueInBaseCurrency: number;
}

View File

@ -1,12 +1,10 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -18,7 +16,8 @@ import {
Param,
Post,
Put,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -27,6 +26,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@ -43,10 +43,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteOrder
)
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -64,61 +61,46 @@ export class OrderController {
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> {
): Promise<Activities> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const userCurrency = this.request.user.Settings.currency;
let orders = await this.orderService.orders({
include: {
Account: {
include: {
Platform: true
}
},
SymbolProfile: {
select: {
name: true
}
}
},
orderBy: { date: 'desc' },
where: { userId: impersonationUserId || this.request.user.id }
let activities = await this.orderService.getOrders({
userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id
});
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
activities = nullifyValuesInObjects(activities, [
'fee',
'feeInBaseCurrency',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
]);
}
return orders;
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getOrderById(@Param('id') id: string): Promise<OrderModel> {
return this.orderService.order({
id_userId: {
id,
userId: this.request.user.id
}
});
return { activities };
}
@Post()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.createOrder
)
!hasPermission(this.request.user.permissions, permissions.createOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -126,45 +108,34 @@ export class OrderController {
);
}
const date = parseISO(data.date);
const accountId = data.accountId;
delete data.accountId;
return this.orderService.createOrder({
...data,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
date,
date: parseISO(data.date),
SymbolProfile: {
connectOrCreate: {
create: {
dataSource: data.dataSource,
symbol: data.symbol
},
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } }
User: { connect: { id: this.request.user.id } },
userId: this.request.user.id
});
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.updateOrder
)
!hasPermission(this.request.user.permissions, permissions.updateOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -1,28 +1,34 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';
@Module({
controllers: [OrderController],
exports: [OrderService],
imports: [
CacheModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
UserModule
],
controllers: [OrderController],
providers: [CacheService, OrderService],
exports: [OrderService]
providers: [AccountService, OrderService]
})
export class OrderModule {}

View File

@ -1,17 +1,27 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma } from '@prisma/client';
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface';
@Injectable()
export class OrderService {
public constructor(
private readonly accountService: AccountService,
private readonly cacheService: CacheService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async order(
@ -28,7 +38,7 @@ export class OrderService {
take?: number;
cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByInput;
orderBy?: Prisma.OrderOrderByWithRelationInput;
}): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params;
@ -42,7 +52,47 @@ export class OrderService {
});
}
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
public async createOrder(
data: Prisma.OrderCreateInput & { accountId?: string; userId: string }
): Promise<Order> {
const defaultAccount = (
await this.accountService.getAccounts(data.userId)
).find((account) => {
return account.isDefault === true;
});
let Account = {
connect: {
id_userId: {
userId: data.userId,
id: data.accountId ?? defaultAccount?.id
}
}
};
if (data.type === 'ITEM') {
const currency = data.currency;
const dataSource: DataSource = 'MANUAL';
const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol;
Account = undefined;
data.dataSource = dataSource;
data.id = id;
data.symbol = null;
data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name;
data.SymbolProfile.connectOrCreate.create.symbol = id;
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
dataSource,
symbol: id
};
} else {
data.SymbolProfile.connectOrCreate.create.symbol =
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
}
const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
@ -51,18 +101,29 @@ export class OrderService {
{
dataSource: data.dataSource,
date: <Date>data.date,
symbol: data.symbol
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
}
this.dataGatheringService.gatherProfileData([data.symbol]);
this.dataGatheringService.gatherProfileData([
{
dataSource: data.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
await this.cacheService.flush();
delete data.accountId;
delete data.userId;
const orderData: Prisma.OrderCreateInput = data;
return this.prismaService.order.create({
data: {
...data,
...orderData,
Account,
isDraft
}
});
@ -71,33 +132,76 @@ export class OrderService {
public async deleteOrder(
where: Prisma.OrderWhereUniqueInput
): Promise<Order> {
return this.prismaService.order.delete({
const order = await this.prismaService.order.delete({
where
});
if (order.type === 'ITEM') {
await this.symbolProfileService.deleteById(order.symbolProfileId);
}
return order;
}
public getOrders({
public async getOrders({
includeDrafts = false,
types,
userCurrency,
userId
}: {
includeDrafts?: boolean;
types?: TypeOfOrder[];
userCurrency: string;
userId: string;
}) {
}): Promise<Activity[]> {
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' }
if (types) {
where.OR = types.map((type) => {
return {
type: {
equals: type
}
};
});
}
return (
await this.orders({
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: {
include: {
Platform: true
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' }
})
).map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.currency,
userCurrency
)
};
});
}
@ -107,6 +211,17 @@ export class OrderService {
}): Promise<Order> {
const { data, where } = params;
if (data.Account.connect.id_userId.id === null) {
delete data.Account;
}
if (data.type === 'ITEM') {
const name = data.symbol;
data.symbol = null;
data.SymbolProfile = { update: { name } };
}
const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {

View File

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

View File

@ -0,0 +1,60 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) {
switch (symbol) {
case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 };
} else if (isSameDay(parseDate('2021-11-22'), date)) {
return { marketPrice: 142.9 };
} else if (isSameDay(parseDate('2021-11-26'), date)) {
return { marketPrice: 139.9 };
} else if (isSameDay(parseDate('2021-11-30'), date)) {
return { marketPrice: 136.6 };
} else if (isSameDay(parseDate('2021-12-18'), date)) {
return { marketPrice: 148.9 };
}
return { marketPrice: 0 };
default:
return { marketPrice: 0 };
}
}
export const CurrentRateServiceMock = {
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
const result = [];
if (dateQuery.lt) {
for (
let date = resetHours(dateQuery.gte);
isBefore(date, endOfDay(dateQuery.lt));
date = addDays(date, 1)
) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
.marketPrice,
symbol: dataGatheringItem.symbol
});
}
}
} else {
for (const date of dateQuery.in) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
.marketPrice,
symbol: dataGatheringItem.symbol
});
}
}
}
return Promise.resolve(result);
}
};

View File

@ -1,11 +1,11 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Currency, MarketData } from '@prisma/client';
import { MarketDataService } from '@ghostfolio/api/services/market-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', () => {
jest.mock('@ghostfolio/api/services/market-data.service', () => {
return {
MarketDataService: jest.fn().mockImplementation(() => {
return {
@ -14,6 +14,7 @@ jest.mock('./market-data.service', () => {
date,
symbol,
createdAt: date,
dataSource: DataSource.YAHOO,
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
marketPrice: 1847.839966
});
@ -30,6 +31,7 @@ jest.mock('./market-data.service', () => {
return Promise.resolve<MarketData[]>([
{
createdAt: dateRangeStart,
dataSource: DataSource.YAHOO,
date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
@ -37,6 +39,7 @@ jest.mock('./market-data.service', () => {
},
{
createdAt: dateRangeEnd,
dataSource: DataSource.YAHOO,
date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
@ -69,15 +72,8 @@ describe('CurrentRateService', () => {
let marketDataService: MarketDataService;
beforeAll(async () => {
dataProviderService = new DataProviderService(
null,
null,
null,
null,
null,
null
);
exchangeRateDataService = new ExchangeRateDataService(null);
dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize();
@ -89,29 +85,16 @@ describe('CurrentRateService', () => {
);
});
it('getValue', async () => {
expect(
await currentRateService.getValue({
currency: Currency.USD,
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
symbol: 'AMZN',
userCurrency: Currency.CHF
})
).toMatchObject({
marketPrice: 1847.839966
});
});
it('getValues', async () => {
expect(
await currentRateService.getValues({
currencies: { AMZN: Currency.USD },
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))
},
symbols: ['AMZN'],
userCurrency: Currency.CHF
userCurrency: 'CHF'
})
).toMatchObject([
{

View File

@ -1,14 +1,13 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-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 {
@ -18,45 +17,10 @@ export class CurrentRateService {
private readonly marketDataService: MarketDataService
) {}
public async getValue({
currency,
date,
symbol,
userCurrency
}: GetValueParams): Promise<GetValueObject> {
if (isToday(date)) {
const dataProviderResult = await this.dataProviderService.get([symbol]);
return {
date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
symbol: symbol
};
}
const marketData = await this.marketDataService.get({
date,
symbol
});
if (marketData) {
return {
date: marketData.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketData.marketPrice,
currency,
userCurrency
),
symbol: marketData.symbol
};
}
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
}
public async getValues({
currencies,
dataGatheringItems,
dateQuery,
symbols,
userCurrency
}: GetValuesParams): Promise<GetValueObject[]> {
const includeToday =
@ -75,24 +39,31 @@ export class CurrentRateService {
if (includeToday) {
const today = resetHours(new Date());
promises.push(
this.dataProviderService.get(symbols).then((dataResultProvider) => {
const result = [];
for (const symbol of symbols) {
result.push({
symbol,
date: today,
marketPrice: this.exchangeRateDataService.toCurrency(
dataResultProvider?.[symbol]?.marketPrice ?? 0,
dataResultProvider?.[symbol]?.currency,
userCurrency
)
});
}
return result;
})
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({

View File

@ -6,6 +6,7 @@ export interface CurrentPositions {
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;
netAnnualizedPerformance?: Big;
netPerformance: Big;
netPerformancePercentage: Big;
currentValue: Big;

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { PortfolioOrder } from './portfolio-order.interface';
export interface PortfolioOrderItem extends PortfolioOrder {
itemType?: '' | 'start' | 'end';
}

View File

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

View File

@ -1,8 +1,8 @@
import { Currency } from '@prisma/client';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { OrderWithAccount } from '@ghostfolio/common/types';
export interface PortfolioPositionDetail {
averagePrice: number;
currency: Currency;
firstBuyDate: string;
grossPerformance: number;
grossPerformancePercent: number;
@ -13,9 +13,17 @@ export interface PortfolioPositionDetail {
minPrice: number;
netPerformance: number;
netPerformancePercent: number;
orders: OrderWithAccount[];
quantity: number;
symbol: string;
SymbolProfile: EnhancedSymbolProfile;
transactionCount: number;
value: number;
}
export interface HistoricalDataContainer {
isAllTimeHigh: boolean;
isAllTimeLow: boolean;
items: HistoricalDataItem[];
}
export interface HistoricalDataItem {

View File

@ -0,0 +1,8 @@
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
import Big from 'big.js';
export interface TimelineInfoInterface {
maxNetPerformance: Big;
minNetPerformance: Big;
timelinePeriods: TimelinePeriod[];
}

View File

@ -1,8 +1,9 @@
import { Currency } from '@prisma/client';
import { DataSource } from '@prisma/client';
import Big from 'big.js';
export interface TransactionPointSymbol {
currency: Currency;
currency: string;
dataSource: DataSource;
fee: Big;
firstBuyDate: string;
investment: Big;

View File

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

View File

@ -0,0 +1,95 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculatorNew', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2021-11-22',
dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
type: 'BUY',
unitPrice: new Big(142.9)
},
{
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(1.65),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
type: 'SELL',
unitPrice: new Big(136.6)
}
]
});
portfolioCalculatorNew.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
parseDate('2021-11-22')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('0'),
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
investment: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
marketPrice: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('0')
});
});
});
});

View File

@ -0,0 +1,84 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculatorNew', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
type: 'BUY',
unitPrice: new Big(136.6)
}
]
});
portfolioCalculatorNew.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
parseDate('2021-11-30')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('297.8'),
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
hasErrors: false,
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
positions: [
{
averagePrice: new Big('136.6'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
investment: new Big('273.2'),
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
marketPrice: 148.9,
quantity: new Big('2'),
symbol: 'BALN.SW',
transactionCount: 1
}
],
totalInvestment: new Big('273.2')
});
});
});
});

View File

@ -0,0 +1,56 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculatorNew', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it('with no orders', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
currentRateService,
currency: 'CHF',
orders: []
});
portfolioCalculatorNew.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
new Date()
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
totalInvestment: new Big(0)
});
});
});
});

View File

@ -0,0 +1,73 @@
import Big from 'big.js';
import { CurrentRateService } from './current-rate.service';
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
describe('PortfolioCalculatorNew', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('annualized performance percentage', () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
currentRateService,
currency: 'USD',
orders: []
});
it('Get annualized performance', async () => {
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0)
})
.toNumber()
).toEqual(0);
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercent: new Big(0)
})
.toNumber()
).toEqual(0);
/**
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025)
})
.toNumber()
).toBeCloseTo(0.729705);
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05)
})
.toNumber()
).toBeCloseTo(0.05);
/**
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374)
})
.toNumber()
).toBeCloseTo(0.145);
});
});
});

View File

@ -0,0 +1,958 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
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 { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
addMilliseconds,
addMonths,
addYears,
endOfDay,
format,
isAfter,
isBefore,
max,
min
} from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.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 PortfolioCalculatorNew {
private static readonly ENABLE_LOGGING = false;
private currency: string;
private currentRateService: CurrentRateService;
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[];
public constructor({
currency,
currentRateService,
orders
}: {
currency: string;
currentRateService: CurrentRateService;
orders: PortfolioOrder[];
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.orders = orders;
this.orders.sort((a, b) => a.date.localeCompare(b.date));
}
public computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
for (const order of this.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)
.plus(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),
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
);
}
}
const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
}
const initialValues: { [symbol: string]: Big } = {};
const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false;
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const {
grossPerformance,
grossPerformancePercentage,
hasErrors,
initialValue,
netPerformance,
netPerformancePercentage
} = this.getSymbolMetrics({
marketSymbolMap,
start,
symbol: item.symbol
});
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
initialValues[item.symbol] = initialValue;
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: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null
: null,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
}
const overall = this.calculateOverallPerformance(positions, initialValues);
return {
...overall,
positions,
hasErrors: hasAnySymbolMetricsErrors || 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.plus(transactionPointSymbol.investment),
new Big(0)
)
};
});
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
): Promise<TimelineInfoInterface> {
if (timelineSpecification.length === 0) {
return {
maxNetPerformance: new Big(0),
minNetPerformance: new Big(0),
timelinePeriods: []
};
}
const startDate = timelineSpecification[0].start;
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
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 timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises
);
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
const maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
);
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: flatten(timelinePeriods)
};
}
private calculateOverallPerformance(
positions: TimelinePosition[],
initialValues: { [symbol: string]: Big }
) {
let currentValue = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let sumOfWeights = new Big(0);
let totalInvestment = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
currentValue = currentValue.plus(
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
);
} else {
hasErrors = true;
}
totalInvestment = totalInvestment.plus(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) {
// Use the average from the initial value and the current investment as
// a weight
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
.plus(currentPosition.investment)
.div(2);
sumOfWeights = sumOfWeights.plus(weight);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(weight)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(weight)
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
);
hasErrors = true;
}
}
if (sumOfWeights.gt(0)) {
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
} else {
grossPerformancePercentage = new Big(0);
netPerformancePercentage = new Big(0);
}
return {
currentValue,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
};
}
private async getTimePeriodForDate(
j: number,
startDate: Date,
endDate: Date
): Promise<TimelineInfoInterface> {
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.plus(item.investment);
fees = fees.plus(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[] = [];
let maxNetPerformance: Big = null;
let minNetPerformance: Big = null;
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.plus(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
}
if (!invalid) {
const grossPerformance = value.minus(investment);
const netPerformance = grossPerformance.minus(fees);
if (
minNetPerformance === null ||
minNetPerformance.gt(netPerformance)
) {
minNetPerformance = netPerformance;
}
if (
maxNetPerformance === null ||
maxNetPerformance.lt(netPerformance)
) {
maxNetPerformance = netPerformance;
}
const result = {
grossPerformance,
investment,
netPerformance,
value,
date: currentDateAsString
};
results.push(result);
}
}
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: results
};
}
private getFactor(type: TypeOfOrder) {
let factor: number;
switch (type) {
case 'BUY':
factor = 1;
break;
case '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 getSymbolMetrics({
marketSymbolMap,
start,
symbol
}: {
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
symbol: string;
}) {
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
return order.symbol === symbol;
});
if (orders.length <= 0) {
return {
hasErrors: false,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
const dateOfFirstTransaction = new Date(first(orders).date);
const endDate = new Date(Date.now());
const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate =
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
hasErrors: true,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
let averagePriceAtEndDate = new Big(0);
let averagePriceAtStartDate = new Big(0);
let feesAtStartDate = new Big(0);
let fees = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0);
let maxTotalInvestment = new Big(0);
let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0);
// Add a synthetic order at the start and the end date
orders.push({
symbol,
currency: null,
date: format(start, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'start',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtStartDate
});
orders.push({
symbol,
currency: null,
date: format(endDate, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'end',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtEndDate
});
// Sort orders so that the start and end placeholder order are at the right
// position
orders = sortBy(orders, (order) => {
let sortIndex = new Date(order.date);
if (order.itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
if (order.itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex((order) => {
return order.itemType === 'start';
});
const indexOfEndOrder = orders.findIndex((order) => {
return order.itemType === 'end';
});
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
// Calculate the average start price as soon as any units are held
if (
averagePriceAtStartDate.eq(0) &&
i >= indexOfStartOrder &&
totalUnits.gt(0)
) {
averagePriceAtStartDate = totalInvestment.div(totalUnits);
}
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPrice
);
const transactionInvestment = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
totalInvestment = totalInvestment.plus(transactionInvestment);
if (totalInvestment.gt(maxTotalInvestment)) {
maxTotalInvestment = totalInvestment;
}
if (i === indexOfEndOrder && totalUnits.gt(0)) {
averagePriceAtEndDate = totalInvestment.div(totalUnits);
}
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
}
}
fees = fees.plus(order.fee);
totalUnits = totalUnits.plus(
order.quantity.mul(this.getFactor(order.type))
);
const valueOfInvestment = totalUnits.mul(order.unitPrice);
const grossPerformanceFromSell =
order.type === TypeOfOrder.SELL
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
totalInvestmentWithGrossPerformanceFromSell =
totalInvestmentWithGrossPerformanceFromSell
.plus(transactionInvestment)
.plus(grossPerformanceFromSell);
lastAveragePrice = totalUnits.eq(0)
? new Big(0)
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
const newGrossPerformance = valueOfInvestment
.minus(totalInvestmentWithGrossPerformanceFromSell)
.plus(grossPerformanceFromSells);
if (
i > indexOfStartOrder &&
!lastValueOfInvestmentBeforeTransaction
.plus(lastTransactionInvestment)
.eq(0)
) {
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.mul(
new Big(1).plus(grossHoldingPeriodReturn)
);
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(fees.minus(feesAtStartDate))
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(netHoldingPeriodReturn)
);
}
grossPerformance = newGrossPerformance;
lastTransactionInvestment = transactionInvestment;
lastValueOfInvestmentBeforeTransaction =
valueOfInvestmentBeforeTransaction;
if (order.itemType === 'start') {
feesAtStartDate = fees;
grossPerformanceAtStartDate = grossPerformance;
}
}
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.minus(1);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.minus(1);
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const grossPerformancePercentage =
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? totalGrossPerformance.div(maxTotalInvestment)
: unitPriceAtEndDate
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const netPerformancePercentage =
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? totalNetPerformance.div(maxTotalInvestment)
: unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
if (PortfolioCalculatorNew.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Average price: ${averagePriceAtStartDate.toFixed(
2
)} -> ${averagePriceAtEndDate.toFixed(2)}
Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%`
);
}
return {
initialValue,
grossPerformancePercentage,
netPerformancePercentage,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
grossPerformance: totalGrossPerformance
};
}
private isNextItemActive(
timelineSpecification: TimelineSpecification[],
currentDate: Date,
i: number
) {
return (
i + 1 < timelineSpecification.length &&
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,15 @@
import { OrderType } from '@ghostfolio/api/models/order-type';
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
addMonths,
addYears,
differenceInDays,
endOfDay,
format,
isAfter,
@ -14,7 +17,7 @@ import {
max,
min
} from 'date-fns';
import { flatten } from 'lodash';
import { flatten, isNumber } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
@ -33,7 +36,7 @@ export class PortfolioCalculator {
public constructor(
private currentRateService: CurrentRateService,
private currency: Currency
private currency: string
) {}
public computeTransactionPoints(orders: PortfolioOrder[]) {
@ -58,6 +61,7 @@ export class PortfolioCalculator {
.plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
@ -65,7 +69,7 @@ export class PortfolioCalculator {
: unitPrice
.mul(order.quantity)
.mul(factor)
.add(oldAccumulatedSymbol.investment),
.plus(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
@ -73,6 +77,7 @@ export class PortfolioCalculator {
} else {
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee,
firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor),
@ -103,6 +108,23 @@ export class PortfolioCalculator {
}
}
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;
}
@ -118,6 +140,7 @@ export class PortfolioCalculator {
hasErrors: false,
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
netAnnualizedPerformance: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
@ -134,12 +157,15 @@ export class PortfolioCalculator {
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length;
const dates = [];
const symbols = new Set<string>();
const currencies: { [symbol: string]: Currency } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
symbols.add(item.symbol);
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
}
for (let i = 0; i < this.transactionPoints.length; i++) {
@ -159,10 +185,10 @@ export class PortfolioCalculator {
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
symbols: Array.from(symbols),
userCurrency: this.currency
});
@ -212,9 +238,7 @@ export class PortfolioCalculator {
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol);
hasErrors = true;
console.error(
`Missing value for symbol ${item.symbol} at ${nextDate}`
);
Logger.warn(`Missing value for symbol ${item.symbol} at ${nextDate}`);
continue;
}
let lastInvestment: Big = new Big(0);
@ -245,7 +269,7 @@ export class PortfolioCalculator {
if (!initialValue) {
invalidSymbols.push(item.symbol);
hasErrors = true;
console.error(
Logger.warn(
`Missing value for symbol ${item.symbol} at ${currentDate}`
);
continue;
@ -290,6 +314,7 @@ export class PortfolioCalculator {
? new Big(0)
: item.investment.div(item.quantity),
currency: item.currency,
dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate,
grossPerformance: isValid
? grossPerformance[item.symbol] ?? null
@ -329,7 +354,7 @@ export class PortfolioCalculator {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
investment.add(transactionPointSymbol.investment),
investment.plus(transactionPointSymbol.investment),
new Big(0)
)
};
@ -339,16 +364,20 @@ export class PortfolioCalculator {
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
): Promise<TimelinePeriod[]> {
): Promise<TimelineInfoInterface> {
if (timelineSpecification.length === 0) {
return [];
return {
maxNetPerformance: new Big(0),
minNetPerformance: new Big(0),
timelinePeriods: []
};
}
const startDate = timelineSpecification[0].start;
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = [];
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
let i = 0;
let j = -1;
for (
@ -391,11 +420,40 @@ export class PortfolioCalculator {
}
}
const timelinePeriods: TimelinePeriod[][] = await Promise.all(
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises
);
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
return flatten(timelinePeriods);
const maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
);
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: flatten(timelinePeriods)
};
}
private calculateOverallPerformance(
@ -410,15 +468,20 @@ export class PortfolioCalculator {
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(
currentValue = currentValue.plus(
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
);
} else {
hasErrors = true;
}
totalInvestment = totalInvestment.add(currentPosition.investment);
totalInvestment = totalInvestment.plus(currentPosition.investment);
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
@ -437,12 +500,21 @@ export class PortfolioCalculator {
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)) {
console.error(
`Initial value is missing for symbol ${currentPosition.symbol}`
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
);
hasErrors = true;
}
@ -453,6 +525,8 @@ export class PortfolioCalculator {
grossPerformancePercentage.div(completeInitialValue);
netPerformancePercentage =
netPerformancePercentage.div(completeInitialValue);
netAnnualizedPerformance =
netAnnualizedPerformance.div(completeInitialValue);
}
return {
@ -460,6 +534,7 @@ export class PortfolioCalculator {
grossPerformance,
grossPerformancePercentage,
hasErrors,
netAnnualizedPerformance,
netPerformance,
netPerformancePercentage,
totalInvestment
@ -470,7 +545,7 @@ export class PortfolioCalculator {
j: number,
startDate: Date,
endDate: Date
): Promise<TimelinePeriod[]> {
): Promise<TimelineInfoInterface> {
let investment: Big = new Big(0);
let fees: Big = new Big(0);
@ -478,30 +553,33 @@ export class PortfolioCalculator {
[date: string]: { [symbol: string]: Big };
} = {};
if (j >= 0) {
const currencies: { [name: string]: Currency } = {};
const symbols: string[] = [];
const currencies: { [name: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency;
symbols.push(item.symbol);
investment = investment.add(item.investment);
fees = fees.add(item.fee);
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.plus(item.investment);
fees = fees.plus(item.fee);
}
let marketSymbols: GetValueObject[] = [];
if (symbols.length > 0) {
if (dataGatheringItems.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
gte: startDate,
lt: endOfDay(endDate)
},
symbols,
currencies,
userCurrency: this.currency
});
} catch (error) {
console.error(
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
error
);
@ -523,6 +601,8 @@ export class PortfolioCalculator {
}
const results: TimelinePeriod[] = [];
let maxNetPerformance: Big = null;
let minNetPerformance: Big = null;
for (
let currentDate = startDate;
isBefore(currentDate, endDate);
@ -539,35 +619,53 @@ export class PortfolioCalculator {
invalid = true;
break;
}
value = value.add(
value = value.plus(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
}
if (!invalid) {
const grossPerformance = value.minus(investment);
const netPerformance = grossPerformance.minus(fees);
if (
minNetPerformance === null ||
minNetPerformance.gt(netPerformance)
) {
minNetPerformance = netPerformance;
}
if (
maxNetPerformance === null ||
maxNetPerformance.lt(netPerformance)
) {
maxNetPerformance = netPerformance;
}
const result = {
grossPerformance,
investment,
netPerformance,
value,
date: currentDateAsString,
netPerformance: grossPerformance.minus(fees)
date: currentDateAsString
};
results.push(result);
}
}
return results;
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: results
};
}
private getFactor(type: OrderType) {
private getFactor(type: TypeOfOrder) {
let factor: number;
switch (type) {
case OrderType.Buy:
case 'BUY':
factor = 1;
break;
case OrderType.Sell:
case 'SELL':
factor = -1;
break;
default:

View File

@ -0,0 +1,25 @@
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PortfolioService } from './portfolio.service';
import { PortfolioServiceNew } from './portfolio.service-new';
@Injectable()
export class PortfolioServiceStrategy {
public constructor(
private readonly portfolioService: PortfolioService,
private readonly portfolioServiceNew: PortfolioServiceNew,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public get() {
if (
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
) {
return this.portfolioServiceNew;
}
return this.portfolioService;
}
}

View File

@ -1,17 +1,25 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import {
PortfolioChart,
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -20,81 +28,48 @@ import {
Inject,
Param,
Query,
Res,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import {
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service';
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
@Controller('portfolio')
export class PortfolioController {
public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async findAll(
@Headers('impersonation-id') impersonationId
): Promise<InvestmentItem[]> {
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 investments;
}
@Get('chart')
@UseGuards(AuthGuard('jwt'))
public async getChart(
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
): Promise<HistoricalDataItem[]> {
let chartData = await this.portfolioService.getChart(
impersonationId,
range
);
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioServiceStrategy
.get()
.getChart(impersonationId, range);
let hasNullValue = false;
let chartData = historicalDataContainer.items;
let hasError = false;
chartData.forEach((chartDataItem) => {
if (hasNotDefinedValuesInObject(chartDataItem)) {
hasNullValue = true;
hasError = true;
}
});
if (hasNullValue) {
res.status(StatusCodes.ACCEPTED);
}
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
@ -115,21 +90,40 @@ export class PortfolioController {
});
}
return <any>res.json(chartData);
return {
hasError,
chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow
};
}
@Get('details')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
): Promise<PortfolioDetails> {
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<PortfolioDetails & { hasError: boolean }> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let hasError = false;
const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(impersonationId, range);
await this.portfolioServiceStrategy
.get()
.getDetails(impersonationId, this.request.user.id, range);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
res.status(StatusCodes.ACCEPTED);
hasError = true;
}
if (
@ -156,8 +150,9 @@ export class PortfolioController {
portfolioPosition.grossPerformance = null;
portfolioPosition.investment =
portfolioPosition.investment / totalInvestment;
portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null;
portfolioPosition.value = portfolioPosition.value / totalValue;
}
for (const [name, { current, original }] of Object.entries(accounts)) {
@ -166,54 +161,79 @@ export class PortfolioController {
}
}
return <any>res.json({ accounts, holdings });
return { accounts, hasError, holdings };
}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let investments = await this.portfolioServiceStrategy
.get()
.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 { firstOrderDate: parseDate(investments[0]?.date), investments };
}
@Get('performance')
@UseGuards(AuthGuard('jwt'))
public async getPerformance(
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
): Promise<PortfolioPerformance> {
const performanceInformation = await this.portfolioService.getPerformance(
impersonationId,
range
);
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const performanceInformation = await this.portfolioServiceStrategy
.get()
.getPerformance(impersonationId, range);
if (performanceInformation?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
let performance = performanceInformation.performance;
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
performance = nullifyValuesInObject(performance, [
'currentGrossPerformance',
'currentValue'
]);
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
['currentGrossPerformance', 'currentValue']
);
}
return <any>res.json(performance);
return performanceInformation;
}
@Get('positions')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions(
impersonationId,
range
);
if (result?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
const result = await this.portfolioServiceStrategy
.get()
.getPositions(impersonationId, range);
if (
impersonationId ||
@ -223,12 +243,72 @@ export class PortfolioController {
return nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'quantity'
]);
});
}
return <any>res.json(result);
return result;
}
@Get('public/:accessId')
public async getPublic(
@Param('accessId') accessId
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
id: access.userId
});
if (!access) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
let hasDetails = true;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioServiceStrategy
.get()
.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 portfolioPublicDetails;
}
@Get('summary')
@ -236,7 +316,9 @@ export class PortfolioController {
public async getSummary(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> {
let summary = await this.portfolioService.getSummary(impersonationId);
let summary = await this.portfolioServiceStrategy
.get()
.getSummary(impersonationId);
if (
impersonationId ||
@ -246,8 +328,11 @@ export class PortfolioController {
'cash',
'committedFunds',
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'dividend',
'fees',
'items',
'netWorth',
'totalBuy',
'totalSell'
@ -257,16 +342,18 @@ export class PortfolioController {
return summary;
}
@Get('position/:symbol')
@Get('position/:dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'))
public async getPosition(
@Headers('impersonation-id') impersonationId,
@Headers('impersonation-id') impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition(
impersonationId,
symbol
);
let position = await this.portfolioServiceStrategy
.get()
.getPosition(dataSource, impersonationId, symbol);
if (position) {
if (
@ -276,7 +363,10 @@ export class PortfolioController {
position = nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'quantity'
'netPerformance',
'orders',
'quantity',
'value'
]);
}
@ -292,8 +382,18 @@ export class PortfolioController {
@Get('report')
@UseGuards(AuthGuard('jwt'))
public async getReport(
@Headers('impersonation-id') impersonationId
@Headers('impersonation-id') impersonationId: string
): Promise<PortfolioReport> {
return await this.portfolioService.getReport(impersonationId);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
}
}

View File

@ -1,3 +1,4 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
@ -6,35 +7,41 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
import { PortfolioServiceNew } from './portfolio.service-new';
import { RulesService } from './rules.service';
@Module({
controllers: [PortfolioController],
exports: [PortfolioServiceStrategy],
imports: [
AccessModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PrismaModule,
SymbolProfileModule,
UserModule
],
controllers: [PortfolioController],
providers: [
AccountService,
CurrentRateService,
MarketDataService,
PortfolioService,
RulesService,
SymbolProfileService
PortfolioServiceNew,
PortfolioServiceStrategy,
RulesService
]
})
export class PortfolioModule {}

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
import { OrderType } from '@ghostfolio/api/models/order-type';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
@ -21,9 +20,10 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
PortfolioDetails,
PortfolioPerformance,
PortfolioReport,
@ -32,19 +32,15 @@ import {
TimelinePosition
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import {
import type {
AccountWithValue,
DateRange,
OrderWithAccount,
RequestWithUser
} from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import {
AssetClass,
Currency,
DataSource,
Type as TypeOfOrder
} from '@prisma/client';
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
endOfToday,
@ -55,12 +51,14 @@ import {
parse,
parseISO,
setDayOfYear,
startOfDay,
subDays,
subYears
} from 'date-fns';
import { isEmpty } from 'lodash';
import { isEmpty, sortBy } from 'lodash';
import {
HistoricalDataContainer,
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
@ -80,10 +78,63 @@ export class PortfolioService {
private readonly symbolProfileService: SymbolProfileService
) {}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
const [accounts, details] = await Promise.all([
this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: aUserId }
}),
this.getDetails(aUserId, aUserId)
]);
const userCurrency = this.request.user.Settings.currency;
return accounts.map((account) => {
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
transactionCount += 1;
}
}
const result = {
...account,
transactionCount,
convertedBalance: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
value: details.accounts[account.id]?.current ?? 0
};
delete result.Order;
return result;
});
}
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId);
let totalBalance = 0;
let totalValue = 0;
let transactionCount = 0;
for (const account of accounts) {
totalBalance += account.convertedBalance;
totalValue += account.value;
transactionCount += account.transactionCount;
}
return { accounts, totalBalance, totalValue, transactionCount };
}
public async getInvestments(
aImpersonationId: string
): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -99,19 +150,40 @@ export class PortfolioService {
return [];
}
return portfolioCalculator.getInvestments().map((item) => {
const investments = portfolioCalculator.getInvestments().map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
});
// Add investment of today
const investmentOfToday = investments.filter((investment) => {
return investment.date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter((investment) => {
return isBefore(parseDate(investment.date), new Date());
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
});
}
return sortBy(investments, (investment) => {
return investment.date;
});
}
public async getChart(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> {
const userId = await this.getUserId(aImpersonationId);
): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -121,14 +193,21 @@ export class PortfolioService {
const { transactionPoints } = await this.getTransactionPoints({ userId });
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return [];
return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
let portfolioStart = parse(
transactionPoints[0].date,
DATE_FORMAT,
new Date()
);
portfolioStart = this.getStartDate(aDateRange, portfolioStart);
// Get start date for the full portfolio because of because of the
// min and max calculation
portfolioStart = this.getStartDate('max', portfolioStart);
const timelineSpecification: TimelineSpecification[] = [
{
@ -137,27 +216,62 @@ export class PortfolioService {
}
];
const timeline = await portfolioCalculator.calculateTimeline(
const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification,
format(new Date(), DATE_FORMAT)
);
return timeline
const timeline = timelineInfo.timelinePeriods;
const items = timeline
.filter((timelineItem) => timelineItem !== null)
.map((timelineItem) => ({
date: timelineItem.date,
marketPrice: timelineItem.value,
value: timelineItem.netPerformance.toNumber()
}));
let lastItem = null;
if (timeline.length > 0) {
lastItem = timeline[timeline.length - 1];
}
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
lastItem?.netPerformance
);
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
lastItem?.netPerformance
);
if (isAllTimeHigh && isAllTimeLow) {
isAllTimeHigh = false;
isAllTimeLow = false;
}
portfolioStart = startOfDay(
this.getStartDate(
aDateRange,
parse(transactionPoints[0].date, DATE_FORMAT, new Date())
)
);
return {
isAllTimeHigh,
isAllTimeLow,
items: items.filter((item) => {
// Filter items of date range
return !isAfter(portfolioStart, parseDate(item.date));
})
};
}
public async getDetails(
aImpersonationId: string,
aUserId: string,
aDateRange: DateRange = 'max'
): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, aUserId);
const userCurrency = this.request.user.Settings.currency;
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
userCurrency
@ -190,12 +304,18 @@ export class PortfolioService {
);
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const dataGatheringItems = currentPositions.positions.map((position) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
};
});
const symbols = currentPositions.positions.map(
(position) => position.symbol
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(symbols),
this.dataProviderService.get(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
@ -225,6 +345,7 @@ export class PortfolioService {
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries,
currency: item.currency,
dataSource: symbolProfile.dataSource,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent:
@ -243,14 +364,18 @@ export class PortfolioService {
};
}
// TODO: Add a cash position for each currency
holdings[ghostfolioCashSymbol] = await this.getCashPosition({
const cashPositions = await this.getCashPositions({
cashDetails,
userCurrency,
investment: totalInvestment,
value: totalValue
});
const accounts = await this.getAccounts(
for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
const accounts = await this.getValueOfAccounts(
orders,
portfolioItemsNow,
userCurrency,
@ -261,19 +386,25 @@ export class PortfolioService {
}
public async getPosition(
aDataSource: DataSource,
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const userId = await this.getUserId(aImpersonationId);
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const orders = (await this.orderService.getOrders({ userId })).filter(
(order) => order.symbol === aSymbol
);
const orders = (
await this.orderService.getOrders({ userCurrency, userId })
).filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === aDataSource &&
SymbolProfile.symbol === aSymbol
);
});
if (orders.length <= 0) {
return {
averagePrice: undefined,
currency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
@ -284,24 +415,34 @@ export class PortfolioService {
minPrice: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
orders: [],
quantity: undefined,
symbol: aSymbol,
transactionCount: undefined
SymbolProfile: undefined,
transactionCount: undefined,
value: undefined
};
}
const positionCurrency = orders[0].currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
aSymbol
]);
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
type: <OrderType>order.type,
unitPrice: new Big(order.unitPrice)
}));
const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
})
.map((order) => ({
currency: order.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -323,6 +464,7 @@ export class PortfolioService {
const {
averagePrice,
currency,
dataSource,
firstBuyDate,
marketPrice,
quantity,
@ -330,25 +472,24 @@ export class PortfolioService {
} = position;
// Convert investment, gross and net performance to currency of user
const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency(
position.investment.toNumber(),
position.investment?.toNumber(),
currency,
userCurrency
);
const grossPerformance = this.exchangeRateDataService.toCurrency(
position.grossPerformance.toNumber(),
position.grossPerformance?.toNumber(),
currency,
userCurrency
);
const netPerformance = this.exchangeRateDataService.toCurrency(
position.netPerformance.toNumber(),
position.netPerformance?.toNumber(),
currency,
userCurrency
);
const historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
[{ dataSource, symbol: aSymbol }],
'day',
parseISO(firstBuyDate),
new Date()
@ -400,7 +541,6 @@ export class PortfolioService {
}
return {
currency,
firstBuyDate,
grossPerformance,
investment,
@ -408,20 +548,28 @@ export class PortfolioService {
maxPrice,
minPrice,
netPerformance,
orders,
SymbolProfile,
transactionCount,
averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
historicalData: historicalDataArray,
netPerformancePercent: position.netPerformancePercentage.toNumber(),
quantity: quantity.toNumber(),
symbol: aSymbol
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(),
currency,
userCurrency
)
};
} else {
const currentData = await this.dataProviderService.get([aSymbol]);
const currentData = await this.dataProviderService.get([
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
]);
const marketPrice = currentData[aSymbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
'day',
portfolioStart,
new Date()
@ -455,8 +603,9 @@ export class PortfolioService {
marketPrice,
maxPrice,
minPrice,
orders,
SymbolProfile,
averagePrice: 0,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
@ -465,8 +614,8 @@ export class PortfolioService {
netPerformance: undefined,
netPerformancePercent: undefined,
quantity: 0,
symbol: aSymbol,
transactionCount: undefined
transactionCount: undefined,
value: 0
};
}
}
@ -475,7 +624,7 @@ export class PortfolioService {
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -502,10 +651,16 @@ export class PortfolioService {
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
);
const dataGatheringItem = positions.map((position) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
};
});
const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(symbols),
this.dataProviderService.get(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
@ -525,7 +680,9 @@ export class PortfolioService {
grossPerformancePercentage:
position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(),
marketState: dataProviderResponses[position.symbol].marketState,
marketState:
dataProviderResponses[position.symbol]?.marketState ??
MarketState.delayed,
name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage:
@ -540,7 +697,7 @@ export class PortfolioService {
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -553,6 +710,7 @@ export class PortfolioService {
return {
hasErrors: false,
performance: {
annualizedPerformancePercent: 0,
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
@ -571,6 +729,8 @@ export class PortfolioService {
);
const hasErrors = currentPositions.hasErrors;
const annualizedPerformancePercent =
currentPositions.netAnnualizedPerformance.toNumber();
const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance =
currentPositions.grossPerformance.toNumber();
@ -583,34 +743,19 @@ export class PortfolioService {
return {
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
annualizedPerformancePercent,
currentGrossPerformance,
currentGrossPerformancePercent,
currentNetPerformance,
currentNetPerformancePercent,
currentValue: currentValue
currentValue
}
};
}
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public async getReport(impersonationId: string): Promise<PortfolioReport> {
const userId = await this.getUserId(impersonationId);
const baseCurrency = this.request.user.Settings.currency;
const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { orders, transactionPoints } = await this.getTransactionPoints({
userId
@ -624,7 +769,7 @@ export class PortfolioService {
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
currency
);
portfolioCalculator.setTransactionPoints(transactionPoints);
@ -637,10 +782,10 @@ export class PortfolioService {
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
const accounts = await this.getAccounts(
const accounts = await this.getValueOfAccounts(
orders,
portfolioItemsNow,
baseCurrency,
currency,
userId
);
return {
@ -660,7 +805,7 @@ export class PortfolioService {
accounts
)
],
{ baseCurrency }
{ baseCurrency: currency }
),
currencyClusterRisk: await this.rulesService.evaluate(
[
@ -681,90 +826,200 @@ export class PortfolioService {
currentPositions
)
],
{ baseCurrency }
{ baseCurrency: currency }
),
fees: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(),
this.getFees(orders)
this.getFees(orders).toNumber()
)
],
{ baseCurrency }
{ baseCurrency: currency }
)
}
};
}
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId);
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails(
userId,
currency
userCurrency
);
const orders = await this.orderService.getOrders({ userId });
const fees = this.getFees(orders);
const orders = await this.orderService.getOrders({
userCurrency,
userId
});
const dividend = this.getDividend(orders).toNumber();
const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber();
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const committedFunds = new Big(totalBuy).sub(totalSell);
const committedFunds = new Big(totalBuy).minus(totalSell);
const netWorth = new Big(balance)
.plus(performanceInformation.performance.currentValue)
.plus(items)
.toNumber();
return {
...performanceInformation.performance,
dividend,
fees,
firstOrderDate,
items,
netWorth,
totalBuy,
totalSell,
annualizedPerformancePercent:
performanceInformation.performance.annualizedPerformancePercent,
cash: balance,
committedFunds: committedFunds.toNumber(),
ordersCount: orders.length,
totalBuy: totalBuy,
totalSell: totalSell
ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
}).length
};
}
private async getCashPosition({
private async getCashPositions({
cashDetails,
investment,
userCurrency,
value
}: {
cashDetails: CashDetails;
investment: Big;
userCurrency: string;
value: Big;
}) {
const cashValue = new Big(cashDetails.balance);
const cashPositions = {};
return {
allocationCurrent: cashValue.div(value).toNumber(),
allocationInvestment: cashValue.div(investment).toNumber(),
assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
countries: [],
currency: Currency.CHF,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: cashValue.toNumber(),
marketPrice: 0,
marketState: MarketState.open,
name: 'Cash',
netPerformance: 0,
netPerformancePercent: 0,
quantity: 0,
sectors: [],
symbol: ghostfolioCashSymbol,
transactionCount: 0,
value: cashValue.toNumber()
};
for (const account of cashDetails.accounts) {
const convertedBalance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
if (convertedBalance === 0) {
continue;
}
if (cashPositions[account.currency]) {
cashPositions[account.currency].investment += convertedBalance;
cashPositions[account.currency].value += convertedBalance;
} else {
cashPositions[account.currency] = {
allocationCurrent: 0,
allocationInvestment: 0,
assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
countries: [],
currency: account.currency,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: convertedBalance,
marketPrice: 0,
marketState: MarketState.open,
name: account.currency,
netPerformance: 0,
netPerformancePercent: 0,
quantity: 0,
sectors: [],
symbol: account.currency,
transactionCount: 0,
value: convertedBalance
};
}
}
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = new Big(
cashPositions[symbol].value
)
.div(value)
.toNumber();
cashPositions[symbol].allocationInvestment = new Big(
cashPositions[symbol].investment
)
.div(investment)
.toNumber();
}
return cashPositions;
}
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date and type dividend
return (
isBefore(date, new Date(order.date)) &&
order.type === TypeOfOrder.DIVIDEND
);
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date and type item
return (
isBefore(date, new Date(order.date)) &&
order.type === TypeOfOrder.ITEM
);
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
@ -795,15 +1050,22 @@ export class PortfolioService {
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
}> {
const orders = await this.orderService.getOrders({ includeDrafts, userId });
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const orders = await this.orderService.getOrders({
includeDrafts,
userCurrency,
userId,
types: ['BUY', 'SELL']
});
if (orders.length <= 0) {
return { transactionPoints: [], orders: [] };
}
const userCurrency = this.request.user.Settings.currency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
@ -815,7 +1077,7 @@ export class PortfolioService {
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
type: <OrderType>order.type,
type: order.type,
unitPrice: new Big(
this.exchangeRateDataService.toCurrency(
order.unitPrice,
@ -836,10 +1098,10 @@ export class PortfolioService {
};
}
private async getAccounts(
private async getValueOfAccounts(
orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency: Currency,
userCurrency: string,
userId: string
) {
const accounts: PortfolioDetails['accounts'] = {};
@ -851,46 +1113,40 @@ export class PortfolioService {
return accountId === account.id;
});
if (ordersByAccount.length <= 0) {
// Add account without orders
const balance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.name] = {
current: balance,
original: balance
};
continue;
}
const convertedBalance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.id] = {
balance: convertedBalance,
currency: account.currency,
current: convertedBalance,
name: account.name,
original: convertedBalance
};
for (const order of ordersByAccount) {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
order.currency,
userCurrency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
userCurrency
);
let currentValueOfSymbol =
order.quantity * portfolioItemsNow[order.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice;
if (order.type === 'SELL') {
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1;
}
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current +=
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original +=
accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[order.Account?.name || UNKNOWN_KEY] = {
accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
current: currentValueOfSymbol,
name: account.name,
original: originalValueOfSymbol
};
}
@ -900,19 +1156,19 @@ export class PortfolioService {
return accounts;
}
private async getUserId(aImpersonationId: string) {
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
aUserId
);
return impersonationUserId || this.request.user.id;
return impersonationUserId || aUserId;
}
private getTotalByType(
orders: OrderWithAccount[],
currency: Currency,
currency: string,
type: TypeOfOrder
) {
return orders

View File

@ -1,7 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
@Injectable()
export class RulesService {
@ -9,7 +8,7 @@ export class RulesService {
public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[],
aUserSettings: { baseCurrency: Currency }
aUserSettings: { baseCurrency: string }
) {
return aRules
.filter((rule) => {

View File

@ -1,3 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CacheModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
@ -17,9 +18,10 @@ import { RedisCacheService } from './redis-cache.service';
store: redisStore,
ttl: configurationService.get('CACHE_TTL')
})
})
}),
ConfigurationModule
],
providers: [ConfigurationService, RedisCacheService],
providers: [RedisCacheService],
exports: [RedisCacheService]
})
export class RedisCacheModule {}

View File

@ -21,9 +21,9 @@ export class RedisCacheService {
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, {
ttl: this.configurationService.get('CACHE_TTL')
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
});
}
}

View File

@ -1,11 +1,16 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpCode,
HttpException,
Inject,
Logger,
Post,
Req,
Res,
@ -21,16 +26,66 @@ import { SubscriptionService } from './subscription.service';
export class SubscriptionController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly subscriptionService: SubscriptionService
) {}
@Post('redeem-coupon')
@HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt'))
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
if (!this.request.user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let coupons =
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
[];
const isValid = coupons.some((coupon) => {
return coupon.code === couponCode;
});
if (!isValid) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.subscriptionService.createSubscription(this.request.user.id);
// Destroy coupon
coupons = coupons.filter((coupon) => {
return coupon.code !== couponCode;
});
await this.propertyService.put({
key: PROPERTY_COUPONS,
value: JSON.stringify(coupons)
});
Logger.log(
`Subscription for user '${this.request.user.id}' has been created with coupon`
);
return {
message: getReasonPhrase(StatusCodes.OK),
statusCode: StatusCodes.OK
};
}
@Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) {
await this.subscriptionService.createSubscription(
const userId = await this.subscriptionService.createSubscriptionViaStripe(
req.query.checkoutSessionId
);
Logger.log(`Subscription for user '${userId}' has been created via Stripe`);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
}
@ -46,7 +101,7 @@ export class SubscriptionController {
userId: this.request.user.id
});
} catch (error) {
console.error(error);
Logger.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),

View File

@ -1,14 +1,15 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service';
@Module({
imports: [],
controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService],
exports: [SubscriptionService]
exports: [SubscriptionService],
imports: [ConfigurationModule, PrismaModule, PropertyModule],
providers: [SubscriptionService]
})
export class SubscriptionModule {}

View File

@ -1,8 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Subscription } from '@prisma/client';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription, User } from '@prisma/client';
import { addDays, isBefore } from 'date-fns';
import Stripe from 'stripe';
@ -64,28 +64,34 @@ export class SubscriptionService {
};
}
public async createSubscription(aCheckoutSessionId: string) {
public async createSubscription(aUserId: string) {
await this.prismaService.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: aUserId
}
}
}
});
}
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try {
const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId
);
await this.prismaService.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: session.client_reference_id
}
}
}
});
await this.createSubscription(session.client_reference_id);
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
});
return session.client_reference_id;
} catch (error) {
console.error(error);
Logger.error(error);
}
}

View File

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

View File

@ -1,7 +1,9 @@
import { Currency, DataSource } from '@prisma/client';
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataSource } from '@prisma/client';
export interface SymbolItem {
currency: Currency;
currency: string;
dataSource: DataSource;
historicalData: HistoricalDataItem[];
marketPrice: number;
}

View File

@ -1,17 +1,19 @@
import { RequestWithUser } from '@ghostfolio/common/types';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import {
Controller,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash';
import { isDate, isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
@ -19,22 +21,19 @@ import { SymbolService } from './symbol.service';
@Controller('symbol')
export class SymbolController {
public constructor(
private readonly symbolService: SymbolService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public constructor(private readonly symbolService: SymbolService) {}
/**
* Must be before /:symbol
*/
@Get('lookup')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol(
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try {
const encodedQuery = encodeURIComponent(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
return this.symbolService.lookup(query.toLowerCase());
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
@ -46,10 +45,26 @@ export class SymbolController {
/**
* Must be after /lookup
*/
@Get(':symbol')
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> {
const result = await this.symbolService.get(symbol);
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string,
@Query('includeHistoricalData') includeHistoricalData?: number
): Promise<SymbolItem> {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const result = await this.symbolService.get({
includeHistoricalData,
dataGatheringItem: { dataSource, symbol }
});
if (!result || isEmpty(result)) {
throw new HttpException(
@ -60,4 +75,27 @@ export class SymbolController {
return result;
}
@Get(':dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString);
if (!isDate(date)) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
return this.symbolService.getForDate({
dataSource,
date,
symbol
});
}
}

View File

@ -1,5 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
@ -7,8 +8,14 @@ import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service';
@Module({
imports: [ConfigurationModule, DataProviderModule, PrismaModule],
controllers: [SymbolController],
exports: [SymbolService],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
],
providers: [SymbolService]
})
export class SymbolModule {}

View File

@ -1,7 +1,14 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Currency, DataSource } from '@prisma/client';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
@ -10,24 +17,70 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService
private readonly marketDataService: MarketDataService
) {}
public async get(aSymbol: string): Promise<SymbolItem> {
const response = await this.dataProviderService.get([aSymbol]);
const { currency, dataSource, marketPrice } = response[aSymbol] ?? {};
public async get({
dataGatheringItem,
includeHistoricalData
}: {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: number;
}): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[] = [];
if (includeHistoricalData > 0) {
const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
symbols: [dataGatheringItem.symbol]
});
historicalData = marketData.map(({ date, marketPrice: value }) => {
return {
value,
date: date.toISOString()
};
});
}
if (currency && dataSource && marketPrice) {
return {
dataSource,
currency,
historicalData,
marketPrice,
currency: <Currency>(<unknown>currency)
dataSource: dataGatheringItem.dataSource
};
}
return undefined;
}
public async getForDate({
dataSource,
date,
symbol
}: {
dataSource: DataSource;
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
date,
date
);
return {
marketPrice:
historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
};
}
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] };
@ -38,35 +91,9 @@ export class SymbolService {
try {
const { items } = await this.dataProviderService.search(aQuery);
results.items = items;
// Add custom symbols
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
AND: [
{
dataSource: DataSource.GHOSTFOLIO,
name: {
startsWith: aQuery
}
}
]
}
});
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
results.items.push(ghostfolioSymbolProfile);
}
return results;
} catch (error) {
console.error(error);
Logger.error(error);
throw error;
}

View File

@ -1,4 +1,7 @@
import { Role } from '@prisma/client';
export interface UserItem {
accessToken?: string;
authToken: string;
role: Role;
}

View File

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

View File

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

View File

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

View File

@ -1,10 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -20,7 +23,7 @@ import {
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { Provider, Role } from '@prisma/client';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -34,7 +37,9 @@ import { UserService } from './user.service';
@Controller('user')
export class UserController {
public constructor(
private readonly configurationService: ConfigurationService,
private jwtService: JwtService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -43,10 +48,7 @@ export class UserController {
@UseGuards(AuthGuard('jwt'))
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteUser
) ||
!hasPermission(this.request.user.permissions, permissions.deleteUser) ||
id === this.request.user.id
) {
throw new HttpException(
@ -68,12 +70,28 @@ export class UserController {
@Post()
public async signupUser(): Promise<UserItem> {
const { accessToken, id } = await this.userService.createUser({
provider: Provider.ANONYMOUS
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
const isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
if (isReadOnlyMode) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({
role: hasAdmin ? 'USER' : 'ADMIN'
});
return {
accessToken,
role,
authToken: this.jwtService.sign({
id
})
@ -85,7 +103,7 @@ export class UserController {
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
this.request.user.permissions,
permissions.updateUserSettings
)
) {
@ -100,6 +118,12 @@ export class UserController {
...data
};
for (const key in userSettings) {
if (userSettings[key] === false) {
delete userSettings[key];
}
}
return await this.userService.updateUserSetting({
userSettings,
userId: this.request.user.id
@ -111,7 +135,7 @@ export class UserController {
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
this.request.user.permissions,
permissions.updateUserSettings
)
) {
@ -127,10 +151,7 @@ export class UserController {
};
if (
hasPermission(
getPermissions(this.request.user.role),
permissions.updateViewMode
)
hasPermission(this.request.user.permissions, permissions.updateViewMode)
) {
userSettings.viewMode = data.viewMode;
}

View File

@ -1,6 +1,7 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@ -8,15 +9,18 @@ import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
controllers: [UserController],
exports: [UserService],
imports: [
ConfigurationModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
}),
PrismaModule,
PropertyModule,
SubscriptionModule
],
controllers: [UserController],
providers: [ConfigurationService, PrismaService, UserService],
exports: [UserService]
providers: [UserService]
})
export class UserModule {}

View File

@ -1,12 +1,21 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { locale } from '@ghostfolio/common/config';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_IS_READ_ONLY_MODE,
baseCurrency,
locale
} from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import {
getPermissions,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
@ -15,11 +24,12 @@ const crypto = require('crypto');
@Injectable()
export class UserService {
public static DEFAULT_CURRENCY = Currency.USD;
public static DEFAULT_CURRENCY = 'USD';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService
) {}
@ -60,6 +70,18 @@ export class UserService {
};
}
public async hasAdmin() {
const usersWithAdminRole = await this.users({
where: {
role: {
equals: 'ADMIN'
}
}
});
return usersWithAdminRole.length > 0;
}
public isRestrictedView(aUser: UserWithSettings) {
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
}
@ -74,12 +96,32 @@ export class UserService {
const user: UserWithSettings = userFromDatabase;
const currentPermissions = getPermissions(userFromDatabase.role);
let currentPermissions = getPermissions(userFromDatabase.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.toggleReadOnlyMode);
}
const isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
if (isReadOnlyMode) {
currentPermissions = currentPermissions.filter((permission) => {
return !(
permission.startsWith('create') ||
permission.startsWith('delete') ||
permission.startsWith('update')
);
});
}
}
user.permissions = currentPermissions;
if (userFromDatabase?.Settings) {
@ -119,7 +161,7 @@ export class UserService {
take?: number;
cursor?: Prisma.UserWhereUniqueInput;
where?: Prisma.UserWhereInput;
orderBy?: Prisma.UserOrderByInput;
orderBy?: Prisma.UserOrderByWithRelationInput;
}): Promise<User[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.user.findMany({
@ -138,20 +180,30 @@ export class UserService {
return hash.digest('hex');
}
public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
if (!data?.provider) {
data.provider = 'ANONYMOUS';
}
let user = await this.prismaService.user.create({
data: {
...data,
Account: {
create: {
currency: baseCurrency,
isDefault: true,
name: 'Default Account'
}
},
Settings: {
create: {
currency: baseCurrency
}
}
}
});
if (data.provider === Provider.ANONYMOUS) {
if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(
user.id,
this.getRandomString(10)

View File

@ -0,0 +1,39 @@
import { decodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ConfigurationService } from '../services/configuration.service';
@Injectable()
export class TransformDataSourceInRequestInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor(
private readonly configurationService: ConfigurationService
) {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
const http = context.switchToHttp();
const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
if (request.body.dataSource) {
request.body.dataSource = decodeDataSource(request.body.dataSource);
}
if (request.params.dataSource) {
request.params.dataSource = decodeDataSource(request.params.dataSource);
}
}
return next.handle();
}
}

View File

@ -0,0 +1,86 @@
import { encodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ConfigurationService } from '../services/configuration.service';
@Injectable()
export class TransformDataSourceInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor(
private readonly configurationService: ConfigurationService
) {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
if (data.activities) {
data.activities.map((activity) => {
activity.SymbolProfile.dataSource = encodeDataSource(
activity.SymbolProfile.dataSource
);
activity.dataSource = encodeDataSource(activity.dataSource);
return activity;
});
}
if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource);
}
if (data.holdings) {
for (const symbol of Object.keys(data.holdings)) {
if (data.holdings[symbol].dataSource) {
data.holdings[symbol].dataSource = encodeDataSource(
data.holdings[symbol].dataSource
);
}
}
}
if (data.items) {
data.items.map((item) => {
item.dataSource = encodeDataSource(item.dataSource);
return item;
});
}
if (data.orders) {
data.orders.map((order) => {
order.dataSource = encodeDataSource(order.dataSource);
return order;
});
}
if (data.positions) {
data.positions.map((position) => {
position.dataSource = encodeDataSource(position.dataSource);
return position;
});
}
if (data.SymbolProfile) {
data.SymbolProfile.dataSource = encodeDataSource(
data.SymbolProfile.dataSource
);
}
}
return data;
})
);
}
}

View File

@ -2,6 +2,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@ -18,8 +19,21 @@ async function bootstrap() {
const port = process.env.PORT || 3333;
await app.listen(port, () => {
logLogo();
Logger.log(`Listening at http://localhost:${port}`);
Logger.log('');
});
}
function logLogo() {
Logger.log(' ________ __ ____ ___');
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___');
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\');
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /');
Logger.log(
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`
);
Logger.log('');
}
bootstrap();

View File

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

View File

@ -1,8 +0,0 @@
export enum OrderType {
CorporateAction = 'CORPORATE_ACTION',
Bonus = 'BONUS',
Buy = 'BUY',
Dividend = 'DIVIDEND',
Sell = 'SELL',
Split = 'SPLIT'
}

View File

@ -1,12 +1,11 @@
import { Account, Currency, SymbolProfile } from '@prisma/client';
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
import { OrderType } from './order-type';
export class Order {
private account: Account;
private currency: Currency;
private currency: string;
private fee: number;
private date: string;
private id: string;
@ -15,7 +14,7 @@ export class Order {
private symbol: string;
private symbolProfile: SymbolProfile;
private total: number;
private type: OrderType;
private type: TypeOfOrder;
private unitPrice: number;
public constructor(data: IOrder) {

View File

@ -3,7 +3,6 @@ import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.in
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface';
@ -29,7 +28,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
public groupCurrentPositionsByAttribute(
positions: TimelinePosition[],
attribute: keyof TimelinePosition,
baseCurrency: Currency
baseCurrency: string
) {
return Array.from(groupBy(attribute, positions).entries()).map(
([attributeValue, objs]) => ({

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