Compare commits

..

364 Commits

Author SHA1 Message Date
cc16ba5dc8 Release 1.169.0 (#1077) 2022-07-14 16:31:03 +02:00
d10227bc39 Feature/add support for luna2 and songbird cryptocurrencies (#1075)
* Add LUNA2 and SGB1

* Update changelog
2022-07-14 16:28:50 +02:00
4e214c32e8 Feature/update cryptocurrencies.json 20220714 (#1074)
* Update cryptocurrencies.json

* Update changelog
2022-07-14 16:27:32 +02:00
49e2862e03 Feature/add blog post about personal finances (#1073)
* Add blog post

* Update changelog
2022-07-14 16:16:07 +02:00
34e33a2400 Feature/upgrade date fns to version 2.28.0 (#1070)
* Upgrade date-fns

* Update changelog
2022-07-11 19:39:03 +02:00
ec9bc984af Release 1.168.0 (#1071) 2022-07-10 22:25:58 +02:00
2388c494df Feature/handle currency pair inconsistency in yahoo finance service (#1069)
* Handle occasional currency pair inconsistency: GBP=X instead of USDGBP=X

* Update changelog
2022-07-10 22:24:27 +02:00
d71ab10eed Bugfix/fix content height of account detail dialog (#1068)
* Fix height

* Update changelog
2022-07-10 21:44:23 +02:00
0e0592180f Add current month (#1067) 2022-07-10 09:41:48 +02:00
60e2aff488 Extend investment timeline by month (#1066)
* Extend investment timeline grouped by month

* Update changelog
2022-07-09 21:18:05 +02:00
7b5454e7de Release 1.167.0 (#1065) 2022-07-07 21:08:32 +02:00
30835ced88 Bugfix/fix holdings for basic users (#1064)
* Fix holdings for basic users

* Update changelog
2022-07-07 21:06:12 +02:00
8897f32bc5 Clean up modules (#1063) 2022-07-07 07:04:23 +02:00
abaa6b5f27 Feature/improve create account link in live demo (#1061)
* Improve router link

* Update changelog
2022-07-06 17:49:19 +02:00
2060fcaf0b Feature/add markets to public pages (#1062)
* Add Markets to public pages

* Update changelog
2022-07-05 21:45:27 +02:00
fd2408dd62 fix: add git when building docker image (#1052) 2022-07-03 19:57:04 +02:00
31cca024f1 Feature/upgrade ngx markdown to version 14.0.1 (#1055)
* Upgrade ngx-markdown

* Update changelog
2022-07-02 10:58:46 +02:00
b535122945 Make use of demo route (#1060) 2022-07-01 20:07:49 +02:00
5113e4e3ad Release 1.166.0 (#1059) 2022-06-30 21:09:58 +02:00
35e039748f Feature/refactor demo account as route (#1058)
* Refactor demo account as route

* Update changelog
2022-06-30 21:07:35 +02:00
c6b9e0aa5b Feature/upgrade zone.js to version 0.11.6 (#1054)
* Upgrade zone.js

* Update changelog
2022-06-30 19:38:33 +02:00
b250491ca5 Feature/upgrade yahoo finance2 to version 2.3.3 (#1053)
* Upgrade yahoo-finance2 to version 2.3.3

* Update changelog
2022-06-30 08:04:39 +02:00
61e501c659 Feature/fix version of @angular/cli (#1056)
* Fix version

* Update yarn.lock
2022-06-29 21:07:55 +02:00
c0f19d56ec Feature/add account detail dialog (#1047)
* Add account detail dialog

* Update changelog
2022-06-28 21:08:34 +02:00
8e2b235b1f Feature/improve search label (#1048)
* Improve search label

* Update changelog
2022-06-28 13:33:59 +02:00
c3407e9b34 Feature/upgrade prisma to version 3.15.2 (#1046)
* Upgrade prisma to version 3.15.2

* Update changelog
2022-06-25 19:04:01 +02:00
74193e4ee2 Feature/upgrade nestjs dependencies to version 8.4.7 (#1045)
* Upgrade nestjs dependencies to version 8.4.7

* Update changelog
2022-06-25 19:03:25 +02:00
3fe8f9c882 Release 1.165.0 (#1044) 2022-06-25 17:34:06 +02:00
d130efad47 Clean up comments (#1043)
* Clean up comments
2022-06-25 17:30:43 +02:00
109f0ebd70 Feature/move positions table to holdings section (#1042)
* Move positions table to holdings section

* Update changelog
2022-06-25 14:47:20 +02:00
069ddcc6b2 Feature/add reusable premium indicator component (#1041)
* Add premium indicator component

* Update changelog
2022-06-25 12:38:15 +02:00
f7bf6e652b Feature/add icon and name to positions table (#1040)
* Add icon and name

* Update changelog
2022-06-25 11:33:59 +02:00
eb059a024a Feature/delete data in data gathering by symbol (#1039)
* Delete market data

* Update changelog
2022-06-25 11:15:01 +02:00
ad88acff1c Release 1.164.0 (#1038) 2022-06-23 19:25:46 +02:00
1ff736537c Feature/add positions table to public page (#1037)
* Add positions table

* Update changelog
2022-06-23 19:24:24 +02:00
1fa65e1efd Release 1.163.0 (#1036) 2022-06-22 20:32:06 +02:00
df6bb489c2 Feature/improve onboarding for ios (#1035)
* Improve onboarding for iOS

* Update changelog
2022-06-22 20:30:03 +02:00
928a13310d Improve title (#1031) 2022-06-21 17:58:13 +02:00
2384861953 Remove experimental (#1032) 2022-06-20 20:32:51 +02:00
fe90bda6fb Release 1.162.0 (#1029) 2022-06-18 17:48:54 +02:00
d4b29ff11c Feature/add privacy policy page (#1028)
* Add privacy policy page

* Update changelog
2022-06-18 17:46:51 +02:00
a0a26cfa58 Feature/simplify header (#1027)
* Hide pricing page link for Premium users

* Harmonize content

* Update changelog
2022-06-18 11:57:27 +02:00
1610150427 Bugfix/fix currency conversion of ila to ils (#1026)
* Fix currency conversion: ILA to ILS

* Update changelog
2022-06-17 20:34:41 +02:00
cff8acd7b1 Clean up (#1022) 2022-06-17 20:04:52 +02:00
0f36d6cbdb Release 1.161.1 (#1025) 2022-06-16 17:20:44 +02:00
046e28b521 Release 1.161.0 (#1024) 2022-06-16 16:51:57 +02:00
aba562cb35 Bugfix/fix error handling for missing market prices (#1023)
* Add fallback for missing market price

* Update changelog
2022-06-16 16:49:29 +02:00
03f2f33344 Feature/restructure landing page (#1021)
* Restructure landing page

* Update changelog
2022-06-16 16:29:35 +02:00
a996dd7ed5 Feature/add vertical hover line to performance chart (#1020)
* Add vertical hover line

* Update changelog
2022-06-16 14:16:53 +02:00
002b883668 Feature/upgrade to angular 14 (#1019)
* Upgrade to angular 14

* Migrate UntypedFormControl to FormControl

* Update changelog
2022-06-16 10:28:23 +02:00
0b06823893 Release 1.160.0 (#1018) 2022-06-15 20:18:13 +02:00
2dfd779444 Bugfix/fix no data provider has been found in search (#1017)
* Fix default value of DATA_SOURCES

* Update changelog
2022-06-15 20:16:38 +02:00
1824413379 Release 1.159.0 (#1015) 2022-06-15 13:24:07 +02:00
3332ade3d3 Feature/filter public endpoint by equity asset class (#1014)
* Filter by ASSET_CLASS: EQUITY

* Update changelog
2022-06-15 13:21:47 +02:00
8d2e110e3d Feature/change default host (#1013)
* Change default host to 0.0.0.0

* Update changelog
2022-06-14 17:43:42 +02:00
a8fcf09380 Fix link (#1012) 2022-06-13 17:44:55 +02:00
1071f446a8 Release 1.158.1 (#1010) 2022-06-12 19:21:02 +02:00
03b050d1ac Release 1.158.0 (#1009) 2022-06-12 17:53:09 +02:00
58eeff7001 Feature/expose the environment variable host (#1007)
* Expose environment variable: HOST

* Update changelog
2022-06-12 17:51:26 +02:00
76fb8825e4 Feature/restructure self hosting documentation (#1008)
* Restructure self-hosting documentation

* Update changelog
2022-06-12 17:50:45 +02:00
0f9d142afe Feature/decrease number of attempts of queue jobs (#1006)
* Decrease number of attempts

* Update changelog
2022-06-12 17:39:55 +02:00
bd33855a27 Feature/improve message for data provider errors (#1003)
* Add title

* Update changelog
2022-06-12 17:39:02 +02:00
5329e45e2c Feature/add data dialog to queue jobs view (#1005)
* Add data dialog

* Update changelog
2022-06-12 17:30:26 +02:00
e990ecd12c Feature/improve cash balance label (#1002)
* Improve label

* Update changelog
2022-06-12 10:06:18 +02:00
a4fcf64f13 Release 1.157.0 (#1000) 2022-06-11 13:41:39 +02:00
557e3a0676 Feature/migrate historical market data gathering to queue design pattern (#991)
* Migrate historical market data gathering to queue

* Filter and delete jobs

* Detect duplicate jobs

* Update changelog
2022-06-11 13:40:15 +02:00
2abe399ebd Bugfix/force reload accounts of user after change (#994)
* Force reload of accounts after change

* Update changelog
2022-06-11 12:56:34 +02:00
74fe90906a Bugfix/exclude emtpy items in activities filter (#999)
* Exclude empty items

* Update changelog
2022-06-11 12:55:32 +02:00
4cb9a3b142 Feature/refresh cryptocurrencies list (#998)
* Use cryptocurrencies list instead of outdated npm package

* Update changelog
2022-06-11 12:54:58 +02:00
0da9368e0c Fix date (#992) 2022-06-10 17:42:56 +02:00
d2f8e3d645 Feature/increase fear and greed index to 180 days (#993)
* Increase number of days to 180

* Update changelog
2022-06-09 18:56:29 +02:00
5263fba64e Feature/upgrade envalid to version 7.3.1 (#990)
* Upgrade envalid to version 7.3.1

* Update changelog
2022-06-06 17:42:32 +02:00
e3689c48f8 Feature/upgrade chart.js to version 3.8.0 (#989)
* Upgrade chart.js to version 3.8.0

* Update changelog
2022-06-06 08:27:34 +02:00
787efdb33b Release 1.156.0 (#988) 2022-06-05 19:03:11 +02:00
e63578d8ce Feature/add guards to local comparison (#986)
* Add guards

* Improve labels
2022-06-05 19:00:45 +02:00
7cf0cdc4ce Feature/add jobs of queue to admin control panel (#987)
* Add jobs of queue to admin control panel

* Update changelog
2022-06-05 19:00:20 +02:00
14a0eeab29 Bugfix/fix docker compose files to resolve variables correctly (#983)
* Fix variable resolving

* Update changelog
2022-06-04 10:02:38 +02:00
6774c48dff Feature/restructure fire page (#982)
* Restructure fire page

* Update changelog
2022-06-04 09:06:53 +02:00
565947e752 Feature/upgrade simplewebauthn browser and server to version 5.2.1 (#981)
* Upgrade @simplewebauthn/browser and @simplewebauthn/server

* Update changelog
2022-06-03 18:44:53 +02:00
2cc7c6fa1c Feature/add user id to account page (#980)
* Add user id

* Update changelog
2022-06-03 06:51:08 +02:00
023a7147e2 Feature/simplify feature page (#978)
* Simplify page

* Update changelog
2022-05-31 18:01:29 +02:00
a96e89a86e Release 1.155.0 (#977) 2022-05-29 15:39:57 +02:00
b9c9443899 Bugfix/fix empty state of proportion chart (#976)
* Fix empty state (chart with two levels)

* Update changelog
2022-05-29 15:38:13 +02:00
f1e06347d3 Feature/add data source eod historical data (#974)
* Add EOD Historical Data as a data source

* Update changelog
2022-05-29 15:37:40 +02:00
697e92f818 Feature/finalize exposing redis password env variable (#975)
* Add hints

* Update changelog
2022-05-29 14:54:53 +02:00
b678998801 Feature/add-redis-password (#947)
* Expose REDIS_PASSWORD
2022-05-29 14:18:57 +02:00
de53cf1884 Release 1.154.0 (#973) 2022-05-28 21:12:42 +02:00
bbe30218bd Feature/remove dependency round to (#972)
* Remove round-to dependency

* Update changelog
2022-05-28 21:10:45 +02:00
15dda886a0 Feature/add vertical hover line to line chart component (#963)
* Add vertical hover line

* Improve tooltips of charts

* Update changelog
2022-05-28 20:53:54 +02:00
34d4212f55 Feature/modernize pricing page (#967)
* Simplify pricing page

* Update changelog
2022-05-28 18:52:30 +02:00
f7060230b7 Update dates (#966) 2022-05-28 18:46:22 +02:00
0fdafcb7e4 Release 1.153.0 (#962) 2022-05-27 11:53:18 +02:00
e79be9f2d6 Feature/do not tweet on weekend (#961)
* Do not tweet on the weekend

* Update changelog
2022-05-27 11:37:48 +02:00
69088b93a6 Feature/add value redaction as interceptor (#960)
* Add value redaction as interceptor

* Update changelog
2022-05-27 11:21:47 +02:00
c3768a882d Feature/add benchmarks to twitter bot service (#959)
* Extend benchmarks with market condition and adapt twitter bot service

* Update changelog
2022-05-27 10:03:37 +02:00
3498ed8549 Feature/upgrade prisma to version 3.14.0 (#958)
* Upgrade prisma dependencies to version 3.14.0

* Update changelog
2022-05-27 09:50:38 +02:00
c07c300fef Move @simplewebauthn/typescript-types to devDependencies (#957) 2022-05-27 09:49:57 +02:00
c62a5af9eb Bugfix/fix width of skeleton loader in benchmark component (#956)
* Fix width

* Update changelog
2022-05-27 09:49:37 +02:00
0c04f10e19 Release 1.152.0 (#955) 2022-05-26 19:01:08 +02:00
2c4c16ec99 Feature/extend markets overview by benchmarks (#953)
* Add benchmarks to markets overview

* Update changelog
2022-05-26 18:59:29 +02:00
4711b0d1ed Improve instructions for Unraid (#954) 2022-05-26 17:59:09 +02:00
a8521e0ecf Update README.md (#943)
* Update README.md for Unraid users
2022-05-26 17:52:14 +02:00
424748ae90 Feature/add ghostfolio trailer to landing page (#952)
* Add link to Ghostfolio trailer

* Update changelog
2022-05-26 10:30:13 +02:00
9c4d8bdf4b Release 1.151.0 (#949) 2022-05-24 20:58:24 +02:00
332203b9e2 Feature/add support to set the base currency via env variable (#948)
* Set base currency via environment variable

* Update changelog
2022-05-24 20:55:55 +02:00
f48832c671 Bugfix/add missing conversion of countries (#941)
* Add missing conversion of countries for SymbolProfileOverrides

* Update changelog
2022-05-23 18:04:09 +02:00
ae8a203526 Add type (#939) 2022-05-22 21:14:22 +02:00
d0c1506ded Release 1.150.0 (#940) 2022-05-21 20:00:34 +02:00
af0863d193 Bugfix/fix currency conversion in accounts (#937)
* Fix currency conversion in accounts

* Update changelog
2022-05-21 19:58:47 +02:00
f5819cc399 Bugfix/fix countries in symbol profile overrides (#936)
* Fix countries

* Update changelog
2022-05-20 20:16:23 +02:00
977c5a9544 Feature/skip data enhancement if data is inaccurate (#935)
* Skip data enhancer if data is inaccurate

* Update changelog
2022-05-20 20:15:19 +02:00
b9cd42cd53 Move dependencies to devDependencies (#934) 2022-05-20 20:14:33 +02:00
379977008d Simplify intro text (#933) 2022-05-19 21:05:14 +02:00
38f9d54705 Release 1.149.0 (#927) 2022-05-16 21:50:43 +02:00
5cb6e5dec6 Feature/support filtering by asset class on the allocations page (#926)
* Support filtering by asset class

* Update changelog
2022-05-16 21:49:22 +02:00
4a123c38f2 Refactor placeholder (#925) 2022-05-16 21:17:58 +02:00
160335302a Feature/group filters by type (#922)
* Add groups to activities filter component

* Update changelog
2022-05-15 21:51:31 +02:00
f1483569a2 Release 1.148.0 (#921) 2022-05-14 13:55:25 +02:00
5391b88c42 Feature/add report data glitch button (#920)
* Add report data glitch button

* Update changelog
2022-05-14 13:53:43 +02:00
2b63f7e707 Feature/support enter to submit create or update transaction dialog form (#913)
* Support enter key press to submit form

* Update changelog
2022-05-14 10:56:07 +02:00
d5c96d1cb7 Bugfix/fix date picker date format (#912)
* Fix date picker date format

* Update changelog
2022-05-14 10:55:09 +02:00
1a4dc51825 Bugfix/fix state of delete account button (#911)
* Fix disable state

* Update changelog
2022-05-13 06:48:40 +02:00
d094bae7de Bugfix/fix issue in activities filter component with typing (#910)
* Handle filter (selecting) or search term (typing)

* Update changelog
2022-05-12 07:48:12 +02:00
57bf10e7e7 Release 1.147.0 (#904) 2022-05-10 21:25:25 +02:00
c1d460cead Improve filtering (#901) 2022-05-10 21:24:36 +02:00
dfa67b275c Feature/improve filtering on allocations page (#900)
* Include cash positions on allocations page (with no filtering)

* Update changelog
2022-05-10 19:22:57 +02:00
80862e5c2a Release 1.146.3 (#899) 2022-05-08 22:54:07 +02:00
904d4db219 Refactor build:all and build:dev scripts (#898) 2022-05-08 22:52:46 +02:00
10f13eec48 Release 1.146.2 (#897) 2022-05-08 22:18:00 +02:00
ea3a9d3b79 Feature/eliminate circular dependencies in common library (#896)
* Eliminate circular dependencies

* Update changelog
2022-05-08 22:16:47 +02:00
e55b05fe3d Release 1.146.1 (#895) 2022-05-08 16:57:23 +02:00
32dd76be5f Fix path to jest files (#894) 2022-05-08 16:55:14 +02:00
ff9b6bb4df Release 1.146.0 (#893) 2022-05-08 16:04:43 +02:00
5be95b7b63 Feature/simplify about page (#892)
* Simplify about page

* Update changelog
2022-05-08 16:02:44 +02:00
b3e07c8446 Feature/support permissions in fire calculator (#891)
* Support hasPermissionToUpdateUserSettings

* Update changelog
2022-05-08 15:59:19 +02:00
eb9cece4e4 Update browserslist database (#890) 2022-05-08 15:56:39 +02:00
b331f5f04d Feature/setup nx cloud (#889)
* Upgrade angular, Nx and storybook

* Setup Nx Cloud

* Update changelog
2022-05-08 15:52:21 +02:00
34cbdd7c2a Upgrade angular, Nx and storybook (#888)
* Upgrade angular, Nx and storybook

* Update changelog
2022-05-08 09:26:33 +02:00
57314d62ee Feature/improve allocations page with no filter (#887)
* Improve accounts for no filters

* Update changelog
2022-05-07 22:33:57 +02:00
40380346e6 Feature/setup bull queue system (#886)
* Setup @nestjs/bull and asset profile data gathering job

* Update changelog
2022-05-07 20:00:51 +02:00
5622c4cf7e Feature/harmonize no data available label (#885)
* Harmonize label for UNKNOWN_KEY

* Update changelog
2022-05-07 14:11:42 +02:00
21173bed21 Release 1.145.0 (#884) 2022-05-07 11:47:28 +02:00
16dd8f7652 Feature/refactor filters with interface (#883)
* Refactor filtering with an interface

* Filter by accounts

* Update changelog
2022-05-07 11:44:29 +02:00
ce6b5fb7cb Bugfix/fix tooltip in proportion chart after update (#882)
* Keep tooltip configuration up to date

* Update changelog
2022-05-01 08:38:57 +02:00
f6f62db830 Feature/add support for private equity (#881)
* Add support for private equity

* Update changelog
2022-04-30 22:16:13 +02:00
01103f3db4 Feature/add asset and asset sub class to wealth items form (#880)
* Add asset and asset sub class

* Update changelog
2022-04-30 21:47:10 +02:00
e9e9f1a124 Release 1.144.0 (#879) 2022-04-30 11:51:49 +02:00
751256f158 Feature/add support for real estate and precious metal (#878)
* Add support for real estate and precious metal

* Update changelog
2022-04-30 11:49:58 +02:00
c2a1cbd20f Feature/improve layout of position detail dialog (#877)
* Improve layout

* Update changelog
2022-04-30 10:48:02 +02:00
04044f8720 Support futures (#845)
* Support futures

* Upgrade yahoo-finance2 to version 2.3.2

* Update changelog
2022-04-30 09:55:24 +02:00
4dc76817ce Bugfix/fix import validation for numbers equal zero (#875)
* Fix import validation for numbers equal 0

* Update changelog
2022-04-29 13:10:45 +02:00
1f0bd5a7db Bugfix/fix color of spinner in filter component (#873)
* Fix color for dark mode

* Update changelog
2022-04-27 17:30:57 +02:00
b6cd007ad4 Release/1.143.0 (#871)
* Release 1.143.0
  * Improve filtering by tags
2022-04-26 22:31:53 +02:00
b4bc72c6f9 Release 1.142.0 (#869) 2022-04-25 22:41:02 +02:00
899fa0370e Feature/improve users table of admin control panel (#866)
* Improve users table

* Update changelog
2022-04-25 22:39:08 +02:00
da27504aa1 Add orderBy statement to make debugging easier (#868) 2022-04-25 22:37:56 +02:00
b7bbc029ac Feature/render tags in dialogs (#864)
* Render tags

* Update changelog
2022-04-25 22:37:34 +02:00
c61a415fb2 Bugfix/change date to utc in data gathering service (#867)
* Change date to UTC

* Update changelog
2022-04-25 18:12:42 +02:00
8ff811ed28 Release/1.141.1 (#863) 2022-04-24 17:27:00 +02:00
9a2ea0a4ed Release 1.141.0 (#862) 2022-04-24 16:24:21 +02:00
bad9d17c44 Setup allocations page and endpoint (#859)
* Setup tagging system

* Update changelog
2022-04-24 16:23:03 +02:00
ea89ca5734 Feature/simplify ids in database schema (#861)
* Simplify ids in database schema
  * Access
  * Order
  * Subscription

* Update changelog
2022-04-24 09:35:01 +02:00
8f61f7c169 Feature/extract activities table filter component (#858)
* Extract activities table component

* Update changelog
2022-04-23 19:22:20 +02:00
edca05f542 Feature/change get started url of public page (#857)
* Change url

* Update changelog
2022-04-23 16:48:23 +02:00
283f054ee2 Feature/upgrade prisma to version 3.12.0 (#856)
* Upgrade prisma to version 3.12.0

* Update changelog
2022-04-23 13:38:41 +02:00
e9a46cb224 Release 1.140.2 (#854) 2022-04-23 09:52:22 +02:00
4a75c6d483 Release 1.140.1 (#853) 2022-04-22 21:45:50 +02:00
bbe9183fb0 Release 1.140.0 (#852) 2022-04-22 21:29:08 +02:00
1b03ddc586 Feature/add symbol profile overrides model (#851)
* Add symbol profile overrides model

* Update changelog
2022-04-22 21:27:55 +02:00
beb12637ce Bugfix/fix total calculation for sell and dividend (#850)
* Fix calculation for sell and dividend activities

* Update changelog
2022-04-22 19:29:18 +02:00
20358d9105 Feature/persist savings rate (#849)
* Persist savings rate

* Update changelog
2022-04-21 23:07:19 +02:00
0e4c39d145 Feature/reuse value component in ghostfolio in numbers section (#846)
* Reuse value component

* Update changelog
2022-04-19 17:06:12 +02:00
83ebacbb06 Add Buy me a coffee link (#848) 2022-04-18 21:32:54 +02:00
7c58c5fb7f Setup funding.yml (#847) 2022-04-18 21:20:30 +02:00
f3271ab1ff Feature/upgrade yahoo finance2 to version 2.3.1 (#844)
* Upgrade yahoo-finance2 to version 2.3.1

* Update changelog
2022-04-18 17:10:00 +02:00
9f597cbff1 Release 1.139.0 (#843) 2022-04-18 11:59:16 +02:00
90efc2ac51 Feature/beautify etf names in asset profile (#842)
* Beautify ETF names

* Update changelog
2022-04-18 11:57:57 +02:00
056b318d86 Bugfix/fix end date in ics files (#841)
* Fix end date

* Update changelog
2022-04-18 11:31:16 +02:00
82ede2fe32 Bugfix/fix fear and greed data source (#840)
* Fix data source of Fear & Greed Index

* Update changelog
2022-04-18 10:49:02 +02:00
8ae041faa0 Bugfix/fix issue in fire calculator after changing investment horizon (#839)
* Properly update chart datasets and improve tooltip

* Update changelog
2022-04-18 10:31:16 +02:00
bd4608e521 Release 1.138.0 (#838) 2022-04-16 21:03:28 +02:00
0d8362ca8f Feature/separate deposit and savings in fire calculator (#837)
* Separate deposit and savings

* Update changelog
2022-04-16 21:01:55 +02:00
638ae3f7fa Feature/add boringly getting rich guide to resources page (#836)
* Add "Boringly Getting Rich" guide

* Update changelog
2022-04-16 15:35:31 +02:00
6e7cf0380b Feature/export single draft (#835)
* Export single draft

* Update changelog
2022-04-16 11:33:01 +02:00
ec2ecab751 Clean up name (#834) 2022-04-16 10:21:32 +02:00
598fe41b8c Release 1.137.0 (#831) 2022-04-15 18:58:33 +02:00
ba7c98d325 Add test case for BUY and SELL (partially) (#826)
* Add test case for BUY and SELL (partially)

* Fix investment calculation for sell activities

* Do not show total value if sell activity

* Update changelog
2022-04-15 18:56:23 +02:00
65e062ad26 Simplify search (#828)
* Simplify search

* Update changelog
2022-04-15 12:39:33 +02:00
8526b5a027 Feature/export draft activities as ics (#830)
* Export draft activities as ICS

* Update changelog
2022-04-15 10:53:40 +02:00
f1feb04f29 Release 1.136.0 (#829) 2022-04-13 07:46:35 +02:00
500e09d95a Bugfix/fix loading state in fire calculator (#824)
* Fix loading state

* Update changelog
2022-04-12 19:57:23 +02:00
aef91d3e30 Feature/improve label in summary (#827)
* Improve label

* Update changelog
2022-04-12 18:04:48 +02:00
70723f8d5f Bugfix/fix projected total amount in fire calculator (#825)
* Fix calculation of projected total amount

* Update changelog
2022-04-11 22:10:45 +02:00
6cfd052781 Release 1.135.0 (#823) 2022-04-10 20:03:39 +02:00
23f2ac472e Feature/add fire calculator (#822)
* Add fire calculator

* Update changelog
2022-04-10 20:02:31 +02:00
d5ba624403 Feature/add support for terra (#820)
* Add Terra (LUNA1-USD)

* Update changelog
2022-04-10 19:38:27 +02:00
9b49ed77f7 Feature/add support for thor chain (#819)
* Add THORChain (RUNE-USD)

* Update changelog
2022-04-09 20:16:36 +02:00
08405d14d5 Release 1.134.0 (#818) 2022-04-09 14:50:13 +02:00
56b169e1c4 Feature/make header background solid (#817)
* Remove alpha

* Update changelog
2022-04-09 10:28:07 +02:00
67f2b326f3 Switch to new calculation engine (#814)
* Switch to new calculation engine

* Clean up old portfolio calculation engine (#815)

* Rename new portfolio calculation engine (#816)

* Update changelog
2022-04-09 10:17:31 +02:00
3d3a6c1204 Feature/improve fire section (#813)
* Improve FIRE section

* Update changelog
2022-04-09 09:03:39 +02:00
bfc8f87d88 Release 1.133.0 (#812) 2022-04-07 22:15:09 +02:00
957200854c Feature/improve empty state of proportion chart (#811)
* Improve empty state

* Update changelog
2022-04-07 22:13:41 +02:00
6575440877 Bugfix/fix dates in value component (#810)
* Fix dates

* Update changelog
2022-04-07 17:20:12 +02:00
255af6a6e9 Release 1.132.1 (#809) 2022-04-06 22:02:40 +02:00
795a6a6799 Release 1.132.0 (#808) 2022-04-06 21:23:20 +02:00
2a854e2574 Various improvements (#807) 2022-04-06 21:21:53 +02:00
52d113e71f Feature/improve label of average price (#805)
* Improve label

* Update changelog
2022-04-06 18:02:21 +02:00
204c7360c3 Feature/prepare for localized date format (#803)
* Support localized date and number format

* Update changelog
2022-04-05 21:02:07 +02:00
fa41e25c8f Release/1.131.1 (#804)
* Add API version

* Update changelog
2022-04-04 07:30:17 +02:00
ba765b9de6 Release 1.131.0 (#797) 2022-04-02 19:32:47 +02:00
fa79196278 Feature/display values in base currency on mobile (#796)
* Display values in base currency on mobile

* Update changelog
2022-04-02 19:28:32 +02:00
d1230ca3ad Support coupon codes on Google Play (#795) 2022-04-02 18:47:53 +02:00
69a1316cfe Feature/upgrade prisma to 3.11.1 (#794)
* Upgrade prisma dependencies to version 3.11.1

* Update changelog
2022-04-02 17:38:47 +02:00
a256b783bc Feature/upgrade yahoo finance to 2.3.0 (#792)
* Upgrade yahoo-finance2

* Update changelog
2022-04-02 17:36:49 +02:00
ebbdd47fa2 Exclude google login callback endpoint from versioning (#793) 2022-04-02 17:36:15 +02:00
3d21e2eac6 Improve upgrade guide (#780) 2022-04-02 12:06:06 +02:00
bc117fe601 Fix port (#788) 2022-04-02 10:47:26 +02:00
65f6bcb166 Feature/harmonize algebraic sign of gross and net performance percent (#776)
* Harmonize algebraic sign

* Update changelog
2022-04-02 10:44:19 +02:00
b8c43ecf89 Add documentation for import API (#787) 2022-04-02 10:29:41 +02:00
1214127ec0 Feature/rename orders to activities in import and export (#786)
* Rename orders to activities

* Update changelog
2022-04-02 10:26:17 +02:00
e986310302 Improve price (#785) 2022-04-02 09:52:59 +02:00
6762572658 Feature/setup api versioning (#783)
* Setup API versioning

* Update changelog
2022-04-02 08:46:24 +02:00
eb77652d6a Clean up import (#784) 2022-04-02 08:03:17 +02:00
a7b59f4ec6 Extend prerequisites (#781) 2022-04-02 06:47:58 +02:00
dd71f2be45 Feature/improve pricing page (#778)
* Improve pricing page

* Update changelog
2022-04-01 19:58:33 +02:00
d530cb38fa Feature/add 7 days in coupon system (#777)
* Add 7 days

* Update changelog
2022-04-01 19:49:05 +02:00
16b79a7e60 Release 1.130.0 (#774) 2022-03-30 21:16:43 +02:00
7f0c98cae6 Feature/setup fire page with 4 percent rule (#773)
* Setup page for FIRE

* Update changelog
2022-03-30 21:14:49 +02:00
57e4163848 Feature/add 14 days in coupon system (#772)
* Add 14 days

* Update changelog
2022-03-29 20:49:44 +02:00
14773bf1aa Bugfix/fix duplicate currency conversion in account calculations (#771)
* Fix currency conversion (duplicate)

* Update changelog
2022-03-29 17:47:08 +02:00
1a8fc5757a Release 1.129.0 (#770) 2022-03-26 13:13:07 +01:00
b4848be914 Feature/add developed vs emerging markets calculation (#767)
* Add allocations by market

* Update changelog
2022-03-26 13:11:30 +01:00
2b4319454d Feature/add bonds and emergency fund to feature overview (#768)
* Add bonds and emergency fund

* Update changelog
2022-03-25 23:37:06 +01:00
e2faaf6faa Feature/add hover effect to page tabs (#764)
* Add hover effect

* Update changelog
2022-03-21 18:59:00 +01:00
86a1589834 Release/1.128.0 (#763) 2022-03-19 14:39:25 +01:00
9f67993c03 Feature/fix issue with recent transactions (#750)
* Fix percentage performance issue with recent transactions

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-03-19 14:33:43 +01:00
32fb3551dc Feature/add default market price to scraper configuration (#762)
* Add default market price to scraper configuration

* Update changelog
2022-03-19 12:17:28 +01:00
30411b1502 Feature/add hover to table (#760)
* Add hover

* Update changelog
2022-03-19 09:56:50 +01:00
eb0444603b Bugfix/fix user currency of public page (#761)
* Fix user currency

* Update changelog
2022-03-19 09:25:20 +01:00
6e582fe505 Release 1.127.0 (#759) 2022-03-16 22:08:48 +01:00
402d73a12c Bugfix/fix get quotes for multiple ghostfolio symbols (#758)
* Support multiple symbols in getQuotes()

* Update changelog
2022-03-16 22:07:18 +01:00
4826a51199 Feature/improve handling of scraper configuration (#757)
* Improve handling of missing scraper configuration

* Update changelog
2022-03-15 17:13:42 +01:00
5356bf568e Release 1.126.0 (#756) 2022-03-14 17:41:23 +01:00
d8da574ae4 Feature/add support for bonds (#755)
* Add support for bonds

* Update changelog
2022-03-14 17:39:09 +01:00
e769fabbae Feature/add multilines to tooltips in proportion chart (#753)
* Introduce multilines for tooltips

* Update changelog
2022-03-13 21:39:06 +01:00
5a369f29d4 Feature/restructure portfolio summary tab (#754)
* Restructure portfolio summary

* Update changelog
2022-03-13 21:01:15 +01:00
122ba9046f Add type (#751)
* Add type

* Refactor import to import type
2022-03-13 21:00:40 +01:00
f781eb207c Release 1.125.0 (#752) 2022-03-12 18:40:09 +01:00
7b6893b5ed Feature/add support for emergency fund (#749)
* Add support for emergency fund

* Update changelog
2022-03-12 13:44:47 +01:00
07799573cb Harmonize button style (#748) 2022-03-10 20:11:32 +01:00
9cdef6a7cb Feature/upgrade nx to version 13.8.5 (#747)
* Upgrade Nx to version 13.8.5

* Update changelog
2022-03-09 18:19:48 +01:00
0d897bc461 Improve table header (#746) 2022-03-08 20:15:59 +01:00
e4908b51aa Feature/add context to logger (#745)
* Add contexts

* Update changelog
2022-03-07 17:20:07 +01:00
718b0de0a7 Release 1.124.0 (#744) 2022-03-06 13:48:17 +01:00
99655604d9 Feature/add support for coupon duration (#743)
* Add support for coupon duration

* Update changelog
2022-03-06 12:26:04 +01:00
b602e7690b Feature/upgrade prisma to version 3.10.0 (#742)
* Upgrade prisma to version 3.10.0

* Update changelog
2022-03-06 12:06:07 +01:00
7745dafe48 Feature/upgrade yahoo finance2 to 2.2.0 (#741)
* Upgrade yahoo-finance2 to version 2.2.0

* Update changelog
2022-03-06 12:04:38 +01:00
50184284e1 Feature/upgrade ngx skeleton loader to 5.0.0 (#740)
* Upgrade ngx-skeleton-loader to version 5.0.0

* Update changelog
2022-03-06 10:47:30 +01:00
f46533107d Release 1.123.0 (#739) 2022-03-05 11:09:07 +01:00
c216ab1d76 Eliminate data source from order model (#730)
* Eliminate currency, data source and symbol from order model

* Remove prefix for symbols with data source GHOSTFOLIO

* Update changelog
2022-03-05 11:07:27 +01:00
86acbf06f4 Feature/add data provider errors to api response (#738)
* Add data provider error details

* Update changelog
2022-03-05 11:00:02 +01:00
3de7d3f60e Bugfix/improve account calculations (#737)
* Improve account calculations

* Update changelog
2022-03-04 21:31:31 +01:00
63ed227f3f Release 1.122.0 (#732) 2022-03-01 21:36:09 +01:00
5bb20f6d5f Bugfix/fix undefined currencies after creating an activity (#731)
* Fix issue with undefined currencies after creating an activity

* Update changelog
2022-03-01 21:32:19 +01:00
b3e58d182a Feature/add support for click in portfolio proportion chart (#729)
* Add support for click

* Update changelog
2022-02-28 21:35:52 +01:00
93d6746739 Refactoring (#727) 2022-02-28 20:42:14 +01:00
e3f8b0cf52 Release 1.121.0 (#726) 2022-02-27 17:07:30 +01:00
c02bcd9bd8 Feature/migrate to yahoo finance2 (#722)
* Migrate to yahoo-finance2

* Add support for mutual funds

* Add url to symbol profile

* Clean up
2022-02-27 17:03:00 +01:00
6a4f1c0188 Release 1.120.0 (#724) 2022-02-25 08:06:01 +01:00
745ba978a3 Bugfix/fix zen mode (#723)
* Fix Zen mode

* Update changelog
2022-02-25 08:04:42 +01:00
46b91d3c3b Feature/improve portfolio page (#721)
* Improve page

* Update changelog
2022-02-23 19:00:13 +01:00
1dd670a7c3 Feature/improve labels in portfolio proportion chart (#720)
* Improve labels for OTHER and UNKNOWN

* Update changelog
2022-02-22 21:12:02 +01:00
68d07cc8d4 Release 1.119.0 (#719) 2022-02-22 20:05:13 +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
466 changed files with 28243 additions and 14497 deletions

8
.env
View File

@ -3,14 +3,14 @@ COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
# POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
ACCESS_TOKEN_SALT=GHOSTFOLIO
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
JWT_SECRET_KEY=123456
PORT=3333
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: ['https://www.buymeacoffee.com/ghostfolio']

View File

@ -5,6 +5,909 @@ 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.169.0 - 14.07.2022
### Added
- Added support for the cryptocurrency _Songbird_ (`SGB1-USD`)
- Added support for the cryptocurrency _Terra 2.0_ (`LUNA2-USD`)
- Added a blog post
### Changed
- Refreshed the cryptocurrencies list to support more coins by default
- Upgraded `date-fns` from version `2.22.1` to `2.28.0`
## 1.168.0 - 10.07.2022
### Added
- Extended the investment timeline grouped by month
### Changed
- Handled an occasional currency pair inconsistency in the _Yahoo Finance_ service (`GBP=X` instead of `USDGBP=X`)
### Fixed
- Fixed the content height of the account detail dialog
## 1.167.0 - 07.07.2022
### Added
- Added _Markets_ to the public pages
### Changed
- Improved the _Create Account_ link in the _Live Demo_
- Upgraded `ngx-markdown` from version `13.0.0` to `14.0.1`
### Fixed
- Fixed an issue in the _Holdings_ section for users without a subscription
## 1.166.0 - 30.06.2022
### Added
- Added an account detail dialog
### Changed
- Improved the label of the (symbol) search
- Refactored the demo account as a route (`/demo`)
- Upgraded `nestjs` from version `8.2.3` to `8.4.7`
- Upgraded `prisma` from version `3.14.0` to `3.15.2`
- Upgraded `yahoo-finance2` from version `2.3.2` to `2.3.3`
- Upgraded `zone.js` from version `0.11.4` to `0.11.6`
## 1.165.0 - 25.06.2022
### Added
- Added an icon and name column to the positions table
- Added a reusable premium indicator component
### Changed
- Moved the positions table to a dedicated section (_Holdings_)
- Changed the data gathering by symbol endpoint to delete data first
## 1.164.0 - 23.06.2022
### Added
- Added the positions table including performance to the public page
## 1.163.0 - 22.06.2022
### Changed
- Improved the onboarding for iOS
## 1.162.0 - 18.06.2022
### Added
- Added a _Privacy Policy_ page
### Changed
- Simplified the header
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ILA` to `ILS`)
## 1.161.1 - 16.06.2022
### Added
- Added the vertical hover line to inspect data points in the performance chart on the home page
### Changed
- Improved the landing page
- Upgraded `angular` from version `13.3.6` to `14.0.2`
- Upgraded `Nx` from version `14.1.4` to `14.3.5`
- Upgraded `storybook` from version `6.4.22` to `6.5.9`
### Fixed
- Improved the error handling of missing market prices
## 1.160.0 - 15.06.2022
### Fixed
- Fixed the `No data provider has been found` error in the search (regression after `envalid` upgrade to `7.3.1` in Ghostfolio `1.157.0`)
## 1.159.0 - 15.06.2022
### Changed
- Changed the default `HOST` to `0.0.0.0`
- Refactored the endpoint of the public page (filter by equity)
## 1.158.1 - 12.06.2022
### Added
- Extended the queue jobs view in the admin control panel by a data dialog
### Changed
- Exposed the environment variable `HOST`
- Decreased the number of attempts of queue jobs from `20` to `10` (fail earlier)
- Improved the message for data provider errors in the client
- Changed the label from _Balance_ to _Cash Balance_ in the account dialog
- Restructured the documentation for self-hosting
## 1.157.0 - 11.06.2022
### Added
- Extended the queue jobs view in the admin control panel by the number of attempts and the status
### Changed
- Migrated the historical market data gathering to the queue design pattern
- Refreshed the cryptocurrencies list to support more coins by default
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 180 days
- Upgraded `chart.js` from version `3.7.0` to `3.8.0`
- Upgraded `envalid` from version `7.2.1` to `7.3.1`
### Fixed
- Reloaded the accounts of a user after creating, editing or deleting one
- Excluded empty items in the activities filter
## 1.156.0 - 05.06.2022
### Added
- Added the user id to the account page
- Added a new view with jobs of the queue to the admin control panel
### Changed
- Simplified the features page
- Restructured the _FIRE_ section
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `4.1.0` to `5.2.1`
### Fixed
- Fixed the `docker-compose` files to resolve variables correctly
## 1.155.0 - 29.05.2022
### Added
- Added `EOD_HISTORICAL_DATA` as a new data source type
### Changed
- Exposed the environment variable `REDIS_PASSWORD`
### Fixed
- Fixed the empty state of the portfolio proportion chart component (with 2 levels)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.154.0 - 28.05.2022
### Added
- Added a vertical hover line to inspect data points in the line chart component
### Changed
- Improved the tooltips of the chart components (content and style)
- Simplified the pricing page
- Improved the rounding numbers in the twitter bot service
- Removed the dependency `round-to`
## 1.153.0 - 27.05.2022
### Added
- Extended the benchmarks of the markets overview by the current market condition (bear and bull market)
- Extended the twitter bot service by benchmarks
- Added value redaction for the impersonation mode in the API response as an interceptor
### Changed
- Changed the twitter bot service to rest on the weekend
- Upgraded `prisma` from version `3.12.0` to `3.14.0`
### Fixed
- Fixed a styling issue in the benchmark component on mobile
## 1.152.0 - 26.05.2022
### Added
- Added the _Ghostfolio_ trailer to the landing page
- Extended the markets overview by benchmarks (current change to the all time high)
## 1.151.0 - 24.05.2022
### Added
- Added support to set the base currency as an environment variable (`BASE_CURRENCY`)
### Fixed
- Fixed an issue with the missing conversion of countries in the symbol profile overrides
## 1.150.0 - 21.05.2022
### Changed
- Skipped data enhancer (_Trackinsight_) if data is inaccurate
### Fixed
- Fixed an issue with the currency conversion in the account calculations
- Fixed an issue with countries in the symbol profile overrides
## 1.149.0 - 16.05.2022
### Added
- Added groups to the activities filter component
- Added support for filtering by asset class on the allocations page
## 1.148.0 - 14.05.2022
### Added
- Supported enter key press to submit the form of the create or edit transaction dialog
- Added a _Report Data Glitch_ button to the position detail dialog
### Fixed
- Fixed the date format of the date picker and support manual changes
- Fixed the state of the account delete button (disable if account contains activities)
- Fixed an issue in the activities filter component (typing a search term)
## 1.147.0 - 10.05.2022
### Changed
- Improved the allocations page with no filtering (include cash positions)
## 1.146.3 - 08.05.2022
### Added
- Set up a queue for the data gathering jobs
- Set up _Nx Cloud_
### Changed
- Migrated the asset profile data gathering to the queue design pattern
- Improved the allocations page with no filtering
- Harmonized the _No data available_ label in the portfolio proportion chart component
- Improved the _FIRE_ calculator for the _Live Demo_
- Simplified the about page
- Upgraded `angular` from version `13.2.2` to `13.3.6`
- Upgraded `Nx` from version `13.8.5` to `14.1.4`
- Upgraded `storybook` from version `6.4.18` to `6.4.22`
### Fixed
- Eliminated the circular dependencies in the `@ghostfolio/common` library
## 1.145.0 - 07.05.2022
### Added
- Added support for filtering by accounts on the allocations page
- Added support for private equity
- Extended the form to set the asset and asset sub class for (wealth) items
### Changed
- Refactored the filtering (activities table and allocations page)
### Fixed
- Fixed the tooltip update in the portfolio proportion chart component
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.144.0 - 30.04.2022
### Added
- Added support for commodities (via futures)
- Added support for real estate
### Changed
- Improved the layout of the position detail dialog
- Upgraded `yahoo-finance2` from version `2.3.1` to `2.3.2`
### Fixed
- Fixed the import validation for numbers equal 0
- Fixed the color of the spinner in the activities filter component (dark mode)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.143.0 - 26.04.2022
### Changed
- Improved the filtering by tags
## 1.142.0 - 25.04.2022
### Added
- Added the tags to the create or edit transaction dialog
- Added the tags to the position detail dialog
### Changed
- Changed the date to UTC in the data gathering service
- Reused the value component in the users table of the admin control panel
## 1.141.1 - 24.04.2022
### Added
- Added the database migration
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.141.0 - 24.04.2022
### Added
- Added a tagging system for activities
### Changed
- Extracted the activities table filter to a dedicated component
- Changed the url of the _Get Started_ link to `https://ghostfol.io` on the public page
- Simplified `@@id` using multiple fields with `@id` in the database schema of (`Access`, `Order`, `Subscription`)
- Upgraded `prisma` from version `3.11.1` to `3.12.0`
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.140.2 - 22.04.2022
### Added
- Added support for sub-labels in the value component
- Added a symbol profile overrides model for manual adjustments
### Changed
- Reused the value component in the _Ghostfolio in Numbers_ section of the about page
- Persisted the savings rate in the _FIRE_ calculator
- Upgraded `yahoo-finance2` from version `2.3.0` to `2.3.1`
### Fixed
- Fixed the calculation of the total value for sell and dividend activities in the create or edit transaction dialog
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.139.0 - 18.04.2022
### Added
- Added the total amount to the tooltip in the chart of the _FIRE_ calculator
### Changed
- Beautified the ETF names in the symbol profile
### Fixed
- Fixed an issue with changing the investment horizon in the chart of the _FIRE_ calculator
- Fixed an issue with the end dates in the `.ics` file of the future activities (drafts) export
- Fixed the data source of the _Fear & Greed Index_ (market mood)
## 1.138.0 - 16.04.2022
### Added
- Added support to export a single future activity (draft) as an `.ics` file
- Added the _Boringly Getting Rich_ guide to the resources section
### Changed
- Separated the deposit and savings in the chart of the _FIRE_ calculator
## 1.137.0 - 15.04.2022
### Added
- Added support to export future activities (drafts) as an `.ics` file
### Changed
- Migrated the search functionality to `yahoo-finance2`
### Fixed
- Fixed an issue in the average price / investment calculation for sell activities
## 1.136.0 - 13.04.2022
### Changed
- Changed the _Total_ label to _Total Assets_ in the portfolio summary tab on the home page
### Fixed
- Fixed an issue with the calculation of the projected total amount in the _FIRE_ calculator
- Fixed an issue with the loading state of the _FIRE_ calculator
## 1.135.0 - 10.04.2022
### Added
- Added a calculator to the _FIRE_ section
- Added support for the cryptocurrency _Terra_ (`LUNA1-USD`)
- Added support for the cryptocurrency _THORChain_ (`RUNE-USD`)
## 1.134.0 - 09.04.2022
### Changed
- Switched to the new calculation engine
- Improved the 4% rule in the _FIRE_ section
- Changed the background of the header to a solid color
## 1.133.0 - 07.04.2022
### Changed
- Improved the empty state of the portfolio proportion chart component
### Fixed
- Fixed an issue with dates in the value component
## 1.132.1 - 06.04.2022
### Fixed
- Fixed an issue with percentages in the value component
## 1.132.0 - 06.04.2022
### Added
- Added support for localization (date and number format) in user settings
### Changed
- Improved the label of the average price from _Ø Buy Price_ to _Average Unit Price_
## 1.131.1 - 04.04.2022
### Fixed
- Fixed the missing API version in the _Stripe_ success callback url
## 1.131.0 - 02.04.2022
### Added
- Added API versioning
- Added more durations in the coupon system
### Changed
- Display the value in base currency in the accounts table on mobile
- Display the value in base currency in the activities table on mobile
- Renamed `orders` to `activities` in import and export functionality
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
- Improved the pricing page
- Upgraded `prisma` from version `3.10.0` to `3.11.1`
- Upgraded `yahoo-finance2` from version `2.2.0` to `2.3.0`
## 1.130.0 - 30.03.2022
### Added
- Added a _FIRE_ (Financial Independence, Retire Early) section including the 4% rule
- Added more durations in the coupon system
### Fixed
- Fixed an issue with the currency conversion (duplicate) in the account calculations
## 1.129.0 - 26.03.2022
### Added
- Added the calculation for developed vs. emerging markets to the allocations page
- Added a hover effect to the page tabs
- Extended the feature overview page by _Bonds_ and _Emergency Fund_
## 1.128.0 - 19.03.2022
### Added
- Added the attribute `defaultMarketPrice` to the scraper configuration to improve the support for bonds
- Added a hover effect to the table style
### Fixed
- Fixed an issue with the user currency of the public page
- Fixed an issue of the performance calculation with recent activities in the new calculation engine
## 1.127.0 - 16.03.2022
### Changed
- Improved the error handling in the scraper configuration
### Fixed
- Fixed the support for multiple symbols of the data source `GHOSTFOLIO`
## 1.126.0 - 14.03.2022
### Added
- Added support for bonds
### Changed
- Restructured the portfolio summary tab on the home page
- Improved the tooltips in the portfolio proportion chart component by introducing multilines
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.125.0 - 12.03.2022
### Added
- Added support for an emergency fund
- Added the contexts to the logger commands
### Changed
- Upgraded `Nx` from version `13.8.1` to `13.8.5`
## 1.124.0 - 06.03.2022
### Added
- Added support for setting a duration in the coupon system
### Changed
- Upgraded `ngx-skeleton-loader` from version `2.9.1` to `5.0.0`
- Upgraded `prisma` from version `3.9.1` to `3.10.0`
- Upgraded `yahoo-finance2` from version `2.1.9` to `2.2.0`
## 1.123.0 - 05.03.2022
### Added
- Included data provider errors in the API response
### Changed
- Removed the redundant attributes (`currency`, `dataSource`, `symbol`) of the activity model
- Removed the prefix for symbols with the data source `GHOSTFOLIO`
### Fixed
- Improved the account calculations
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.122.0 - 01.03.2022
### Added
- Added support for click in the portfolio proportion chart component
### Fixed
- Fixed an issue with undefined currencies after creating an activity
## 1.121.0 - 27.02.2022
### Added
- Added support for mutual funds
- Added the url to the symbol profile model
### Changed
- Migrated from `yahoo-finance` to `yahoo-finance2`
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.120.0 - 25.02.2022
### Changed
- Distinguished the labels _Other_ and _Unknown_ in the portfolio proportion chart component
- Improved the portfolio entry page
### Fixed
- Fixed the _Zen Mode_
## 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.2`
- 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
@ -110,7 +1013,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support for cryptocurrency _Solana_ (`SOL-USD`)
- 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

View File

@ -12,7 +12,7 @@ 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 apk add --no-cache python3 g++ make openssl git
RUN yarn install
# See https://github.com/nrwl/nx/issues/6586 for further details
@ -23,7 +23,7 @@ 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 ./jest.config.ts jest.config.ts
COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps

161
README.md
View File

@ -9,10 +9,10 @@
<h1>Ghostfolio</h1>
<p>
<strong>Open Source Wealth Management Software made for Humans</strong>
<strong>Open Source Wealth Management Software</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://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>
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><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">
@ -24,38 +24,31 @@
</p>
</div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
<div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
</div>
## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
## 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
- 🙅 saying no to spreadsheets in 2022
- 😎 still reading this list
## Features
@ -65,10 +58,15 @@ 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
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
</div>
## Technology Stack
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
@ -81,66 +79,66 @@ 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).
## Run with Docker (self-hosting)
## Self-hosting
### Prerequisites
### Run with Docker Compose
- [Docker](https://www.docker.com/products/docker-desktop)
#### Prerequisites
### a. Run environment
- Basic knowledge of Docker
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
- Local copy of this Git repository (clone)
#### 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
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
```
#### Setup Database
##### 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
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
### b. Build and run environment
#### 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
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
```
#### Setup Database
##### 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
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
```
### Fetch Historical Data
#### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. 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_
### Finalization
#### Upgrade Version
1. Create a new user via _Get Started_
1. Assign the role `ADMIN` to this user (directly in the database)
1. Delete the original _Admin_ (directly in the database)
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
### Migrate Database
### Run with _Unraid_ (Community)
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
```
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
## Development
@ -149,14 +147,15 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)
### Setup
1. Run `yarn install`
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 `docker-compose --env-file ./.env -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 server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
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_
@ -175,18 +174,94 @@ Run `yarn start:client`
Run `yarn start:storybook`
### Migrate Database
With the following command you can keep your database schema in sync:
```bash
yarn database:push
```
## Testing
Run `yarn test`
## Public API
### Import Activities
#### Request
`POST http://localhost:3333/api/v1/import`
#### Authorization: Bearer Token
Set the header as follows:
```
"Authorization": "Bearer eyJh..."
```
#### Body
```
{
"activities": [
{
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-09-15T00:00:00.000Z",
"fee": 19,
"quantity": 5,
"symbol": "MSFT"
"type": "BUY",
"unitPrice": 298.58
}
]
}
```
| Field | Type | Description |
| ---------- | ------------------- | -------------------------------------------------- |
| accountId | string (`optional`) | Id of the account |
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` |
| fee | number | Fee of the activity |
| quantity | number | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
| unitPrice | number | Price per unit of the activity |
#### Response
##### Success
`201 Created`
##### Error
`400 Bad Request`
```
{
"error": "Bad Request",
"message": [
"activities.1 is a duplicate activity"
]
}
```
## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please 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.
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
## 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

@ -2,6 +2,7 @@
"version": 1,
"projects": {
"api": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/api",
"sourceRoot": "apps/api/src",
"projectType": "application",
@ -9,7 +10,7 @@
"schematics": {},
"architect": {
"build": {
"builder": "@nrwl/node:build",
"builder": "@nrwl/node:webpack",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
@ -33,7 +34,7 @@
"outputs": ["{options.outputPath}"]
},
"serve": {
"builder": "@nrwl/node:execute",
"builder": "@nrwl/node:node",
"options": {
"buildTarget": "api:build"
}
@ -47,7 +48,7 @@
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.js",
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/api"]
@ -56,6 +57,7 @@
"tags": []
},
"client": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
@ -113,7 +115,7 @@
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"],
"scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
@ -180,7 +182,7 @@
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.js",
"jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/client"]
@ -189,6 +191,7 @@
"tags": []
},
"client-e2e": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/client-e2e",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
@ -211,6 +214,7 @@
"implicitDependencies": ["client"]
},
"common": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "libs/common",
"sourceRoot": "libs/common/src",
"projectType": "library",
@ -225,7 +229,7 @@
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"],
"options": {
"jestConfig": "libs/common/jest.config.js",
"jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true
}
}
@ -233,6 +237,7 @@
"tags": []
},
"ui": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"schematics": {
"@schematics/angular:component": {
@ -247,7 +252,7 @@
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"],
"options": {
"jestConfig": "libs/ui/jest.config.js",
"jestConfig": "libs/ui/jest.config.ts",
"passWithNoTests": true
}
},
@ -258,13 +263,12 @@
}
},
"storybook": {
"builder": "@nrwl/storybook:storybook",
"builder": "@storybook/angular:start-storybook",
"options": {
"uiFramework": "@storybook/angular",
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
}
"configDir": "libs/ui/.storybook",
"browserTarget": "ui:build-storybook",
"compodoc": false
},
"configurations": {
"ci": {
@ -273,14 +277,13 @@
}
},
"build-storybook": {
"builder": "@nrwl/storybook:build",
"builder": "@storybook/angular:build-storybook",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/angular",
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
}
"outputDir": "dist/storybook/ui",
"configDir": "libs/ui/.storybook",
"browserTarget": "ui:build-storybook",
"compodoc": false
},
"configurations": {
"ci": {
@ -292,6 +295,7 @@
"tags": []
},
"ui-e2e": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/ui-e2e",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",

View File

@ -1,6 +1,6 @@
module.exports = {
export default {
displayName: 'api',
preset: '../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json'
@ -12,5 +12,6 @@ module.exports = {
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000,
testEnvironment: 'node'
testEnvironment: 'node',
preset: '../../jest.preset.js'
};

View File

@ -78,8 +78,12 @@ export class AccessController {
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
const access = await this.accessService.access({ id });
if (
!hasPermission(this.request.user.permissions, permissions.deleteAccess)
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
!access ||
access.userId !== this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -88,10 +92,7 @@ export class AccessController {
}
return this.accessService.deleteAccess({
id_userId: {
id,
userId: this.request.user.id
}
id
});
}
}

View File

@ -1,4 +1,4 @@
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';
@ -7,7 +7,7 @@ import { AccessService } from './access.service';
@Module({
controllers: [AccessController],
exports: [AccessService],
imports: [],
providers: [AccessService, PrismaService]
imports: [PrismaModule],
providers: [AccessService]
})
export class AccessModule {}

View File

@ -7,7 +7,10 @@ import {
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 type {
AccountWithValue,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -102,16 +105,18 @@ export class AccountController {
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalance',
'totalValue'
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value'
'value',
'valueInBaseCurrency'
])
};
}
@ -121,13 +126,45 @@ export class AccountController {
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
return this.accountService.account({
id_userId: {
id,
userId: this.request.user.id
}
});
public async getAccountById(
@Headers('impersonation-id') impersonationId,
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id,
[{ id, type: 'ACCOUNT' }]
);
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
])
};
}
return accountsWithAggregations.accounts[0];
}
@Post()

View File

@ -13,6 +13,7 @@ import { AccountService } from './account.service';
@Module({
controllers: [AccountController],
exports: [AccountService],
imports: [
ConfigurationModule,
DataProviderModule,

View File

@ -1,7 +1,10 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -101,25 +104,51 @@ export class AccountService {
});
}
public async getCashDetails(
aUserId: string,
aCurrency: string
): Promise<CashDetails> {
let totalCashBalance = 0;
public async getCashDetails({
currency,
filters = [],
userId
}: {
currency: string;
filters?: Filter[];
userId: string;
}): Promise<CashDetails> {
let totalCashBalanceInBaseCurrency = new Big(0);
const accounts = await this.accounts({
where: { userId: aUserId }
const where: Prisma.AccountWhereInput = { userId };
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
if (filtersByAccount?.length > 0) {
where.id = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
const accounts = await this.accounts({ where });
for (const account of accounts) {
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
currency
)
);
});
}
return { accounts, balance: totalCashBalance };
return {
accounts,
balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber()
};
}
public async updateAccount(

View File

@ -2,5 +2,5 @@ import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
balance: number;
balanceInBaseCurrency: number;
}

View File

@ -1,6 +1,10 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
@ -11,6 +15,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
@ -55,6 +60,24 @@ export class AdminController {
return this.adminService.get();
}
@Post('gather')
@UseGuards(AuthGuard('jwt'))
public async gather7Days(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gather7Days();
}
@Post('gather/max')
@UseGuards(AuthGuard('jwt'))
public async gatherMax(): Promise<void> {
@ -70,10 +93,20 @@ export class AdminController {
);
}
await this.dataGatheringService.gatherProfileData();
this.dataGatheringService.gatherMax();
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
return;
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
this.dataGatheringService.gatherMax();
}
@Post('gather/profile-data')
@ -91,9 +124,18 @@ export class AdminController {
);
}
this.dataGatheringService.gatherProfileData();
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
return;
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
}
@Post('gather/profile-data/:dataSource/:symbol')
@ -114,9 +156,14 @@ export class AdminController {
);
}
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
return;
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
@Post('gather/:dataSource/:symbol')
@ -195,9 +242,10 @@ export class AdminController {
return this.adminService.getMarketData();
}
@Get('market-data/:symbol')
@Get('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
if (
@ -212,7 +260,7 @@ export class AdminController {
);
}
return this.adminService.getMarketDataBySymbol(symbol);
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@Put('market-data/:dataSource/:symbol/:dateString')
@ -248,6 +296,27 @@ export class AdminController {
});
}
@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(

View File

@ -6,10 +6,12 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
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';
import { AdminService } from './admin.service';
import { QueueModule } from './queue/queue.module';
@Module({
imports: [
@ -20,7 +22,9 @@ import { AdminService } from './admin.service';
MarketDataModule,
PrismaModule,
PropertyModule,
SubscriptionModule
QueueModule,
SubscriptionModule,
SymbolProfileModule
],
controllers: [AdminController],
providers: [AdminService],

View File

@ -5,11 +5,14 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
AdminMarketDataDetails,
AdminMarketDataItem,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
@ -17,6 +20,8 @@ import { differenceInDays } from 'date-fns';
@Injectable()
export class AdminService {
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
@ -24,30 +29,35 @@ export class AdminService {
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService
) {}
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
}
public async get(): Promise<AdminData> {
return {
dataGatheringProgress:
await this.dataGatheringService.getDataGatheringProgress(),
exchangeRates: this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== baseCurrency;
return currency !== this.baseCurrency;
})
.map((currency) => {
return {
label1: baseCurrency,
label1: this.baseCurrency,
label2: currency,
value: this.exchangeRateDataService.toCurrency(
1,
baseCurrency,
this.baseCurrency,
currency
)
};
}),
lastDataGathering: await this.getLastDataGathering(),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
@ -56,25 +66,82 @@ export class AdminService {
}
public async getMarketData(): Promise<AdminMarketData> {
return {
marketData: await (
await this.dataGatheringService.getSymbolsMax()
).map((symbol) => {
return symbol;
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(
aSymbol: string
): Promise<AdminMarketDataDetails> {
public async getMarketDataBySymbol({
dataSource,
symbol
}: UniqueAsset): Promise<AdminMarketDataDetails> {
return {
marketData: await this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
symbol: aSymbol
dataSource,
symbol
}
})
};
@ -91,30 +158,11 @@ export class AdminService {
if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize();
await this.dataGatheringService.reset();
}
return response;
}
private async getLastDataGathering() {
const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();
if (lastDataGathering) {
return lastDataGathering;
}
const dataGatheringInProgress =
await this.dataGatheringService.getIsInProgress();
if (dataGatheringInProgress) {
return 'IN_PROGRESS';
}
return undefined;
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy: {

View File

@ -0,0 +1,87 @@
import { AdminJobs } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { JobStatus } from 'bull';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { QueueService } from './queue.service';
@Controller('admin/queue')
export class QueueController {
public constructor(
private readonly queueService: QueueService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete('job')
@UseGuards(AuthGuard('jwt'))
public async deleteJobs(
@Query('status') filterByStatus?: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.deleteJobs({ status });
}
@Get('job')
@UseGuards(AuthGuard('jwt'))
public async getJobs(
@Query('status') filterByStatus?: string
): Promise<AdminJobs> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.getJobs({ status });
}
@Delete('job/:id')
@UseGuards(AuthGuard('jwt'))
public async deleteJob(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.queueService.deleteJob(id);
}
}

View File

@ -0,0 +1,12 @@
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { Module } from '@nestjs/common';
import { QueueController } from './queue.controller';
import { QueueService } from './queue.service';
@Module({
controllers: [QueueController],
imports: [DataGatheringModule],
providers: [QueueService]
})
export class QueueModule {}

View File

@ -0,0 +1,65 @@
import {
DATA_GATHERING_QUEUE,
QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { JobStatus, Queue } from 'bull';
@Injectable()
export class QueueService {
public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue
) {}
public async deleteJob(aId: string) {
return (await this.dataGatheringQueue.getJob(aId))?.remove();
}
public async deleteJobs({
status = QUEUE_JOB_STATUS_LIST
}: {
status?: JobStatus[];
}) {
const jobs = await this.dataGatheringQueue.getJobs(status);
for (const job of jobs) {
try {
await job.remove();
} catch (error) {
Logger.warn(error, 'QueueService');
}
}
}
public async getJobs({
limit = 1000,
status = QUEUE_JOB_STATUS_LIST
}: {
limit?: number;
status?: JobStatus[];
}): Promise<AdminJobs> {
const jobs = await this.dataGatheringQueue.getJobs(status);
const jobsWithState = await Promise.all(
jobs.slice(0, limit).map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,
name: job.name,
stacktrace: job.stacktrace,
state: await job.getState(),
timestamp: job.timestamp
};
})
);
return {
jobs: jobsWithState
};
}
}

View File

@ -1,26 +1,6 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Controller } from '@nestjs/common';
import { RedisCacheService } from './redis-cache/redis-cache.service';
@Controller()
export class AppController {
public constructor(
private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService
) {
this.initialize();
}
private async initialize() {
this.redisCacheService.reset();
const isDataGatheringInProgress =
await this.dataGatheringService.getIsInProgress();
if (isDataGatheringInProgress) {
// Prepare for automatical data gathering, if hung up in progress state
await this.dataGatheringService.reset();
}
}
public constructor() {}
}

View File

@ -8,6 +8,8 @@ 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 { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
@ -18,6 +20,7 @@ import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
@ -35,6 +38,14 @@ import { UserModule } from './user/user.module';
AccountModule,
AuthDeviceModule,
AuthModule,
BenchmarkModule,
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10),
password: process.env.REDIS_PASSWORD
}
}),
CacheModule,
ConfigModule.forRoot(),
ConfigurationModule,
@ -65,6 +76,7 @@ import { UserModule } from './user/user.module';
}),
SubscriptionModule,
SymbolModule,
TwitterBotModule,
UserModule
],
controllers: [AppController],

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

@ -9,7 +9,9 @@ import {
Post,
Req,
Res,
UseGuards
UseGuards,
VERSION_NEUTRAL,
Version
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -51,6 +53,7 @@ export class AuthController {
@Get('google/callback')
@UseGuards(AuthGuard('google'))
@Version(VERSION_NEUTRAL)
public googleLoginCallback(@Req() req, @Res() res) {
// Handles the Google OAuth2 callback
const jwt: string = req.user.jwt;

View File

@ -2,8 +2,8 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.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 { 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' }
}),
PrismaModule,
SubscriptionModule,
UserModule
],
providers: [
AuthDeviceService,
AuthService,
ConfigurationService,
GoogleStrategy,
JwtStrategy,
PrismaService,
WebAuthService
]
})

View File

@ -42,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
done(null, user);
} catch (error) {
Logger.error(error);
Logger.error(error, 'GoogleStrategy');
done(error, false);
}
}

View File

@ -95,7 +95,7 @@ export class WebAuthService {
};
verification = await verifyRegistrationResponse(opts);
} catch (error) {
Logger.error(error);
Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException(error.message);
}
@ -193,7 +193,7 @@ export class WebAuthService {
};
verification = verifyAuthenticationResponse(opts);
} catch (error) {
Logger.error(error);
Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message });
}

View File

@ -0,0 +1,30 @@
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { BenchmarkService } from './benchmark.service';
@Controller('benchmark')
export class BenchmarkController {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly propertyService: PropertyService
) {}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
return {
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets)
};
}
}

View File

@ -0,0 +1,25 @@
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 { MarketDataModule } from '@ghostfolio/api/services/market-data.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 { BenchmarkController } from './benchmark.controller';
import { BenchmarkService } from './benchmark.service';
@Module({
controllers: [BenchmarkController],
exports: [BenchmarkService],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

View File

@ -0,0 +1,84 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import Big from 'big.js';
@Injectable()
export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async getBenchmarks(
benchmarkAssets: UniqueAsset[]
): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (benchmarks) {
return benchmarks;
}
} catch {}
const promises: Promise<number>[] = [];
const [quotes, assetProfiles] = await Promise.all([
this.dataProviderService.getQuotes(benchmarkAssets),
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
]);
for (const benchmarkAsset of benchmarkAssets) {
promises.push(this.marketDataService.getMax(benchmarkAsset));
}
const allTimeHighs = await Promise.all(promises);
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
const performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
return {
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: assetProfiles.find(({ dataSource, symbol }) => {
return (
dataSource === benchmarkAssets[index].dataSource &&
symbol === benchmarkAssets[index].symbol
);
})?.name,
performances: {
allTimeHigh: {
performancePercent: performancePercentFromAllTimeHigh.toNumber()
}
}
};
});
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks)
);
return benchmarks;
}
private getMarketCondition(aPerformanceInPercent: Big) {
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}
}

View File

@ -1,25 +1,39 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import {
Controller,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('cache')
export class CacheController {
public constructor(
private readonly cacheService: CacheService,
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {
this.redisCacheService.reset();
}
) {}
@Post('flush')
@UseGuards(AuthGuard('jwt'))
public async flushCache(): Promise<void> {
this.redisCacheService.reset();
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.cacheService.flush();
return this.redisCacheService.reset();
}
}

View File

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

View File

@ -1,15 +0,0 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class CacheService {
public constructor(
private readonly dataGaterhingService: DataGatheringService
) {}
public async flush(): Promise<void> {
await this.dataGaterhingService.reset();
return;
}
}

View File

@ -1,6 +1,6 @@
import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -15,8 +15,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

@ -7,25 +7,61 @@ 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 activities = await this.prismaService.order.findMany({
orderBy: { date: 'desc' },
select: {
currency: true,
dataSource: true,
accountId: true,
date: true,
fee: true,
id: true,
quantity: true,
symbol: true,
SymbolProfile: true,
type: true,
unitPrice: true
},
where: { userId }
});
if (activityIds) {
activities = activities.filter((activity) => {
return activityIds.includes(activity.id);
});
}
return {
meta: { date: new Date().toISOString(), version: environment.version },
orders
activities: activities.map(
({
accountId,
date,
fee,
id,
quantity,
SymbolProfile,
type,
unitPrice
}) => {
return {
accountId,
fee,
id,
quantity,
type,
unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
};
}
)
};
}
}

View File

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

View File

@ -36,11 +36,11 @@ export class ImportController {
try {
return await this.importService.import({
orders: importData.orders,
activities: importData.activities,
userId: this.request.user.id
});
} catch (error) {
Logger.error(error);
Logger.error(error, ImportController);
throw new HttpException(
{

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,26 +1,44 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
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 { isSameDay, parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(
private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService
) {}
public async import({
orders,
activities,
userId
}: {
orders: Partial<Order>[];
activities: Partial<CreateOrderDto>[];
userId: string;
}): Promise<void> {
await this.validateOrders({ orders, userId });
for (const activity of activities) {
if (!activity.dataSource) {
if (activity.type === 'ITEM') {
activity.dataSource = 'MANUAL';
} else {
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
}
}
}
await this.validateActivities({ activities, userId });
const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => {
return account.id;
}
);
for (const {
accountId,
@ -32,44 +50,54 @@ export class ImportService {
symbol,
type,
unitPrice
} of orders) {
} of activities) {
await this.orderService.createOrder({
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
userId,
accountId: accountIds.includes(accountId) ? accountId : undefined,
date: parseISO(<string>(<unknown>date)),
SymbolProfile: {
connectOrCreate: {
create: {
currency,
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
},
User: { connect: { id: userId } }
});
}
}
private async validateOrders({
orders,
private async validateActivities({
activities,
userId
}: {
orders: Partial<Order>[];
activities: Partial<CreateOrderDto>[];
userId: string;
}) {
if (
orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) {
throw new Error(
`Too many transactions (${this.configurationService.get(
`Too many activities (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT'
)} at most)`
);
}
const existingOrders = await this.orderService.orders({
const existingActivities = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' },
where: { userId }
});
@ -77,38 +105,40 @@ export class ImportService {
for (const [
index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
] of orders.entries()) {
const duplicateOrder = existingOrders.find((order) => {
] of activities.entries()) {
const duplicateActivity = existingActivities.find((activity) => {
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
activity.SymbolProfile.currency === currency &&
activity.SymbolProfile.dataSource === dataSource &&
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
activity.fee === fee &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol &&
activity.type === type &&
activity.unitPrice === unitPrice
);
});
if (duplicateOrder) {
throw new Error(`orders.${index} is a duplicate transaction`);
if (duplicateActivity) {
throw new Error(`activities.${index} is a duplicate activity`);
}
const result = await this.dataProviderService.get([
{ dataSource, symbol }
]);
if (dataSource !== 'MANUAL') {
const quotes = await this.dataProviderService.getQuotes([
{ dataSource, symbol }
]);
if (result[symbol] === undefined) {
throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (quotes[symbol] === undefined) {
throw new Error(
`activities.${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}"`
);
if (quotes[symbol].currency !== currency) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
);
}
}
}
}

View File

@ -1,12 +1,12 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { 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 { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@ -14,7 +14,9 @@ import { InfoController } from './info.controller';
import { InfoService } from './info.service';
@Module({
controllers: [InfoController],
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
@ -22,16 +24,12 @@ import { InfoService } from './info.service';
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
}),
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
SymbolProfileModule,
TagModule
],
controllers: [InfoController],
providers: [
ConfigurationService,
DataGatheringService,
InfoService,
PrismaService
]
providers: [InfoService]
})
export class InfoModule {}

View File

@ -1,17 +1,19 @@
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 { 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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE
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';
@ -27,13 +29,13 @@ export class InfoService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService
private readonly redisCacheService: RedisCacheService,
private readonly tagService: TagService
) {}
public async get(): Promise<InfoItem> {
@ -51,6 +53,20 @@ export class InfoService {
globalPermissions.push(permissions.enableBlog);
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource
);
} else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
}
globalPermissions.push(permissions.enableFearAndGreedIndex);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
@ -89,12 +105,12 @@ export class InfoService {
isReadOnlyMode,
platforms,
systemMessage,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(),
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions()
subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
};
}
@ -139,7 +155,7 @@ export class InfoService {
const contributors = await get();
return contributors?.length;
} catch (error) {
Logger.error(error);
Logger.error(error, 'InfoService');
return undefined;
}
@ -160,7 +176,7 @@ export class InfoService {
const { stargazers_count } = await get();
return stargazers_count;
} catch (error) {
Logger.error(error);
Logger.error(error, 'InfoService');
return undefined;
}
@ -200,13 +216,6 @@ export class InfoService {
});
}
private async getLastDataGathering() {
const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();
return lastDataGathering ?? null;
}
private async getStatistics() {
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
return undefined;

View File

@ -1,15 +1,31 @@
import { DataSource, Type } from '@prisma/client';
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class CreateOrderDto {
@IsString()
accountId: string;
@IsOptional()
accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString()
currency: string;
@IsEnum(DataSource, { each: true })
dataSource: DataSource;
@IsOptional()
dataSource?: DataSource;
@IsISO8601()
date: string;

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,6 +1,10 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -14,7 +18,9 @@ import {
Param,
Post,
Put,
UseGuards
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -23,6 +29,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';
@ -38,8 +45,12 @@ export class OrderController {
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id });
if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -48,25 +59,55 @@ export class OrderController {
}
return this.orderService.deleteOrder({
id_userId: {
id,
userId: this.request.user.id
}
id
});
}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> {
@Headers('impersonation-id') impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<Activities> {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const userCurrency = this.request.user.Settings.currency;
let orders = await this.orderService.getOrders({
let activities = await this.orderService.getOrders({
filters,
userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id
});
@ -75,30 +116,22 @@ export class OrderController {
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
orders = nullifyValuesInObjects(orders, [
activities = nullifyValuesInObjects(activities, [
'fee',
'feeInBaseCurrency',
'quantity',
'unitPrice',
'value'
'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(this.request.user.permissions, permissions.createOrder)
@ -109,57 +142,42 @@ 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: {
currency: data.currency,
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(this.request.user.permissions, permissions.updateOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalOrder = await this.orderService.order({
id_userId: {
id,
userId: this.request.user.id
}
id
});
if (!originalOrder) {
if (
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
!originalOrder ||
originalOrder.userId !== this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -180,13 +198,23 @@ export class OrderController {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
SymbolProfile: {
connect: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
update: {
assetClass: data.assetClass,
assetSubClass: data.assetSubClass,
name: data.symbol
}
},
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
id
}
});
}

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,18 +1,38 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { AccountService } from '@ghostfolio/api/app/account/account.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 {
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Order,
Prisma,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface';
@Injectable()
export class OrderService {
public constructor(
private readonly cacheService: CacheService,
private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async order(
@ -43,34 +63,94 @@ export class OrderService {
});
}
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
const isDraft = isAfter(data.date as Date, endOfToday());
public async createOrder(
data: Prisma.OrderCreateInput & {
accountId?: string;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
currency?: string;
dataSource?: DataSource;
symbol?: string;
userId: string;
}
): Promise<Order> {
const defaultAccount = (
await this.accountService.getAccounts(data.userId)
).find((account) => {
return account.isDefault === true;
});
// Convert the symbol to uppercase to avoid case-sensitive duplicates
const symbol = data.symbol.toUpperCase();
let Account = {
connect: {
id_userId: {
userId: data.userId,
id: data.accountId ?? defaultAccount?.id
}
}
};
if (data.type === 'ITEM') {
const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass;
const currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL';
const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol;
Account = undefined;
data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name;
data.SymbolProfile.connectOrCreate.create.symbol = id;
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
dataSource,
symbol: id
};
} else {
data.SymbolProfile.connectOrCreate.create.symbol =
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
}
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
symbol,
dataSource: data.dataSource,
date: <Date>data.date
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
}
this.dataGatheringService.gatherProfileData([
{ symbol, dataSource: data.dataSource }
]);
delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.userId;
await this.cacheService.flush();
const orderData: Prisma.OrderCreateInput = data;
return this.prismaService.order.create({
data: {
...data,
isDraft,
symbol
...orderData,
Account,
isDraft
}
});
}
@ -78,26 +158,90 @@ 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 async getOrders({
filters,
includeDrafts = false,
types,
userCurrency,
userId
}: {
filters?: Filter[];
includeDrafts?: boolean;
types?: TypeOfOrder[];
userCurrency: string;
userId: string;
}) {
}): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId };
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filtersByAccount?.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
if (includeDrafts === false) {
where.isDraft = false;
}
if (filtersByAssetClass?.length > 0) {
where.SymbolProfile = {
OR: [
{
AND: [
{
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
},
{
SymbolProfileOverrides: {
is: null
}
}
]
},
{
SymbolProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
}
}
]
};
}
if (filtersByTag?.length > 0) {
where.tags = {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
};
}
if (types) {
where.OR = types.map((type) => {
return {
@ -119,41 +263,74 @@ export class OrderService {
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
SymbolProfile: true,
tags: true
},
orderBy: { date: 'asc' }
})
).map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
value: new Big(order.quantity)
.mul(order.unitPrice)
.plus(order.fee)
.toNumber()
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.SymbolProfile.currency,
userCurrency
)
};
});
}
public async updateOrder(params: {
public async updateOrder({
data,
where
}: {
data: Prisma.OrderUpdateInput & {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
currency?: string;
dataSource?: DataSource;
symbol?: string;
};
where: Prisma.OrderWhereUniqueInput;
data: Prisma.OrderUpdateInput;
}): Promise<Order> {
const { data, where } = params;
const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: <DataSource>data.dataSource,
date: <Date>data.date,
symbol: <string>data.symbol
}
]);
if (data.Account.connect.id_userId.id === null) {
delete data.Account;
}
await this.cacheService.flush();
let isDraft = false;
if (data.type === 'ITEM') {
delete data.SymbolProfile.connect;
} else {
delete data.SymbolProfile.update;
isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
date: <Date>data.date,
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
}
]);
}
}
delete data.assetClass;
delete data.assetSubClass;
delete data.currency;
delete data.dataSource;
delete data.symbol;
return this.prismaService.order.update({
data: {

View File

@ -1,9 +1,24 @@
import { DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator';
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class UpdateOrderDto {
@IsOptional()
@IsString()
accountId: string;
accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString()
currency: string;

View File

@ -0,0 +1,75 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface';
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 };
case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 };
}
return { marketPrice: 0 };
default:
return { marketPrice: 0 };
}
}
export const CurrentRateServiceMock = {
getValues: ({
dataGatheringItems,
dateQuery
}: GetValuesParams): Promise<GetValueObject[]> => {
const result: GetValueObject[] = [];
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,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
date
).marketPrice,
symbol: dataGatheringItem.symbol
});
}
}
} else {
for (const date of dateQuery.in) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
date
).marketPrice,
symbol: dataGatheringItem.symbol
});
}
}
}
return Promise.resolve(result);
}
};

View File

@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
import { GetValueObject } from './interfaces/get-value-object.interface';
jest.mock('@ghostfolio/api/services/market-data.service', () => {
return {
@ -73,7 +74,12 @@ describe('CurrentRateService', () => {
beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize();
@ -85,19 +91,6 @@ describe('CurrentRateService', () => {
);
});
it('getValue', async () => {
expect(
await currentRateService.getValue({
currency: 'USD',
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
symbol: 'AMZN',
userCurrency: 'CHF'
})
).toMatchObject({
marketPrice: 1847.839966
});
});
it('getValues', async () => {
expect(
await currentRateService.getValues({
@ -109,15 +102,15 @@ describe('CurrentRateService', () => {
},
userCurrency: 'CHF'
})
).toMatchObject([
).toMatchObject<GetValueObject[]>([
{
date: undefined,
marketPrice: 1841.823902,
marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPrice: 1847.839966,
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
}
]);

View File

@ -7,7 +7,6 @@ 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';
@Injectable()
@ -18,46 +17,6 @@ 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,
dataSource: this.dataProviderService.getPrimaryDataSource()
}
]);
return {
symbol,
date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
};
}
const marketData = await this.marketDataService.get({
date,
symbol
});
if (marketData) {
return {
date: marketData.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketData.marketPrice,
currency,
userCurrency
),
symbol: marketData.symbol
};
}
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
}
public async getValues({
currencies,
dataGatheringItems,
@ -69,30 +28,25 @@ export class CurrentRateService {
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise<
{
date: Date;
marketPrice: number;
symbol: string;
}[]
>[] = [];
const promises: Promise<GetValueObject[]>[] = [];
if (includeToday) {
const today = resetHours(new Date());
promises.push(
this.dataProviderService
.get(dataGatheringItems)
.getQuotes(dataGatheringItems)
.then((dataResultProvider) => {
const result = [];
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date: today,
marketPrice: this.exchangeRateDataService.toCurrency(
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
0,
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency
),
marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency(
dataResultProvider?.[dataGatheringItem.symbol]
?.marketPrice ?? 0,
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency
),
symbol: dataGatheringItem.symbol
});
}
@ -115,11 +69,12 @@ export class CurrentRateService {
return data.map((marketDataItem) => {
return {
date: marketDataItem.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice,
currencies[marketDataItem.symbol],
userCurrency
),
marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice,
currencies[marketDataItem.symbol],
userCurrency
),
symbol: marketDataItem.symbol
};
});

View File

@ -1,12 +1,11 @@
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
export interface CurrentPositions {
hasErrors: boolean;
export interface CurrentPositions extends ResponseError {
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;
netAnnualizedPerformance: Big;
netAnnualizedPerformance?: Big;
netPerformance: Big;
netPerformancePercentage: Big;
currentValue: Big;

View File

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

View File

@ -1,6 +0,0 @@
export interface GetValueParams {
currency: string;
date: Date;
symbol: string;
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,11 +1,12 @@
import {
EnhancedSymbolProfile,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass } from '@prisma/client';
import { Tag } from '@prisma/client';
export interface PortfolioPositionDetail {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
averagePrice: number;
currency: string;
firstBuyDate: string;
grossPerformance: number;
grossPerformancePercent: number;
@ -14,12 +15,12 @@ export interface PortfolioPositionDetail {
marketPrice: number;
maxPrice: number;
minPrice: number;
name: string;
netPerformance: number;
netPerformancePercent: number;
orders: OrderWithAccount[];
quantity: number;
symbol: string;
SymbolProfile: EnhancedSymbolProfile;
tags: Tag[];
transactionCount: number;
value: number;
}
@ -29,10 +30,3 @@ export interface HistoricalDataContainer {
isAllTimeLow: boolean;
items: HistoricalDataItem[];
}
export interface HistoricalDataItem {
averagePrice?: number;
date: string;
grossPerformancePercent?: number;
value: number;
}

View File

@ -0,0 +1,96 @@
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 { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
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)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2021-11-22')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('0'),
errors: [],
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,85 @@
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 { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy', async () => {
const portfolioCalculator = new PortfolioCalculator({
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)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2021-11-30')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('297.8'),
errors: [],
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 { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it('with no orders', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: []
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculator.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,96 @@
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 { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(1.3),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(2.95),
name: 'Novartis AG',
quantity: new Big(1),
symbol: 'NOVN.SW',
type: 'SELL',
unitPrice: new Big(85.73)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
positions: [
{
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
investment: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
marketPrice: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('75.80')
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +1,31 @@
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 { ResponseError, 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,
differenceInDays,
endOfDay,
format,
isAfter,
isBefore,
isSameMonth,
isSameYear,
max,
min
min,
set
} from 'date-fns';
import { flatten, isNumber } from 'lodash';
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 {
@ -32,22 +36,39 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator {
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
true;
private static readonly ENABLE_LOGGING = false;
private currency: string;
private currentRateService: CurrentRateService;
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[];
public constructor(
private currentRateService: CurrentRateService,
private currency: string
) {}
public constructor({
currency,
currentRateService,
orders
}: {
currency: string;
currentRateService: CurrentRateService;
orders: PortfolioOrder[];
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.orders = orders;
public computeTransactionPoints(orders: PortfolioOrder[]) {
orders.sort((a, b) => a.date.localeCompare(b.date));
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 orders) {
for (const order of this.orders) {
const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol;
@ -59,17 +80,30 @@ export class PortfolioCalculator {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
let investment = new Big(0);
if (newQuantity.gt(0)) {
if (order.type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice)
);
} else if (order.type === 'SELL') {
const averagePrice = oldAccumulatedSymbol.investment.div(
oldAccumulatedSymbol.quantity
);
investment = oldAccumulatedSymbol.investment.minus(
order.quantity.mul(averagePrice)
);
}
}
currentTransactionPointItem = {
investment,
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.add(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
@ -94,7 +128,7 @@ export class PortfolioCalculator {
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
);
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
newItems.sort((a, b) => a.symbol?.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = {
date: currentDate,
@ -140,7 +174,6 @@ 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: [],
@ -195,120 +228,50 @@ export class PortfolioCalculator {
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) {
if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
marketSymbol.marketPriceInBaseCurrency
);
}
}
let hasErrors = false;
const startString = format(start, DATE_FORMAT);
const holdingPeriodReturns: { [symbol: string]: Big } = {};
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
const grossPerformance: { [symbol: string]: Big } = {};
const netPerformance: { [symbol: string]: Big } = {};
const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
}
const invalidSymbols = [];
const lastInvestments: { [symbol: string]: Big } = {};
const lastQuantities: { [symbol: string]: Big } = {};
const lastFees: { [symbol: string]: Big } = {};
const initialValues: { [symbol: string]: Big } = {};
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
const currentDate =
i === firstIndex ? startString : this.transactionPoints[i].date;
const nextDate =
i + 1 < this.transactionPoints.length
? this.transactionPoints[i + 1].date
: todayString;
const items = this.transactionPoints[i].items;
for (const item of items) {
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol);
hasErrors = true;
Logger.warn(`Missing value for symbol ${item.symbol} at ${nextDate}`);
continue;
}
let lastInvestment: Big = new Big(0);
let lastQuantity: Big = item.quantity;
if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) {
lastInvestment = item.investment.minus(lastInvestments[item.symbol]);
lastQuantity = lastQuantities[item.symbol];
}
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
let initialValue = itemValue?.mul(lastQuantity);
let investedValue = itemValue?.mul(item.quantity);
const isFirstOrderAndIsStartBeforeCurrentDate =
i === firstIndex &&
isBefore(parseDate(this.transactionPoints[i].date), start);
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
const fee = isFirstOrderAndIsStartBeforeCurrentDate
? new Big(0)
: item.fee.minus(lastFee);
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
initialValue = item.investment;
investedValue = item.investment;
}
if (i === firstIndex || !initialValues[item.symbol]) {
initialValues[item.symbol] = initialValue;
}
if (!item.quantity.eq(0)) {
if (!initialValue) {
invalidSymbols.push(item.symbol);
hasErrors = true;
Logger.warn(
`Missing value for symbol ${item.symbol} at ${currentDate}`
);
continue;
}
const cashFlow = lastInvestment;
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
item.quantity
);
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] = (
holdingPeriodReturns[item.symbol] ?? new Big(1)
).mul(holdingPeriodReturn);
grossPerformance[item.symbol] = (
grossPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue));
const netHoldingPeriodReturn = endValue.div(
initialValue.plus(cashFlow).plus(fee)
);
netHoldingPeriodReturns[item.symbol] = (
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
).mul(netHoldingPeriodReturn);
netPerformance[item.symbol] = (
netPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue).minus(fee));
}
lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity;
lastFees[item.symbol] = item.fee;
}
}
const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
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)
@ -316,31 +279,33 @@ export class PortfolioCalculator {
currency: item.currency,
dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate,
grossPerformance: isValid
? grossPerformance[item.symbol] ?? null
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
: null,
grossPerformancePercentage:
isValid && holdingPeriodReturns[item.symbol]
? holdingPeriodReturns[item.symbol].minus(1)
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
netPerformancePercentage:
isValid && netHoldingPeriodReturns[item.symbol]
? netHoldingPeriodReturns[item.symbol].minus(1)
: null,
netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null
: null,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
if (hasErrors) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
}
}
const overall = this.calculateOverallPerformance(positions, initialValues);
return {
...overall,
errors,
positions,
hasErrors: hasErrors || overall.hasErrors
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
};
}
@ -354,13 +319,53 @@ export class PortfolioCalculator {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
investment.add(transactionPointSymbol.investment),
investment.plus(transactionPointSymbol.investment),
new Big(0)
)
};
});
}
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
if (this.orders.length === 0) {
return [];
}
const investments = [];
let currentDate = parseDate(this.orders[0].date);
let investmentByMonth = new Big(0);
for (const [index, order] of this.orders.entries()) {
if (
isSameMonth(parseDate(order.date), currentDate) &&
isSameYear(parseDate(order.date), currentDate)
) {
investmentByMonth = investmentByMonth.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
);
if (index === this.orders.length - 1) {
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
} else {
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
currentDate = parseDate(order.date);
investmentByMonth = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
}
}
return investments;
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
@ -458,75 +463,69 @@ export class PortfolioCalculator {
private calculateOverallPerformance(
positions: TimelinePosition[],
initialValues: { [p: string]: Big }
initialValues: { [symbol: string]: Big }
) {
let hasErrors = false;
let currentValue = new Big(0);
let totalInvestment = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let hasErrors = false;
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());
let sumOfWeights = new Big(0);
let totalInvestment = new Big(0);
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
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (
currentPosition.grossPerformancePercentage &&
initialValues[currentPosition.symbol]
) {
const currentInitialValue = initialValues[currentPosition.symbol];
completeInitialValue = completeInitialValue.plus(currentInitialValue);
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(currentInitialValue)
);
netAnnualizedPerformance = netAnnualizedPerformance.plus(
this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
today,
parseDate(currentPosition.firstBuyDate)
),
netPerformancePercent: currentPosition.netPerformancePercentage
}).mul(currentInitialValue)
currentPosition.grossPerformancePercentage.mul(weight)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue)
currentPosition.netPerformancePercentage.mul(weight)
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
'PortfolioCalculator'
);
hasErrors = true;
}
}
if (!completeInitialValue.eq(0)) {
grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue);
netPerformancePercentage =
netPerformancePercentage.div(completeInitialValue);
netAnnualizedPerformance =
netAnnualizedPerformance.div(completeInitialValue);
if (sumOfWeights.gt(0)) {
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
} else {
grossPerformancePercentage = new Big(0);
netPerformancePercentage = new Big(0);
}
return {
@ -534,7 +533,6 @@ export class PortfolioCalculator {
grossPerformance,
grossPerformancePercentage,
hasErrors,
netAnnualizedPerformance,
netPerformance,
netPerformancePercentage,
totalInvestment
@ -562,8 +560,8 @@ export class PortfolioCalculator {
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.add(item.investment);
fees = fees.add(item.fee);
investment = investment.plus(item.investment);
fees = fees.plus(item.fee);
}
let marketSymbols: GetValueObject[] = [];
@ -581,7 +579,8 @@ export class PortfolioCalculator {
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
error
error,
'PortfolioCalculator'
);
return null;
}
@ -592,9 +591,9 @@ export class PortfolioCalculator {
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
marketSymbol.marketPriceInBaseCurrency
);
}
}
@ -619,7 +618,7 @@ export class PortfolioCalculator {
invalid = true;
break;
}
value = value.add(
value = value.plus(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
@ -687,6 +686,356 @@ export class PortfolioCalculator {
}
}
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 investmentAtStartDate: 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);
let valueAtStartDate: Big;
// 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
);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
}
const transactionInvestment = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
totalInvestment = totalInvestment.plus(transactionInvestment);
if (i >= indexOfStartOrder && 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 maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
maxTotalInvestment.minus(investmentAtStartDate)
);
const grossPerformancePercentage =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
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 =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
if (PortfolioCalculator.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,

View File

@ -4,19 +4,28 @@ import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { 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 {
Filter,
PortfolioChart,
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
DateRange,
GroupBy,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -25,12 +34,12 @@ 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 { ViewMode } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
@ -39,6 +48,8 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
export class PortfolioController {
private baseCurrency: string;
public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
@ -46,14 +57,15 @@ export class PortfolioController {
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('chart')
@UseGuards(AuthGuard('jwt'))
public async getChart(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
@Query('range') range
): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioService.getChart(
impersonationId,
@ -90,36 +102,58 @@ export class PortfolioController {
});
}
return <any>res.json({
return {
hasError,
chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow
});
};
}
@Get('details')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
): Promise<PortfolioDetails> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({ accounts: {}, holdings: {} });
}
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') range?: DateRange,
@Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> {
let hasError = false;
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
range
range,
filters
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
@ -161,26 +195,56 @@ export class PortfolioController {
}
}
return <any>res.json({ accounts, hasError, holdings });
let hasDetails = true;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = {
...portfolioPosition,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined,
sectors: hasDetails ? portfolioPosition.sectors : []
};
}
return {
accounts,
hasError,
holdings
};
}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string,
@Res() res: Response
@Query('groupBy') groupBy?: GroupBy
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({});
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
let investments: InvestmentItem[];
if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments(
impersonationId,
'month'
);
} else {
investments = await this.portfolioService.getInvestments(impersonationId);
}
if (
impersonationId ||
@ -197,16 +261,16 @@ export class PortfolioController {
}));
}
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
return { firstOrderDate: parseDate(investments[0]?.date), investments };
}
@Get('performance')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPerformance(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
@Query('range') range
): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioService.getPerformance(
impersonationId,
range
@ -214,6 +278,7 @@ export class PortfolioController {
if (
impersonationId ||
this.request.user.Settings.viewMode === ViewMode.ZEN ||
this.userService.isRestrictedView(this.request.user)
) {
performanceInformation.performance = nullifyValuesInObject(
@ -222,15 +287,15 @@ export class PortfolioController {
);
}
return <any>res.json(performanceInformation);
return performanceInformation;
}
@Get('positions')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
@Query('range') range
): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions(
impersonationId,
@ -251,13 +316,12 @@ export class PortfolioController {
});
}
return <any>res.json(result);
return result;
}
@Get('public/:accessId')
public async getPublic(
@Param('accessId') accessId,
@Res() res: Response
@Param('accessId') accessId
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
@ -265,8 +329,10 @@ export class PortfolioController {
});
if (!access) {
res.status(StatusCodes.NOT_FOUND);
return <any>res.json({ accounts: {}, holdings: {} });
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
let hasDetails = true;
@ -276,7 +342,9 @@ export class PortfolioController {
const { holdings } = await this.portfolioService.getDetails(
access.userId,
access.userId
access.userId,
'max',
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
);
const portfolioPublicDetails: PortfolioPublicDetails = {
@ -285,32 +353,31 @@ export class PortfolioController {
};
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
this.request.user?.Settings?.currency ?? this.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
};
}
portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.value / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name,
netPerformancePercent: portfolioPosition.netPerformancePercent,
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
value: portfolioPosition.value / totalValue
};
}
return <any>res.json(portfolioPublicDetails);
return portfolioPublicDetails;
}
@Get('summary')
@ -318,6 +385,16 @@ export class PortfolioController {
public async getSummary(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let summary = await this.portfolioService.getSummary(impersonationId);
if (
@ -331,7 +408,9 @@ export class PortfolioController {
'currentNetPerformance',
'currentValue',
'dividend',
'emergencyFund',
'fees',
'items',
'netWorth',
'totalBuy',
'totalSell'
@ -341,13 +420,17 @@ 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: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
@ -379,19 +462,18 @@ export class PortfolioController {
@Get('report')
@UseGuards(AuthGuard('jwt'))
public async getReport(
@Headers('impersonation-id') impersonationId: string,
@Res() res: Response
@Headers('impersonation-id') impersonationId: string
): Promise<PortfolioReport> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({ rules: [] });
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return <any>(
res.json(await this.portfolioService.getReport(impersonationId))
);
return await this.portfolioService.getReport(impersonationId);
}
}

View File

@ -18,6 +18,7 @@ import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service';
@Module({
controllers: [PortfolioController],
exports: [PortfolioService],
imports: [
AccessModule,
@ -32,7 +33,6 @@ import { RulesService } from './rules.service';
SymbolProfileModule,
UserModule
],
controllers: [PortfolioController],
providers: [
AccountService,
CurrentRateService,

File diff suppressed because it is too large Load Diff

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';
@ -13,13 +14,15 @@ import { RedisCacheService } from './redis-cache.service';
useFactory: async (configurationService: ConfigurationService) => ({
host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT'),
store: redisStore,
ttl: configurationService.get('CACHE_TTL')
})
})
}),
ConfigurationModule
],
providers: [ConfigurationService, RedisCacheService],
providers: [RedisCacheService],
exports: [RedisCacheService]
})
export class RedisCacheModule {}

View File

@ -7,6 +7,7 @@ import {
Body,
Controller,
Get,
HttpCode,
HttpException,
Inject,
Logger,
@ -17,7 +18,6 @@ import {
} 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 { SubscriptionService } from './subscription.service';
@ -32,11 +32,9 @@ export class SubscriptionController {
) {}
@Post('redeem-coupon')
@HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt'))
public async redeemCoupon(
@Body() { couponCode }: { couponCode: string },
@Res() res: Response
) {
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
if (!this.request.user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -48,22 +46,25 @@ export class SubscriptionController {
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
[];
const isValid = coupons.some((coupon) => {
return coupon.code === couponCode;
const coupon = coupons.find((currentCoupon) => {
return currentCoupon.code === couponCode;
});
if (!isValid) {
if (coupon === undefined) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.subscriptionService.createSubscription(this.request.user.id);
await this.subscriptionService.createSubscription({
duration: coupon.duration,
userId: this.request.user.id
});
// Destroy coupon
coupons = coupons.filter((coupon) => {
return coupon.code !== couponCode;
coupons = coupons.filter((currentCoupon) => {
return currentCoupon.code !== couponCode;
});
await this.propertyService.put({
key: PROPERTY_COUPONS,
@ -71,15 +72,14 @@ export class SubscriptionController {
});
Logger.log(
`Subscription for user '${this.request.user.id}' has been created with coupon`
`Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`,
'SubscriptionController'
);
res.status(StatusCodes.OK);
return <any>res.json({
return {
message: getReasonPhrase(StatusCodes.OK),
statusCode: StatusCodes.OK
});
};
}
@Get('stripe/callback')
@ -88,7 +88,10 @@ export class SubscriptionController {
req.query.checkoutSessionId
);
Logger.log(`Subscription for user '${userId}' has been created via Stripe`);
Logger.log(
`Subscription for user '${userId}' has been created via Stripe`,
'SubscriptionController'
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
}
@ -105,7 +108,7 @@ export class SubscriptionController {
userId: this.request.user.id
});
} catch (error) {
Logger.error(error);
Logger.error(error, 'SubscriptionController');
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),

View File

@ -1,5 +1,5 @@
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';
@ -7,9 +7,9 @@ import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service';
@Module({
imports: [PropertyModule],
controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService],
exports: [SubscriptionService]
exports: [SubscriptionService],
imports: [ConfigurationModule, PrismaModule, PropertyModule],
providers: [SubscriptionService]
})
export class SubscriptionModule {}

View File

@ -2,8 +2,9 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription, User } from '@prisma/client';
import { addDays, isBefore } from 'date-fns';
import { Subscription } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns';
import ms, { StringValue } from 'ms';
import Stripe from 'stripe';
@Injectable()
@ -44,7 +45,7 @@ export class SubscriptionService {
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
)}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
if (couponId) {
@ -64,13 +65,19 @@ export class SubscriptionService {
};
}
public async createSubscription(aUserId: string) {
public async createSubscription({
duration = '1 year',
userId
}: {
duration?: StringValue;
userId: string;
}) {
await this.prismaService.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
expiresAt: addMilliseconds(new Date(), ms(duration)),
User: {
connect: {
id: aUserId
id: userId
}
}
}
@ -83,7 +90,7 @@ export class SubscriptionService {
aCheckoutSessionId
);
await this.createSubscription(session.client_reference_id);
await this.createSubscription({ userId: session.client_reference_id });
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
@ -91,7 +98,7 @@ export class SubscriptionService {
return session.client_reference_id;
} catch (error) {
Logger.error(error);
Logger.error(error, 'SubscriptionService');
}
}

View File

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

View File

@ -1,19 +1,17 @@
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 type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
DefaultValuePipe,
Get,
HttpException,
Inject,
Param,
ParseBoolPipe,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash';
@ -23,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),
@ -51,12 +46,12 @@ export class SymbolController {
* Must be after /lookup
*/
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string,
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
includeHistoricalData: boolean
@Query('includeHistoricalData') includeHistoricalData?: number
): Promise<SymbolItem> {
if (!DataSource[dataSource]) {
throw new HttpException(

View File

@ -8,13 +8,14 @@ import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service';
@Module({
controllers: [SymbolController],
exports: [SymbolService],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
],
controllers: [SymbolController],
providers: [SymbolService]
})
export class SymbolModule {}

View File

@ -1,12 +1,11 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns';
@ -18,25 +17,26 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService
private readonly marketDataService: MarketDataService
) {}
public async get({
dataGatheringItem,
includeHistoricalData = false
includeHistoricalData
}: {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: boolean;
includeHistoricalData?: number;
}): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
const quotes = await this.dataProviderService.getQuotes([
dataGatheringItem
]);
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[];
let historicalData: HistoricalDataItem[] = [];
if (includeHistoricalData) {
const days = 30;
if (includeHistoricalData > 0) {
const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
@ -55,7 +55,8 @@ export class SymbolService {
currency,
historicalData,
marketPrice,
dataSource: dataGatheringItem.dataSource
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
};
}
@ -93,35 +94,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) {
Logger.error(error);
Logger.error(error, 'SymbolService');
throw error;
}

View File

@ -1,4 +0,0 @@
export interface Access {
alias?: string;
id: string;
}

View File

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

View File

@ -1,3 +1,5 @@
export interface UserSettings {
emergencyFund?: number;
locale?: string;
isRestrictedView?: boolean;
}

View File

@ -1,6 +1,19 @@
import { IsBoolean } from 'class-validator';
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpdateUserSettingDto {
@IsNumber()
@IsOptional()
emergencyFund?: number;
@IsBoolean()
@IsOptional()
isRestrictedView?: boolean;
@IsString()
@IsOptional()
locale?: string;
@IsNumber()
@IsOptional()
savingsRate?: number;
}

View File

@ -2,17 +2,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
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 {
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
@ -23,7 +20,6 @@ import {
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
import { Provider, Role } from '@prisma/client';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -38,7 +34,7 @@ import { UserService } from './user.service';
export class UserController {
public constructor(
private readonly configurationService: ConfigurationService,
private jwtService: JwtService,
private readonly jwtService: JwtService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@ -64,8 +60,13 @@ export class UserController {
@Get()
@UseGuards(AuthGuard('jwt'))
public async getUser(@Param('id') id: string): Promise<User> {
return this.userService.getUser(this.request.user);
public async getUser(
@Headers('accept-language') acceptLanguage: string
): Promise<User> {
return this.userService.getUser(
this.request.user,
acceptLanguage?.split(',')?.[0]
);
}
@Post()
@ -83,12 +84,15 @@ export class UserController {
}
}
const { accessToken, id } = await this.userService.createUser({
provider: Provider.ANONYMOUS
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
})
@ -115,6 +119,12 @@ export class UserController {
...data
};
for (const key in userSettings) {
if (userSettings[key] === false || userSettings[key] === null) {
delete userSettings[key];
}
}
return await this.userService.updateUserSetting({
userSettings,
userId: this.request.user.id

View File

@ -1,7 +1,8 @@
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 { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@ -9,16 +10,19 @@ 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
SubscriptionModule,
TagModule
],
controllers: [UserController],
providers: [ConfigurationService, PrismaService, UserService],
exports: [UserService]
providers: [UserService]
})
export class UserModule {}

View File

@ -2,20 +2,17 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_IS_READ_ONLY_MODE,
baseCurrency,
locale
} from '@ghostfolio/common/config';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client';
import { Prisma, Role, User, ViewMode } from '@prisma/client';
import { sortBy } from 'lodash';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
@ -26,21 +23,29 @@ const crypto = require('crypto');
export class UserService {
public static DEFAULT_CURRENCY = 'USD';
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService
) {}
private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getUser({
Account,
alias,
id,
permissions,
Settings,
subscription
}: UserWithSettings): Promise<IUser> {
public async getUser(
{
Account,
alias,
id,
permissions,
Settings,
subscription
}: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const access = await this.prismaService.access.findMany({
include: {
User: true
@ -48,12 +53,21 @@ export class UserService {
orderBy: { User: { alias: 'asc' } },
where: { GranteeUser: { id } }
});
let tags = await this.tagService.getByUser(id);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === 'Basic'
) {
tags = [];
}
return {
alias,
id,
permissions,
subscription,
tags,
access: access.map((accessItem) => {
return {
alias: accessItem.User.alias,
@ -63,13 +77,25 @@ export class UserService {
accounts: Account,
settings: {
...(<UserSettings>Settings.settings),
locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
}
};
}
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;
}
@ -77,17 +103,63 @@ export class UserService {
public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
const userFromDatabase = await this.prismaService.user.findUnique({
const {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
Subscription,
thirdPartyId,
updatedAt
} = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput
});
const user: UserWithSettings = userFromDatabase;
const user: UserWithSettings = {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
thirdPartyId,
updatedAt
};
let currentPermissions = getPermissions(userFromDatabase.role);
if (user?.Settings) {
if (!user.Settings.currency) {
// Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (user) {
// Set default settings if needed
user.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
userId: user?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
}
let currentPermissions = getPermissions(user.role);
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
@ -110,36 +182,10 @@ export class UserService {
}
}
user.permissions = currentPermissions;
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (userFromDatabase) {
// Set default settings if needed
userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription = this.subscriptionService.getSubscription(
userFromDatabase?.Subscription
);
if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => {
return permission !== permissions.updateViewMode;
});
user.Settings.viewMode = ViewMode.ZEN;
}
}
user.Account = sortBy(user.Account, (account) => {
return account.name;
});
user.permissions = currentPermissions.sort();
return user;
}
@ -168,26 +214,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,
currency: this.baseCurrency,
isDefault: true,
name: 'Default Account'
}
},
Settings: {
create: {
currency: baseCurrency
currency: this.baseCurrency
}
}
}
});
if (data.provider === Provider.ANONYMOUS) {
if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(
user.id,
this.getRandomString(10)

View File

@ -0,0 +1,26 @@
[
"AT",
"AU",
"BE",
"CA",
"CH",
"DE",
"DK",
"ES",
"FI",
"FR",
"GB",
"HK",
"IE",
"IL",
"IT",
"JP",
"LU",
"NL",
"NO",
"NZ",
"PT",
"SE",
"SG",
"US"
]

View File

@ -0,0 +1,28 @@
[
"AE",
"BR",
"CL",
"CN",
"CO",
"CY",
"CZ",
"EG",
"GR",
"HK",
"HU",
"ID",
"IN",
"KR",
"KW",
"MX",
"MY",
"PE",
"PH",
"PL",
"QA",
"SA",
"TH",
"TR",
"TW",
"ZA"
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
{
"LUNA1": "Terra",
"LUNA2": "Terra",
"SGB1": "Songbird",
"UNI1": "Uniswap"
}

View File

@ -0,0 +1,50 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor() {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
const request = context.switchToHttp().getRequest();
const hasImpersonationId = !!request.headers?.['impersonation-id'];
if (hasImpersonationId) {
if (data.accounts) {
for (const accountId of Object.keys(data.accounts)) {
if (data.accounts[accountId]?.balance !== undefined) {
data.accounts[accountId].balance = null;
}
}
}
if (data.activities) {
data.activities = data.activities.map((activity: Activity) => {
if (activity.Account?.balance !== undefined) {
activity.Account.balance = null;
}
return activity;
});
}
}
return data;
})
);
}
}

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
);
return activity;
});
}
if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource);
}
if (data.errors) {
for (const error of data.errors) {
if (error.dataSource) {
error.dataSource = encodeDataSource(error.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.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

@ -1,4 +1,4 @@
import { Logger, ValidationPipe } from '@nestjs/common';
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
@ -7,8 +7,11 @@ import { environment } from './environments/environment';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
app.enableVersioning({
defaultVersion: '1',
type: VersioningType.URI
});
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
forbidNonWhitelisted: true,
@ -17,10 +20,11 @@ async function bootstrap() {
})
);
const host = process.env.HOST || '0.0.0.0';
const port = process.env.PORT || 3333;
await app.listen(port, () => {
await app.listen(port, host, () => {
logLogo();
Logger.log(`Listening at http://localhost:${port}`);
Logger.log(`Listening at http://${host}:${port}`);
Logger.log('');
});
}

View File

@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
};
} = {};
for (const account of Object.keys(this.accounts)) {
accounts[account] = {
name: account,
investment: this.accounts[account].current
for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[accountId] = {
name: account.name,
investment: account.current
};
}
let maxItem;
let totalInvestment = 0;
Object.values(accounts).forEach((account) => {
for (const account of Object.values(accounts)) {
if (!maxItem) {
maxItem = account;
}
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
if (account.investment > maxItem?.investment) {
maxItem = account;
}
});
}
const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -1,10 +1,10 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import {
PortfolioDetails,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
}
public evaluate(ruleSettings?: Settings) {
const platforms: {
const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number;
};
} = {};
for (const account of Object.keys(this.accounts)) {
platforms[account] = {
name: account,
investment: this.accounts[account].original
for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[accountId] = {
name: account.name,
investment: account.original
};
}
let maxItem;
let totalInvestment = 0;
Object.values(platforms).forEach((platform) => {
for (const account of Object.values(accounts)) {
if (!maxItem) {
maxItem = platform;
maxItem = account;
}
// Calculate total investment
totalInvestment += platform.investment;
totalInvestment += account.investment;
// Find maximum
if (platform.investment > maxItem?.investment) {
maxItem = platform;
if (account.investment > maxItem?.investment) {
maxItem = account;
}
});
}
const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -1,7 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,6 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -12,8 +12,10 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({ default: 'USD' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: [DataSource.YAHOO] }),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
@ -23,18 +25,28 @@ export class ConfigurationService {
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
EOD_HISTORICAL_DATA_API_KEY: str({ default: '' }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }),
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: '0.0.0.0' }),
JWT_SECRET_KEY: str({}),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }),
REDIS_HOST: host({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
TWITTER_API_KEY: str({ default: 'dummyApiKey' }),
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
});
}

View File

@ -1,18 +1,24 @@
import {
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service';
import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable()
export class CronService {
public constructor(
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly twitterBotService: TwitterBotService
) {}
@Cron(CronExpression.EVERY_MINUTE)
public async runEveryMinute() {
@Cron(CronExpression.EVERY_HOUR)
public async runEveryHour() {
await this.dataGatheringService.gather7Days();
}
@ -21,8 +27,24 @@ export class CronService {
await this.exchangeRateDataService.loadCurrencies();
}
@Cron(CronExpression.EVERY_DAY_AT_5PM)
public async runEveryDayAtFivePM() {
this.twitterBotService.tweetFearAndGreedIndex();
}
@Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() {
await this.dataGatheringService.gatherProfileData();
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
}
}

View File

@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
const cryptocurrencies = require('cryptocurrencies');
const customCryptocurrencies = require('./custom-cryptocurrencies.json');
const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json');
const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json');
@Injectable()
export class CryptocurrencyService {
@ -18,7 +17,7 @@ export class CryptocurrencyService {
private getCryptocurrencies() {
if (!this.combinedCryptocurrencies) {
this.combinedCryptocurrencies = [
...cryptocurrencies.symbols(),
...Object.keys(cryptocurrencies),
...Object.keys(customCryptocurrencies)
];
}

View File

@ -1,11 +0,0 @@
{
"1INCH": "1inch",
"ALGO": "Algorand",
"ATOM": "Cosmos",
"AVAX": "Avalanche",
"DOT": "Polkadot",
"MATIC": "Polygon",
"SHIB": "Shiba Inu",
"SOL": "Solana",
"UNI3": "Uniswap"
}

View File

@ -3,21 +3,34 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import ms from 'ms';
import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { MarketDataModule } from './market-data.module';
import { SymbolProfileModule } from './symbol-profile.module';
@Module({
imports: [
BullModule.registerQueue({
limiter: {
duration: ms('5 seconds'),
max: 1
},
name: DATA_GATHERING_QUEUE
}),
ConfigurationModule,
DataEnhancerModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
SymbolProfileModule
],
providers: [DataGatheringService],
exports: [DataEnhancerModule, DataGatheringService]
providers: [DataGatheringProcessor, DataGatheringService],
exports: [BullModule, DataEnhancerModule, DataGatheringService]
})
export class DataGatheringModule {}

View File

@ -0,0 +1,128 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Job } from 'bull';
import {
format,
getDate,
getMonth,
getYear,
isBefore,
parseISO
} from 'date-fns';
import { DataGatheringService } from './data-gathering.service';
import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@Injectable()
@Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor {
public constructor(
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService
) {}
@Process(GATHER_ASSET_PROFILE_PROCESS)
public async gatherAssetProfile(job: Job<UniqueAsset>) {
try {
await this.dataGatheringService.gatherAssetProfiles([job.data]);
} catch (error) {
Logger.error(
error,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
);
throw new Error(error);
}
}
@Process(GATHER_HISTORICAL_MARKET_DATA_PROCESS)
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
try {
const { dataSource, date, symbol } = job.data;
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
parseISO(<string>(<unknown>date)),
new Date()
);
let currentDate = parseISO(<string>(<unknown>date));
let lastMarketPrice: number;
while (
isBefore(
currentDate,
new Date(
Date.UTC(
getYear(new Date()),
getMonth(new Date()),
getDate(new Date()),
0
)
)
)
) {
if (
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice
) {
lastMarketPrice =
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice;
}
if (lastMarketPrice) {
try {
await this.prismaService.marketData.create({
data: {
dataSource,
symbol,
date: new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate),
0
)
),
marketPrice: lastMarketPrice
}
});
} catch {}
}
// Count month one up for iteration
currentDate = new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate) + 1,
0
)
);
}
Logger.log(
`Historical market data gathering has been completed for ${symbol} (${dataSource}).`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
);
} catch (error) {
Logger.error(
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
);
throw new Error(error);
}
}
}

View File

@ -1,181 +1,72 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
PROPERTY_LAST_DATA_GATHERING,
PROPERTY_LOCKED_DATA_GATHERING
DATA_GATHERING_QUEUE,
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import {
differenceInHours,
format,
getDate,
getMonth,
getYear,
isBefore,
subDays
} from 'date-fns';
import { JobOptions, Queue } from 'bull';
import { format, subDays } from 'date-fns';
import { DataProviderService } from './data-provider/data-provider.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { MarketDataService } from './market-data.service';
import { PrismaService } from './prisma.service';
@Injectable()
export class DataGatheringService {
private dataGatheringProgress: number;
public constructor(
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[],
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async gather7Days() {
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
public async addJobToQueue(name: string, data: any, options?: JobOptions) {
const hasJob = await this.hasJob(name, data);
if (isDataGatheringNeeded) {
Logger.log('7d data gathering has been started.');
console.time('data-gathering-7d');
await this.prismaService.property.create({
data: {
key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString()
}
});
const symbols = await this.getSymbols7D();
try {
await this.gatherSymbols(symbols);
await this.prismaService.property.upsert({
create: {
key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString()
},
update: { value: new Date().toISOString() },
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error);
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
Logger.log('7d data gathering has been completed.');
console.timeEnd('data-gathering-7d');
if (hasJob) {
Logger.log(
`Job ${name} with data ${JSON.stringify(data)} already exists.`,
'DataGatheringService'
);
} else {
return this.dataGatheringQueue.add(name, data, options);
}
}
public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D();
await this.gatherSymbols(dataGatheringItems);
}
public async gatherMax() {
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
if (!isDataGatheringLocked) {
Logger.log('Max data gathering has been started.');
console.time('data-gathering-max');
await this.prismaService.property.create({
data: {
key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString()
}
});
const symbols = await this.getSymbolsMax();
try {
await this.gatherSymbols(symbols);
await this.prismaService.property.upsert({
create: {
key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString()
},
update: { value: new Date().toISOString() },
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error);
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
Logger.log('Max data gathering has been completed.');
console.timeEnd('data-gathering-max');
}
const dataGatheringItems = await this.getSymbolsMax();
await this.gatherSymbols(dataGatheringItems);
}
public async gatherSymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol });
if (!isDataGatheringLocked) {
Logger.log(`Symbol data gathering for ${symbol} has been started.`);
console.time('data-gathering-symbol');
await this.prismaService.property.create({
data: {
key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString()
}
});
const symbols = (await this.getSymbolsMax()).filter(
(dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
}
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
try {
await this.gatherSymbols(symbols);
await this.prismaService.property.upsert({
create: {
key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString()
},
update: { value: new Date().toISOString() },
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error);
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
Logger.log(`Symbol data gathering for ${symbol} has been completed.`);
console.timeEnd('data-gathering-symbol');
}
});
await this.gatherSymbols(symbols);
}
public async gatherSymbolForDate({
@ -210,42 +101,48 @@ export class DataGatheringService {
});
}
} catch (error) {
Logger.error(error);
Logger.error(error, 'DataGatheringService');
} finally {
return undefined;
}
}
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
Logger.log('Profile data gathering has been started.');
console.time('data-gathering-profile');
public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
return dataGatheringItem.dataSource !== 'MANUAL';
});
let dataGatheringItems = aDataGatheringItems;
if (!dataGatheringItems) {
dataGatheringItems = await this.getSymbolsProfileData();
if (!uniqueAssets) {
uniqueAssets = await this.getUniqueAssets();
}
const currentData = await this.dataProviderService.get(dataGatheringItems);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => {
return symbol;
})
const assetProfiles = await this.dataProviderService.getAssetProfiles(
uniqueAssets
);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(
uniqueAssets.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, response] of Object.entries(currentData)) {
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.symbolMapping;
for (const dataEnhancer of this.dataEnhancers) {
try {
currentData[symbol] = await dataEnhancer.enhance({
response,
assetProfiles[symbol] = await dataEnhancer.enhance({
response: assetProfile,
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
});
} catch (error) {
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
Logger.error(
`Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`,
error,
'DataGatheringService'
);
}
}
@ -256,8 +153,9 @@ export class DataGatheringService {
currency,
dataSource,
name,
sectors
} = currentData[symbol];
sectors,
url
} = assetProfiles[symbol];
try {
await this.prismaService.symbolProfile.upsert({
@ -269,7 +167,8 @@ export class DataGatheringService {
dataSource,
name,
sectors,
symbol
symbol,
url
},
update: {
assetClass,
@ -277,7 +176,8 @@ export class DataGatheringService {
countries,
currency,
name,
sectors
sectors,
url
},
where: {
dataSource_symbol: {
@ -287,132 +187,40 @@ export class DataGatheringService {
}
});
} catch (error) {
Logger.error(`${symbol}: ${error?.meta?.cause}`);
Logger.error(
`${symbol}: ${error?.meta?.cause}`,
error,
'DataGatheringService'
);
}
}
Logger.log('Profile data gathering has been completed.');
console.timeEnd('data-gathering-profile');
Logger.log(
`Asset profile data gathering has been completed for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
}
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false;
let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
if (dataSource === 'MANUAL') {
continue;
}
try {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
await this.addJobToQueue(
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
{
dataSource,
date,
new Date()
);
let currentDate = date;
let lastMarketPrice: number;
while (
isBefore(
currentDate,
new Date(
Date.UTC(
getYear(new Date()),
getMonth(new Date()),
getDate(new Date()),
0
)
)
)
) {
if (
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice
) {
lastMarketPrice =
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice;
}
if (lastMarketPrice) {
try {
await this.prismaService.marketData.create({
data: {
dataSource,
symbol,
date: currentDate,
marketPrice: lastMarketPrice
}
});
} catch {}
} else {
Logger.warn(
`Failed to gather data for symbol ${symbol} at ${format(
currentDate,
DATE_FORMAT
)}.`
);
}
// Count month one up for iteration
currentDate = new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate) + 1,
0
)
);
}
} catch (error) {
hasError = true;
Logger.error(error);
}
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
Logger.log(
`Data gathering progress: ${(
this.dataGatheringProgress * 100
).toFixed(2)}%`
);
}
symbolCounter += 1;
symbol
},
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
);
}
await this.exchangeRateDataService.initialize();
if (hasError) {
throw '';
}
}
public async getDataGatheringProgress() {
const isInProgress = await this.getIsInProgress();
if (isInProgress) {
return this.dataGatheringProgress;
}
return undefined;
}
public async getIsInProgress() {
return await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
}
public async getLastDataGathering() {
const lastDataGathering = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
if (lastDataGathering?.value) {
return new Date(lastDataGathering.value);
}
return undefined;
}
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
@ -445,6 +253,11 @@ export class DataGatheringService {
},
scraperConfiguration: true,
symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
}
})
).map((symbolProfile) => {
@ -457,52 +270,66 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather];
}
public async reset() {
Logger.log('Data gathering has been reset.');
await this.prismaService.property.deleteMany({
where: {
OR: [
{ key: PROPERTY_LAST_DATA_GATHERING },
{ key: PROPERTY_LOCKED_DATA_GATHERING }
]
}
public async getUniqueAssets(): Promise<UniqueAsset[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter(({ dataSource }) => {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAKUTEN
);
})
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
});
}
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
}
});
// Only consider symbols with incomplete market data for the last
// 7 days
const symbolsToGather = (
const symbolsNotToGather = (
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
orderBy: [{ symbol: 'asc' }],
where: {
date: { gt: startDate }
}
})
)
.filter((group) => {
return group._count < 6;
return group._count >= 6;
})
.map((group) => {
return group.symbol;
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
}
})
)
const symbolProfilesToGather = symbolProfiles
.filter(({ symbol }) => {
return symbolsToGather.includes(symbol);
return !symbolsNotToGather.includes(symbol);
})
.map((symbolProfile) => {
return {
@ -514,7 +341,7 @@ export class DataGatheringService {
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.filter(({ symbol }) => {
return symbolsToGather.includes(symbol);
return !symbolsNotToGather.includes(symbol);
})
.map(({ dataSource, symbol }) => {
return {
@ -527,30 +354,17 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather];
}
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }
});
private async hasJob(name: string, data: any) {
const jobs = await this.dataGatheringQueue.getJobs(
QUEUE_JOB_STATUS_LIST.filter((status) => {
return status !== 'completed';
})
);
return distinctOrders.filter((distinctOrder) => {
return jobs.some((job) => {
return (
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.RAKUTEN
job.name === name && JSON.stringify(job.data) === JSON.stringify(data)
);
});
}
private async isDataGatheringNeeded() {
const lastDataGathering = await this.getLastDataGathering();
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
const diffInHours = differenceInHours(new Date(), lastDataGathering);
return (diffInHours >= 1 || !lastDataGathering) && !isDataGatheringLocked;
}
}

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