Compare commits

..

197 Commits

Author SHA1 Message Date
a3a9957196 Release 1.27.0 (#223) 2021-07-18 20:05:46 +02:00
9072cbdba1 Feature/add no transactions info on zen page (#222)
* Add no transactions info to zen page

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Update permissions and add discount

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

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

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

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

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

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

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

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

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

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

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

* Move webauthn login to separate page /webauthn

* Stay signed in with social login

* Update changelog

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

* Complete WebAuthn device sign up and login

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

* Fix after rebase

* Fix tests

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

* Add option to "Stay signed in"

* Remove device list feature, sign in with deviceId instead

* Improve usability

* Update changelog

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

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

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

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

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

* Extend seed

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

* Update changelog
2021-06-09 20:32:39 +02:00
beb7e6ec34 Release 1.13.0 (#151) 2021-06-08 22:02:11 +02:00
2eafc042ad Feature/add world map (#150)
* Add a global heat map

* Update changelog
2021-06-08 21:59:46 +02:00
74954bc51d Release 1.12.0 (#149) 2021-06-06 15:33:20 +02:00
6a03120225 Feature/add symbol profile model (#148)
* Add symbol profile model and positions by country chart

* Add positions by continent chart

* Fix tests

* Extend seed

* Update changelog
2021-06-06 15:31:28 +02:00
21504573b4 Release 1.11.0 (#147) 2021-06-05 17:30:59 +02:00
fabd912fba Setup initial prisma migration (#146) 2021-06-05 17:20:52 +02:00
00b42855b6 Feature/upgrade prisma to 2.24.1 (#145)
* Upgrade prisma

* Update changelog

* Update database push script
2021-06-05 17:19:38 +02:00
ef272360fb Feature/render average prices in position detail chart (#144)
* Render average buy prices

* Update changelog
2021-06-05 17:17:53 +02:00
026a5011d4 Feature/add account registration page (#141)
* Add account registration page

* Update changelog
2021-06-05 17:16:07 +02:00
aa4206af0e Feature/various frontend improvements 2 (#140)
* Change buttons to links

* Update changelog
2021-06-05 17:11:03 +02:00
7788465272 Release 1.10.1 (#139) 2021-06-02 21:24:27 +02:00
3066dfd805 Release 1.10.0 (#138) 2021-06-02 20:56:06 +02:00
34303163bc Various frontend improvements (#137)
* Various frontend improvements

* Clean up import
2021-06-02 20:54:12 +02:00
e7fbcd4fa0 Feature/extend pricing page (#130)
* Extend pricing page

* Feature/align pricing page with subscription model (#135)

* Align pricing page with subscription model

* Update changelog
2021-06-02 20:15:53 +02:00
7c22969de1 Feature/move tools to sub path (#125)
* Move tools to sub path

* Update changelog
2021-06-02 20:10:44 +02:00
6623bc0113 Release 1.9.0 (#136) 2021-06-01 21:44:46 +02:00
146b5201b5 Feature/make x ray rules order consistent (#134)
* Make order of X-ray rules consistent

* Update changelog
2021-06-01 21:40:32 +02:00
b021fbde59 Feature/refactor to format distance to now strict (#133)
* Change from formatDistanceToNow to formatDistanceToNowStrict

* Update changelog
2021-06-01 21:38:55 +02:00
ec046b81a7 Fix style (#132) 2021-06-01 21:35:47 +02:00
aea497154a Feature/prettify symbols in transaction filtering component (#131)
* Prettify generic scraper symbols

* Update changelog
2021-06-01 21:34:53 +02:00
dc736d53b4 Fix sorting (#129)
* Fix sorting
2021-06-01 21:33:56 +02:00
5957b33779 Feature/enable labels on the x axis of the investment chart (#128)
* Enable x-axis labels

* Update changelog
2021-05-30 20:39:37 +02:00
bafdce56ad add yarn build:all to .travis.yml (#127) 2021-05-27 22:32:10 +02:00
42a2d404e4 Fix type errors (#126) 2021-05-27 21:12:55 +02:00
11b2379d98 Feature/respect data source in data gathering (#107)
* Respect data source in data gathering

* Update changelog

* optimize fetching from multiple data sources (#123)

* optimize fetching from multiple data sources

* improve performance by executing data gathering promises in parallel

* removed unused imports

* rename hasHistoricalData to canHandle

* Sort imports

* Clean up

Co-authored-by: Valentin Zickner <3200232+vzickner@users.noreply.github.com>
2021-05-27 20:50:10 +02:00
c0657a2e9e Extend README.md (#124)
* Add contributions welcome badge
* Add features
* State technology stack precisely
2021-05-24 21:11:06 +02:00
646dcb91c5 Release 1.8.0 (#122) 2021-05-24 16:32:11 +02:00
ad961f3039 Bugfix/fix missing header of public pages (#121)
* Fix missing header of public pages

* Update changelog
2021-05-24 16:28:42 +02:00
c16f743b07 Feature/add tools section (#120)
* Add tools section

* Update changelog
2021-05-24 16:25:59 +02:00
8e13f6ef9b Bugfix/fix performance chart (#119)
* Fix value of performance chart

* Update changelog
2021-05-24 16:24:54 +02:00
95bcdea69b Refactor cd to changeDetectorRef (#118) 2021-05-24 10:12:53 +02:00
0d6fe4a232 Feature/refactor user service as observable store (#117)
* Implement user service as observable store

* Clean up tokenStorageService usage

* Update changelog
2021-05-24 09:38:44 +02:00
ced4519412 Reorder (#116) 2021-05-22 16:14:16 +02:00
ef8b7718b1 Release 1.7.0 (#115) 2021-05-22 13:49:25 +02:00
b4762dc463 Bugfix/fix internal navigation with query param (#114)
* Fix internal navigation with query parameter

* Add guard

* Update changelog
2021-05-22 13:48:06 +02:00
9851cce382 Feature/hide footer on mobile (#113)
* Hide footer on mobile

* Improve about text

* Update changelog
2021-05-22 13:45:50 +02:00
a1460a98fd Release 1.6.0 (#112) 2021-05-22 10:22:46 +02:00
1a553a296f Feature/improve user table of admin control panel (#109)
* Improve user table

* Add index
* Increase limit
* Improve alignment of cell content

* Update changelog
2021-05-22 10:17:12 +02:00
f5bd6b0d58 Release 1.5.0 (#111) 2021-05-22 10:11:01 +02:00
78a4946e8b Feature/zen mode (#110)
* Start with implementation
* Refactor AuthGuard, persist displayMode in user settings
* Refactor DisplayMode to ViewMode
* Update changelog
2021-05-22 10:04:56 +02:00
702ee956a2 Release 1.4.0 (#108) 2021-05-20 20:43:48 +02:00
200a7d2d65 Feature/refactor search functionality (#105)
* Refactor search functionality

* Update changelog

* Improvements after code review
2021-05-20 20:36:08 +02:00
79edc09710 Store utm_source in local storage (#106) 2021-05-19 20:36:44 +02:00
77255df4be Feature/disable base currency selector for demo user (#104)
* Disable base currency selector based on permission

* Update changelog
2021-05-18 19:36:24 +02:00
277133fa1a Clean up services (#103)
* LanguageService
* TokenStorageService
2021-05-17 19:55:10 +02:00
abd0e08566 Introduce @ghostfolio/common lib (#102) 2021-05-16 22:11:14 +02:00
561d8dbc70 Feature/rename account link (#101)
* Rename account link

* Update changelog
2021-05-16 21:22:03 +02:00
c973ffd3ba Feature/reorganize helper lib (#100)
Reorganize helper lib (Move interfaces and types)
* InfoItem
* PortfolioItem
* PortfolioOverview
* PortfolioPerformance
* Position
* PortfolioPosition
* PortfolioReport
* PortfolioReportRule
* User
* UserSettings
* DateRange
* AdminData
* AccessWithGranteeUser
* OrderWithAccount
* Granularity
* UserWithSettings
* RequestWithUser
2021-05-16 21:20:59 +02:00
368de7dedc Extend unit tests (#99) 2021-05-16 21:19:14 +02:00
e56514629f Refactor postgres variables (#98)
Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-16 09:31:49 +02:00
7a8a25c4c0 Feature/filter by year in transaction table (#97)
* Filter by year

Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-16 09:31:28 +02:00
5d36d3a6bb remove database dependencies for test execution (#96)
Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-15 18:57:27 +02:00
0ef35fd31f Feature/hide unknown exchange (#95)
* Hide unknown exchange

* Update changelog
2021-05-15 17:50:28 +02:00
c1c22c195d Release 1.3.0 (#94) 2021-05-15 10:15:29 +02:00
111d8d8e3c Feature/rename share to allocation in columns of positions table (#93)
* Rename columns

* Initial Share -> Initial Allocation
* Current Share -> Current Allocation

* Update changelog
2021-05-15 10:12:12 +02:00
b0a24e4fc0 Desaturate background color (#92) 2021-05-15 10:09:07 +02:00
694b9b8991 Feature/refactor active menu item state (#91)
* Refactor active menu item state

* Update changelog
2021-05-15 10:05:03 +02:00
fada347aa5 Fix pricing page link (#90) 2021-05-15 08:36:13 +02:00
f37ea9f0e7 Release 1.2.1 (#89) 2021-05-14 21:38:20 +02:00
59911925c2 Feature/update sitemap.xml (#88)
* Update sitemap

* Update changelog

* Fix permissions in header component
2021-05-14 21:35:19 +02:00
58fd59beb1 Release 1.2.0 (#87) 2021-05-14 21:22:38 +02:00
eb09d77251 Feature/move pricing section to page (#86)
* Add a dedicated pricing page

* Update changelog
2021-05-14 21:15:20 +02:00
42b9178d96 Feature/improve filter search in transactions table (#85)
* Improve filter search style

* Update changelog
2021-05-14 21:09:30 +02:00
45516311f5 Improve proportion charts (#83)
* Improve proportion charts

* Clean up code
2021-05-13 21:58:39 +02:00
04cfa7366f Sort imports (#84) 2021-05-12 22:03:07 +02:00
4234ab84a9 Add build badge (#81) 2021-05-12 20:35:28 +02:00
b8c05d1014 Feature/harmonize table styles (#80)
* Harmonize table styles

* Update changelog
2021-05-12 20:32:34 +02:00
91ec9aa0a4 Release 1.1.0 (#79) 2021-05-11 18:14:49 +02:00
565e920f1b Add link to retrieve manually the current market price (#74)
* feat: add link to retrieve manually the current market price from data source

* Add icon

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-05-11 18:01:46 +02:00
5d24adfa75 Feature/improve transaction filtering (#76)
* add multi-filter support for transaction filtering with auto completion

* update changelog

* fix table for transaction for accounts without platform

* simplify readme file since docker compose build is not required (#75)

* simplify readme file since docker compose build is not required

* add anchor navigation in README.md

* Improve UI

* Refactoring

* Refactoring

* Feature/travis (#77)

* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml

* Bugfix/keep current menu item active (#78)

* Keep current menu item active

* Update changelog

* Feature/travis (#77)

* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml

* Keep current menu item active

* Update changelog

Co-authored-by: Valentin Zickner <3200232+vzickner@users.noreply.github.com>

* add multi-filter support for transaction filtering with auto completion

* update changelog

* fix table for transaction for accounts without platform

* Improve UI

* Refactoring

* Refactoring

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-05-11 17:55:55 +02:00
1dc94c0027 Bugfix/keep current menu item active (#78)
* Keep current menu item active

* Update changelog

* Feature/travis (#77)

* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml

* Keep current menu item active

* Update changelog

Co-authored-by: Valentin Zickner <3200232+vzickner@users.noreply.github.com>
2021-05-11 17:49:35 +02:00
ebae2f4ec9 Feature/travis (#77)
* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml
2021-05-11 17:43:35 +02:00
7099edc591 simplify readme file since docker compose build is not required (#75)
* simplify readme file since docker compose build is not required

* add anchor navigation in README.md
2021-05-09 20:11:10 +02:00
de973d6bda Add filterPredicate on transactions table to filter by account name (#73)
* fix: add filterPredicate on transactions table to filter by account name

* Minor refactoring

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-05-06 22:57:15 +02:00
993a491d24 Release 1.0.0 (#70) 2021-05-05 12:05:43 +02:00
631efff7ae Add duplicate action on transactions table (#68)
* feat: add duplicate action on transactions table

* fix: review changes

* fix: add type and dataSource
2021-05-05 12:01:56 +02:00
a3d1ac2ce4 Feature/update icon and add google play badge (#69)
* Add maskable icons

* Add Google Play badge

* Update changelog
2021-05-04 21:48:51 +02:00
4484c21757 Clean up platform id (#67) 2021-05-04 17:49:47 +02:00
87cd3ef33f Release 0.99.0 (#65) 2021-05-03 21:25:39 +02:00
163f4a3d3f Feature/allow to delete users (#64)
* Allow to delete users

* Update changelog
2021-05-03 21:23:00 +02:00
a84256dc03 Feature/eliminate platform from order (#63)
* Eliminate platform from order

* Update changelog
2021-05-03 21:19:56 +02:00
cf82066976 Fix test (#62) 2021-05-02 22:38:42 +02:00
e248c9cedd Release 0.98.0 (#61) 2021-05-02 21:22:30 +02:00
90a2fea7d6 Feature/create and update accounts (#60)
* Allow to create and update accounts

* Activate account selector in transaction dialog

* Refactor analytics and report from platforms to accounts
2021-05-02 21:18:52 +02:00
d17b02092e Release 0.97.0 (#59) 2021-05-01 12:33:35 +02:00
c70eb7793e Feature/migration to accounts (#58)
* Migrate transaction table
* Add accounts page
* Add account page logic
2021-05-01 12:30:52 +02:00
e3a1d2b9cf Fix tests (#57) 2021-05-01 12:28:48 +02:00
e68ebdf76d Release 0.96.0 2021-04-30 21:18:42 +02:00
949cf58eef Release 0.96.0 (#56) 2021-04-30 21:14:28 +02:00
0816defb95 Feature/update slogan (#55)
* Harmonize slogan
2021-04-30 21:11:48 +02:00
a076a1c933 Feature/extend position detail dialog (#54)
* Extend position detail dialog

* Absolute change
* Number of transactions
2021-04-30 21:08:43 +02:00
40c95a541d Add data source to transaction model (#53) 2021-04-28 21:30:49 +02:00
2d04d7b8d5 Release 0.94.0 (#52) 2021-04-27 21:04:51 +02:00
94e0feac68 Add scraper symbols to symbol lookup results (#51) 2021-04-27 21:03:22 +02:00
cd9e974c40 Release 0.93.0 (#50) 2021-04-26 22:09:20 +02:00
11700f75d9 Improve colors in dark mode, improve user table (#49) 2021-04-26 21:54:19 +02:00
0439a7beaa Release 0.92.0 (#48) 2021-04-25 21:27:13 +02:00
608b195ba9 Extend user table (#47)
* Column: accounts
* Horizontal scrollbar
2021-04-25 21:25:23 +02:00
8cb5fd64dd Prepare for multi accounts support: store account for new transactions (#46) 2021-04-25 21:22:35 +02:00
ef317a86ed Setup angular compiler options (#45) 2021-04-25 17:41:53 +02:00
19ada83d0b Fix header and about page (#44) 2021-04-25 17:29:04 +02:00
6ecf66ea2a Fix types 2021-04-25 12:34:34 +02:00
65ba16c07c Fix tests 2021-04-25 12:30:28 +02:00
aee7c12c44 Release 0.91.0 (#43) 2021-04-25 12:15:40 +02:00
125956eb3e Refactor number extraction from string (#41) 2021-04-25 12:11:52 +02:00
954224401d Prepare for multi accounts support (#42) 2021-04-25 12:07:32 +02:00
d268de3e12 Feature/improve styling of x ray rules (#40) 2021-04-24 22:22:41 +02:00
39cfb4603b Extend support for feature flags (#39) 2021-04-24 22:01:38 +02:00
15a70abf67 Release 0.90.0 2021-04-22 22:02:52 +02:00
5cb69291f5 Introduce market states (#38)
* closed
* delayed (no live data)
* open
2021-04-22 22:00:46 +02:00
cf582b2e98 Add symbol icon to position detail dialog (#37) 2021-04-22 21:21:52 +02:00
82e159a083 Filter inactive users (#36) 2021-04-22 20:55:05 +02:00
2aff139982 Release 0.89.0 (#35) 2021-04-21 21:17:25 +02:00
6c7adb6193 Prettify generic scraper symbols (#34) 2021-04-21 21:15:49 +02:00
bb2cd1c85a Improve documentation (#31) 2021-04-21 21:14:06 +02:00
9d92c48ab7 Fix text truncation of buttons (#33) 2021-04-21 21:13:38 +02:00
dbed4ea527 Feature/improve imports with paths in tsconfig (#32)
* Improve imports
2021-04-21 20:27:39 +02:00
8d149b5e2b Reduce period of max data gathering (#30) 2021-04-21 18:58:21 +02:00
27f1ec5d8a Release 0.88.0 (#29) 2021-04-20 21:55:57 +02:00
c361143ba2 Fix interceptor for unauthorized http response (#27) 2021-04-20 21:54:01 +02:00
069006145a Improve scraper (#28) 2021-04-20 21:52:01 +02:00
3e3395aff9 Release 0.87.0 (#26) 2021-04-19 22:27:50 +02:00
a2687eacbc Feature/implement scraper (#25)
* Clean up imports

* Implement scraper

* Sort imports
2021-04-19 22:25:52 +02:00
0f2c8c856c Clean up imports (#24) 2021-04-19 14:38:55 +02:00
ec4dbf2a51 Feature/extend database seed (#23)
* Extend database seed

* platforms
* transactions of demo user

* Improve error handling
2021-04-19 14:14:16 +02:00
3d34aa5e80 Add guards (#22) 2021-04-18 20:49:57 +02:00
c45bd70711 Harmonize scripts (#21) 2021-04-18 20:35:34 +02:00
9f876e6020 Update README.md (#20) 2021-04-18 20:30:58 +02:00
354 changed files with 13675 additions and 6386 deletions

4
.env
View File

@ -5,12 +5,12 @@ REDIS_HOST=localhost
REDIS_PORT=6379
# POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=ghostfolio-db
ACCESS_TOKEN_SALT=GHOSTFOLIO
ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://user:password@localhost:5432/ghostfolio-db?sslmode=prefer
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
JWT_SECRET_KEY=123456
PORT=3333

11
.travis.yml Normal file
View File

@ -0,0 +1,11 @@
language: node_js
git:
depth: false
node_js:
- 14
before_script:
- yarn
script:
- yarn format:check
- yarn test
- yarn build:all

View File

@ -5,6 +5,457 @@ 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.27.0 - 18.07.2021
### Changed
- Improved the onboarding
- Flow of creating a new account
- Info message to add the first transaction
### Fixed
- Fixed the chart on the landing page
- Fixed the url to the _Fear & Greed Index_ on the resources page
## 1.26.0 - 17.07.2021
### Added
- Added the import functionality for transactions
- Added the `robots.txt` file
### Changed
- Improved the styling of the current pricing plan
- Improved the styling of the transaction type badge
- Set the public _Stripe_ key dynamically
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
### Fixed
- Fixed the warn color (button) of the theme
## 1.25.0 - 11.07.2021
### Added
- Added the export functionality for transactions
### Changed
- Respected the cash balance on the analysis page
- Improved the settings selectors on the account page
- Harmonized the slogan to "Open Source Wealth Management Software"
### Fixed
- Fixed rendering of currency and platform in dialogs (account and transaction)
- Fixed an issue in the calculation of the average buy prices in the position detail chart
## 1.24.0 - 07.07.2021
### Added
- Added the total value in the create or edit transaction dialog
- Added a balance attribute to the account model
- Calculated the total balance (cash)
### Changed
- Upgraded `@angular/cdk` and `@angular/material` from version `11.0.4` to `12.0.6`
- Upgraded `@nestjs` dependencies
- Upgraded `angular-material-css-vars` from version `1.2.0` to `2.0.0`
- Upgraded `Nx` from version `12.3.6` to `12.5.4`
## 1.23.1 - 03.07.2021
### Fixed
- Fixed the investment chart (drafts)
## 1.23.0 - 03.07.2021
### Added
- Added support for future transactions (drafts)
## 1.22.0 - 25.06.2021
### Added
- Set the user id in the _Stripe_ callback
## 1.21.0 - 22.06.2021
### Changed
- Changed _Stripe_ mode from `subscription` to `payment`
### Fixed
- Fixed the base currency on the pricing page
## 1.20.0 - 21.06.2021
### Added
- Set up _Stripe_ for subscriptions
### Changed
- Improved the style of the _Ghostfolio in Numbers_ section
## 1.19.0 - 17.06.2021
### Added
- Added a _Ghostfolio in Numbers_ section to the about page
## 1.18.0 - 16.06.2021
### Changed
- Improved the pie chart: Investments by sector
- Improved the onboarding for TWA by redirecting to the account registration page
## 1.17.0 - 15.06.2021
### Changed
- Improved the error page of the sign in with fingerprint
- Disable the sign in with fingerprint selector for the demo user
- Upgraded `angular` from version `11.2.4` to `12.0.4`
- Upgraded `angular-material-css-vars` from version `1.1.2` to `1.2.0`
- Upgraded `chart.js` from version `3.2.1` to `3.3.2`
- Upgraded `date-fns` from version `2.19.0` to `2.22.1`
- Upgraded `eslint` and `prettier` dependencies
- Upgraded `ngx-device-detector` from version `2.0.6` to `2.1.1`
- Upgraded `ngx-markdown` from version `11.1.2` to `12.0.1`
## 1.16.0 - 14.06.2021
### Changed
- Improved the sign in with fingerprint
## 1.15.0 - 14.06.2021
### Added
- Added a counter column to the transactions table
- Added a label to indicate the default account in the accounts table
- Added an option to limit the items in pie charts
- Added sign in with fingerprint
### Changed
- Cleaned up the analysis page with an unused chart module
- Improved the cell alignment in the users table of the admin control panel
### Fixed
- Fixed the last activity column of users in the admin control panel
## 1.14.0 - 09.06.2021
### Added
- Added a connect or create symbol profile model logic on creating a new transaction
### Changed
- Improved the global heat map to visualize investments by country
## 1.13.0 - 08.06.2021
### Added
- Added a global heat map to visualize investments by country
## 1.12.0 - 06.06.2021
### Added
- Added a symbol profile model with additional data
- Added new pie charts: Investments by continent and country
## 1.11.0 - 05.06.2021
### Added
- Added a dedicated page for the account registration
- Rendered the average buy prices in the position detail chart (useful for recurring transactions)
- Introduced the initial prisma migration
### Changed
- Changed the buttons to links (`<a>`) on the tools page
- Upgraded `prisma` from version `2.20.1` to `2.24.1`
## 1.10.1 - 02.06.2021
### Fixed
- Fixed an optional type in the user interface
## 1.10.0 - 02.06.2021
### Changed
- Moved the tools to a sub path (`/tools`)
- Extended the pricing page and aligned with the subscription model
## 1.9.0 - 01.06.2021
### Added
- Added the year labels to the investment chart on the x-axis
### Changed
- Respected the data source attribute of the transactions model in the data management for historical data
- Prettified the generic scraper symbols in the transaction filtering component
- Changed to the strict mode of distance formatting between two given dates
### Fixed
- Fixed the sorting in various tables
- Made the order of the rules in the _X-ray_ section consistent
## 1.8.0 - 24.05.2021
### Added
- Added a section for _Analysis_, _X-ray_ and upcoming tools
### Changed
- Introduced a user service implemented as an observable store (single source of truth for state)
### Fixed
- Fixed the performance chart by considering the investment
- Fixed missing header of public pages (_About_, _Pricing_, _Resources_)
## 1.7.0 - 22.05.2021
### Changed
- Hid footer on mobile (except on landing page)
### Fixed
- Fixed the internal navigation of the _Zen Mode_ in combination with a query parameter
## 1.6.0 - 22.05.2021
### Added
- Added an index in the users table of the admin control panel
### Changed
- Improved the alignment in the users table of the admin control panel
## 1.5.0 - 22.05.2021
### Added
- Added _Zen Mode_: the distraction-free view
## 1.4.0 - 20.05.2021
### Added
- Added filtering by year in the transaction filtering component
### Changed
- Renamed _Ghostfolio Account_ to _My Ghostfolio_
- Hid unknown exchange in the position overview
- Disable the base currency selector for the demo user
- Refactored the portfolio unit tests to work without database
- Refactored the search functionality of the data management (aligned with data source)
- Renamed shared helper to `@ghostfolio/common/helper`
- Moved shared interfaces to `@ghostfolio/common/interfaces`
- Moved shared types to `@ghostfolio/common/types`
## 1.3.0 - 15.05.2021
### Changed
- Refactored the active menu item state by parsing the current url
- Used a desaturated background color for unknown types in pie charts
- Renamed the columns _Initial Share_ and _Current Share_ to _Initial Allocation_ and _Current Allocation_ in the positions table
### Fixed
- Fixed the link to the pricing page
## 1.2.1 - 14.05.2021
### Changed
- Updated the sitemap
## 1.2.0 - 14.05.2021
### Changed
- Harmonized the style of various tables
- Keep the color per type when switching between _Initial_ and _Current_ in pie charts
- Upgraded `chart.js` from version `3.0.2` to `3.2.1`
- Moved the pricing section to a dedicated page
- Improved the style of the transaction filtering component
### Fixed
- Fixed the tooltips when switching between _Initial_ and _Current_ in pie charts
## 1.1.0 - 11.05.2021
### Added
- Added a button to fetch the current market price in the create or edit transaction dialog
### Changed
- Improved the transaction filtering with multi filter support
### Fixed
- Fixed the filtering by account name in the transactions table
- Fixed the active menu item state when a modal has opened
## 1.0.0 - 05.05.2021
### Added
- Added the functionality to clone a transaction
- Added a _Google Play_ badge on the landing page
### Changed
- Changed to maskable icons
## 0.99.0 - 03.05.2021
### Added
- Added support for deleting users in the admin control panel
### Changed
- Eliminated the platform attribute from the transaction model
## 0.98.0 - 02.05.2021
### Added
- Added the logic to create and update accounts
## 0.97.0 - 01.05.2021
### Added
- Added an account page as a preparation for the multi accounts support
## 0.96.0 - 30.04.2021
### Added
- Added the absolute change to the position detail dialog
- Added the number of transactions to the position detail dialog
### Changed
- Harmonized the slogan to "Open Source Portfolio Tracker"
## 0.95.0 - 28.04.2021
### Added
- Added a data source attribute to the transactions model
## 0.94.0 - 27.04.2021
### Added
- Added the generic scraper symbols to the symbol lookup results
## 0.93.0 - 26.04.2021
### Changed
- Improved the users table styling of the admin control panel
- Improved the background colors in the dark mode
## 0.92.0 - 25.04.2021
### Added
- Prepared further for multi accounts support: store account for new transactions
- Added a horizontal scrollbar to the users table of the admin control panel
### Fixed
- Fixed an issue in the header with outdated data
- Fixed an issue on the about page with outdated data
## 0.91.0 - 25.04.2021
### Added
- Extended the support for feature flags to simplify the initial project setup
- Prepared for multi accounts support
### Changed
- Improved the styling of the rules in the _X-ray_ section
## 0.90.0 - 22.04.2021
### Added
- Added the symbol logo to the position detail dialog
- Introduced a third option for the market state: `delayed` (besides `open` and `closed`)
### Changed
- Improved the users table of the admin control panel
## 0.89.0 - 21.04.2021
### Added
- Added a prettifier (pipe) for generic scraper symbols
### Fixed
- Fixed the text truncation in buttons of the admin control panel
## 0.88.0 - 20.04.2021
### Changed
- Reverted the restoring of the scroll position when opening a new page
### Fixed
- Fixed the frozen screen if the token has expired
- Fixed some issues in the generic scraper
## 0.87.0 - 19.04.2021
### Added
- Added a generic scraper
### Fixed
- Fixed an issue in the users table of the admin control panel with missing data
## 0.86.1 - 18.04.2021
### Added
@ -18,7 +469,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the about page for the new license
- Optimized the data management for historical data
- Optimized the exchange rate service
- Improved the user table in the admin control panel
- Improved the users table of the admin control panel
### Fixed
@ -53,7 +504,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed an issue in the portfolio update on deleting a transaction
- Fixed an issue in the _X-Ray_ section (missing redirection on logout)
- Fixed an issue in the _X-ray_ section (missing redirection on logout)
## 0.82.0 - 10.04.2021
@ -126,7 +577,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Grouped the _X-Ray_ section visually in _Currency Cluster Risk_ and _Platform Cluster Risk_
- Grouped the _X-ray_ section visually in _Currency Cluster Risk_ and _Platform Cluster Risk_
## 0.76.0 - 02.04.2021
@ -138,7 +589,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed an issue in the _X-Ray_ section (empty portfolio)
- Fixed an issue in the _X-ray_ section (empty portfolio)
## 0.75.0 - 01.04.2021
@ -151,7 +602,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added a _Create Account_ message in the _Live Demo_
- Added skeleton loaders to the _X-Ray_ section
- Added skeleton loaders to the _X-ray_ section
### Changed
@ -167,7 +618,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the intro text in the _X-Ray_ section
- Improved the intro text in the _X-ray_ section
### Fixed
@ -183,7 +634,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added an intro text to the _X-Ray_ section
- Added an intro text to the _X-ray_ section
### Changed
@ -202,7 +653,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the styling in the _X-Ray_ section
- Improved the styling in the _X-ray_ section
## 0.70.0 - 27.03.2021
@ -210,7 +661,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the current _Fear & Greed Index_ as text
- Extended the landing page text: _Ghostfolio_ empowers busy folks...
- Added the first static portfolio analysis rule in the brand new _X-Ray_ section
- Added the first static portfolio analysis rule in the brand new _X-ray_ section
### Changed

View File

@ -1,19 +1,36 @@
<div align="center">
<a href="https://ghostfol.io">
<img
alt="Ghostfolio Logo"
src="https://avatars.githubusercontent.com/u/82473144?s=200"
width="100"
/>
</a>
<h1>Ghostfolio</h1>
<p>
<strong>Open Source Portfolio Tracker</strong>
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p>
<p>
<a href="#contributing">
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3">
</a>
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</p>
</div>
**Ghostfolio** is an open source portfolio tracker. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
## Why Ghostfolio?
@ -40,56 +57,73 @@ Ghostfolio is for you if you are...
## Features
- ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
- ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ Dark Mode
- ✅ Zen Mode
- ✅ Mobile-first design
## Technology
## Technology Stack
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
### Frontend
The frontend is built with [Angular](https://angular.io).
### Backend
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database and [Redis](https://redis.io) for caching.
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database together with [Prisma](https://www.prisma.io) and [Redis](https://redis.io) for caching.
### Frontend
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org/en/download)
- [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- [Docker](https://www.docker.com/products/docker-desktop)
### Setup
1. Run `yarn install`
2. Run `cd docker`
3. Run `docker compose build`
4. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
5. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
6. Start server and client (see _Development_)
7. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Run `cd docker`
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `cd -` to go back to the project root directory
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
## Development
Please make sure you have completed the instructions from [_Setup_](#Setup).
### Start server
- Debug: Run `yarn watch:server` and click "Launch Program" in _Visual Studio Code_
- Serve: Run `yarn start:server`
<ol type="a">
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
<li>Serve: Run <code>yarn start:server</code></li>
</ol>
### Start client
- Run `yarn start:client`
Run `yarn start:client`
## Testing
Run `yarn test`
## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
## License
© 2021 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -86,7 +86,6 @@
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"aot": true,
"assets": [
"apps/client/src/assets",
{
@ -104,6 +103,11 @@
"input": "",
"output": "./"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
@ -121,7 +125,13 @@
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"]
"scripts": ["node_modules/marked/lib/marked.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"production": {
@ -152,7 +162,8 @@
]
}
},
"outputs": ["{options.outputPath}"]
"outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
@ -208,22 +219,22 @@
}
}
},
"helper": {
"root": "libs/helper",
"sourceRoot": "libs/helper/src",
"common": {
"root": "libs/common",
"sourceRoot": "libs/common/src",
"projectType": "library",
"architect": {
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/helper/**/*.ts"]
"lintFilePatterns": ["libs/common/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/helper"],
"outputs": ["coverage/libs/common"],
"options": {
"jestConfig": "libs/helper/jest.config.js",
"jestConfig": "libs/common/jest.config.js",
"passWithNoTests": true
}
}

View File

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

View File

@ -1,10 +1,10 @@
import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { AccessService } from './access.service';
import { Access } from './interfaces/access.interface';
@Controller('access')
export class AccessController {

View File

@ -1,6 +1,6 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { PrismaService } from '../../services/prisma.service';
import { AccessController } from './access.controller';
import { AccessService } from './access.service';

View File

@ -1,9 +1,8 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../services/prisma.service';
import { AccessWithGranteeUser } from './interfaces/access-with-grantee-user.type';
@Injectable()
export class AccessService {
public constructor(private prisma: PrismaService) {}

View File

@ -0,0 +1,241 @@
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Account as AccountModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto';
import { UpdateAccountDto } from './update-account.dto';
@Controller('account')
export class AccountController {
public constructor(
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const account = await this.accountService.accountWithOrders(
{
id_userId: {
id,
userId: this.request.user.id
}
},
{ Order: true }
);
if (account?.isDefault || account?.Order.length > 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountService.deleteAccount(
{
id_userId: {
id,
userId: this.request.user.id
}
},
this.request.user.id
);
}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accounts = await this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: impersonationUserId || this.request.user.id }
});
if (
impersonationUserId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
accounts = nullifyValuesInObjects(accounts, [
'fee',
'quantity',
'unitPrice'
]);
}
return accounts;
}
@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
}
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createAccount(
@Body() data: CreateAccountDto
): Promise<AccountModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.createAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.accountService.createAccount(
{
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
} else {
delete data.platformId;
return this.accountService.createAccount(
{
...data,
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.updateAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccount = await this.accountService.account({
id_userId: {
id,
userId: this.request.user.id
}
});
if (!originalAccount) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
} else {
// platformId is null, remove it
delete data.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
Platform: originalAccount.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
}
}
}

View File

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

View File

@ -0,0 +1,113 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Currency, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface';
@Injectable()
export class AccountService {
public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
) {}
public async account(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
): Promise<Account | null> {
return this.prisma.account.findUnique({
where: accountWhereUniqueInput
});
}
public async accountWithOrders(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
accountInclude: Prisma.AccountInclude
): Promise<
Account & {
Order?: Order[];
}
> {
return this.prisma.account.findUnique({
include: accountInclude,
where: accountWhereUniqueInput
});
}
public async accounts(params: {
include?: Prisma.AccountInclude;
skip?: number;
take?: number;
cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput;
}): Promise<Account[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.account.findMany({
cursor,
include,
orderBy,
skip,
take,
where
});
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
): Promise<Account> {
return this.prisma.account.create({
data
});
}
public async deleteAccount(
where: Prisma.AccountWhereUniqueInput,
aUserId: string
): Promise<Account> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
return this.prisma.account.delete({
where
});
}
public async getCashDetails(
aUserId: string,
aCurrency: Currency
): Promise<CashDetails> {
let totalCashBalance = 0;
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
);
});
return { accounts, balance: totalCashBalance };
}
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;
data: Prisma.AccountUpdateInput;
},
aUserId: string
): Promise<Account> {
const { data, where } = params;
return this.prisma.account.update({
data,
where
});
}
}

View File

@ -0,0 +1,20 @@
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
name: string;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
}

View File

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

View File

@ -0,0 +1,23 @@
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
id: string;
@IsString()
name: string;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
}

View File

@ -1,3 +1,11 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AdminData } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -8,13 +16,9 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
import { DataGatheringService } from '../../services/data-gathering.service';
import { AdminService } from './admin.service';
import { AdminData } from './interfaces/admin-data.interface';
@Controller('admin')
export class AdminController {

View File

@ -1,13 +1,14 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ConfigurationService } from '../../services/configuration.service';
import { DataGatheringService } from '../../services/data-gathering.service';
import { DataProviderService } from '../../services/data-provider.service';
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { PrismaService } from '../../services/prisma.service';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@ -21,6 +22,7 @@ import { AdminService } from './admin.service';
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService

View File

@ -1,10 +1,9 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AdminData } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { PrismaService } from '../../services/prisma.service';
import { AdminData } from './interfaces/admin-data.interface';
@Injectable()
export class AdminService {
public constructor(
@ -97,7 +96,7 @@ export class AdminService {
},
select: {
_count: {
select: { Order: true }
select: { Account: true, Order: true }
},
alias: true,
Analytics: {
@ -109,7 +108,12 @@ export class AdminService {
createdAt: true,
id: true
},
take: 20
take: 30,
where: {
NOT: {
Analytics: null
}
}
});
}
}

View File

@ -1,5 +1,6 @@
import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
@ -10,20 +11,25 @@ import { CronService } from '../services/cron.service';
import { DataGatheringService } from '../services/data-gathering.service';
import { DataProviderService } from '../services/data-provider.service';
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { PrismaService } from '../services/prisma.service';
import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module';
@ -31,10 +37,14 @@ import { UserModule } from './user/user.module';
imports: [
AdminModule,
AccessModule,
AccountModule,
AuthDeviceModule,
AuthModule,
CacheModule,
ConfigModule.forRoot(),
ExperimentalModule,
ExportModule,
ImportModule,
InfoModule,
OrderModule,
PortfolioModule,
@ -54,6 +64,7 @@ import { UserModule } from './user/user.module';
rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*']
}),
SubscriptionModule,
SymbolModule,
UserModule
],
@ -65,6 +76,7 @@ import { UserModule } from './user/user.module';
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigurationService } from '../../services/configuration.service';
import { UserService } from '../user/user.service';
import { ValidateOAuthLoginParams } from './interfaces/interfaces';

View File

@ -1,9 +1,9 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { Strategy } from 'passport-google-oauth20';
import { ConfigurationService } from '../../services/configuration.service';
import { AuthService } from './auth.service';
@Injectable()

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigurationService } from '../../services/configuration.service';
import { PrismaService } from '../../services/prisma.service';
import { UserService } from '../user/user.service';
@Injectable()

View File

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

View File

@ -1,7 +1,7 @@
import { Controller, Inject, Param, Post, UseGuards } from '@nestjs/common';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CacheService } from './cache.service';

View File

@ -1,6 +1,6 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { PrismaService } from '../../services/prisma.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { CacheController } from './cache.controller';
import { CacheService } from './cache.service';

View File

@ -1,7 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, User } from '@prisma/client';
import { PrismaService } from '../../services/prisma.service';
@Injectable()
export class CacheService {

View File

@ -1,3 +1,6 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -9,11 +12,8 @@ import {
Post
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { parse } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { baseCurrency, benchmarks } from 'libs/helper/src';
import { isApiTokenAuthorized } from 'libs/helper/src';
import { CreateOrderDto } from './create-order.dto';
import { ExperimentalService } from './experimental.service';
@ -37,7 +37,9 @@ export class ExperimentalController {
);
}
return benchmarks;
return benchmarks.map(({ symbol }) => {
return symbol;
});
}
@Get('benchmarks/:symbol')

View File

@ -1,25 +1,30 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { Module } from '@nestjs/common';
import { ConfigurationService } from '../../services/configuration.service';
import { DataProviderService } from '../../services/data-provider.service';
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { PrismaService } from '../../services/prisma.service';
import { RulesService } from '../../services/rules.service';
import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service';
@Module({
imports: [],
imports: [RedisCacheModule],
controllers: [ExperimentalController],
providers: [
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
ExperimentalService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
RulesService,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get } from '@nestjs/common';
import { InfoService } from './info.service';
import { InfoItem } from './interfaces/info-item.interface';
@Controller('info')
export class InfoController {

View File

@ -1,8 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigurationService } from '../../services/configuration.service';
import { PrismaService } from '../../services/prisma.service';
import { InfoController } from './info.controller';
import { InfoService } from './info.service';

View File

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

View File

@ -1,3 +0,0 @@
import { Settings, User } from '@prisma/client';
export type UserWithSettings = User & { Settings: Settings };

View File

@ -1,20 +1,22 @@
import { Currency, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto {
@IsString()
accountId: string;
@IsString()
currency: Currency;
@IsString()
dataSource: DataSource;
@IsISO8601()
date: string;
@IsNumber()
fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsNumber()
quantity: number;

View File

@ -1,3 +0,0 @@
import { Order, Platform } from '@prisma/client';
export type OrderWithPlatform = Order & { Platform?: Platform };

View File

@ -1,3 +1,11 @@
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -14,13 +22,9 @@ import {
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Order as OrderModel } from '@prisma/client';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
import { nullifyValuesInObjects } from '../../helper/object.helper';
import { ImpersonationService } from '../../services/impersonation.service';
import { CreateOrderDto } from './create-order.dto';
import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@ -64,14 +68,19 @@ export class OrderController {
public async getAllOrders(
@Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let orders = await this.orderService.orders({
include: {
Account: {
include: {
Platform: true
}
}
},
orderBy: { date: 'desc' },
where: { userId: impersonationUserId || this.request.user.id }
@ -118,31 +127,36 @@ export class OrderController {
const date = parseISO(data.date);
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
const accountId = data.accountId;
delete data.accountId;
return this.orderService.createOrder(
{
...data,
date,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
} else {
delete data.platformId;
return this.orderService.createOrder(
{
...data,
date,
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
date,
SymbolProfile: {
connectOrCreate: {
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
@Put(':id')
@ -167,41 +181,28 @@ export class OrderController {
}
});
if (!originalOrder) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(data.date);
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
const accountId = data.accountId;
delete data.accountId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
this.request.user.id
);
} else {
// platformId is null, remove it
delete data.platformId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Platform: originalOrder.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
},
where: {
@ -215,4 +216,3 @@ export class OrderController {
);
}
}
}

View File

@ -1,13 +1,14 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ConfigurationService } from '../../services/configuration.service';
import { DataGatheringService } from '../../services/data-gathering.service';
import { DataProviderService } from '../../services/data-provider.service';
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ImpersonationService } from '../../services/impersonation.service';
import { PrismaService } from '../../services/prisma.service';
import { CacheService } from '../cache/cache.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { OrderController } from './order.controller';
@ -22,6 +23,7 @@ import { OrderService } from './order.service';
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
ImpersonationService,
OrderService,
PrismaService,

View File

@ -1,11 +1,12 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Order, Prisma } from '@prisma/client';
import { DataSource, Order, Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
import { DataGatheringService } from '../../services/data-gathering.service';
import { PrismaService } from '../../services/prisma.service';
import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { OrderWithPlatform } from './interfaces/order-with-platform.type';
@Injectable()
export class OrderService {
@ -31,7 +32,7 @@ export class OrderService {
cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByInput;
}): Promise<OrderWithPlatform[]> {
}): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.order.findMany({
@ -50,13 +51,16 @@ export class OrderService {
): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
// Gather symbol data of order in the background
if (!isAfter(data.date as Date, endOfToday())) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: <Date>data.date,
symbol: data.symbol
}
]);
}
await this.cacheService.flush(aUserId);
@ -90,6 +94,7 @@ export class OrderService {
// Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([
{
dataSource: <DataSource>data.dataSource,
date: <Date>data.date,
symbol: <string>data.symbol
}

View File

@ -1,20 +1,22 @@
import { Currency, Type } from '@prisma/client';
import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateOrderDto {
@IsString()
accountId: string;
@IsString()
currency: Currency;
@IsString()
dataSource: DataSource;
@IsISO8601()
date: string;
@IsNumber()
fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsString()
id: string;

View File

@ -1,6 +1,8 @@
import { Currency } from '@prisma/client';
export interface PortfolioPositionDetail {
averagePrice: number;
currency: string;
currency: Currency;
firstBuyDate: string;
grossPerformance: number;
grossPerformancePercent: number;
@ -11,6 +13,7 @@ export interface PortfolioPositionDetail {
minPrice: number;
quantity: number;
symbol: string;
transactionCount: number;
}
export interface HistoricalDataItem {

View File

@ -1,3 +1,22 @@
import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import {
PortfolioItem,
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -13,24 +32,11 @@ import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '../../helper/object.helper';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { ImpersonationService } from '../../services/impersonation.service';
import { RequestWithUser } from '../interfaces/request-with-user.type';
import { PortfolioItem } from './interfaces/portfolio-item.interface';
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
import { PortfolioPerformance } from './interfaces/portfolio-performance.interface';
import {
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { PortfolioPosition } from './interfaces/portfolio-position.interface';
import { PortfolioReport } from './interfaces/portfolio-report.interface';
import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
@ -136,7 +142,8 @@ export class PortfolioController {
): Promise<{ [symbol: string]: PortfolioPosition }> {
let details: { [symbol: string]: PortfolioPosition } = {};
const impersonationUserId = await this.impersonationService.validateImpersonationId(
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
@ -185,11 +192,11 @@ export class PortfolioController {
portfolioPosition.investment =
portfolioPosition.investment / totalInvestment;
for (const [platform, { current, original }] of Object.entries(
portfolioPosition.platforms
for (const [account, { current, original }] of Object.entries(
portfolioPosition.accounts
)) {
portfolioPosition.platforms[platform].current = current / totalValue;
portfolioPosition.platforms[platform].original =
portfolioPosition.accounts[account].current = current / totalValue;
portfolioPosition.accounts[account].original =
original / totalInvestment;
}
@ -215,6 +222,7 @@ export class PortfolioController {
)
) {
overview = nullifyValuesInObject(overview, [
'cash',
'committedFunds',
'fees',
'totalBuy',
@ -232,7 +240,8 @@ export class PortfolioController {
@Query('range') range,
@Res() res: Response
): Promise<PortfolioPerformance> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
@ -300,7 +309,8 @@ export class PortfolioController {
public async getReport(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioReport> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
@ -309,18 +319,6 @@ export class PortfolioController {
impersonationUserId || this.request.user.id
);
let report = await portfolio.getReport();
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
// TODO: Filter out absolute numbers
}
return report;
return await portfolio.getReport();
}
}

View File

@ -1,19 +1,21 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserService } from '@ghostfolio/api/app/user/user.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.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { Module } from '@nestjs/common';
import { ConfigurationService } from '../../services/configuration.service';
import { DataGatheringService } from '../../services/data-gathering.service';
import { DataProviderService } from '../../services/data-provider.service';
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { ImpersonationService } from '../../services/impersonation.service';
import { PrismaService } from '../../services/prisma.service';
import { RulesService } from '../../services/rules.service';
import { CacheService } from '../cache/cache.service';
import { OrderService } from '../order/order.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { UserService } from '../user/user.service';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
@ -21,12 +23,14 @@ import { PortfolioService } from './portfolio.service';
imports: [RedisCacheModule],
controllers: [PortfolioController],
providers: [
AccountService,
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
OrderService,
PortfolioService,

View File

@ -1,8 +1,25 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import {
PortfolioItem,
PortfolioOverview
} from '@ghostfolio/common/interfaces';
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { DataSource } from '@prisma/client';
import {
add,
addMonths,
endOfToday,
format,
getDate,
getMonth,
@ -18,18 +35,6 @@ import {
import { isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { Portfolio } from '../../models/portfolio';
import { DataProviderService } from '../../services/data-provider.service';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { ImpersonationService } from '../../services/impersonation.service';
import { IOrder } from '../../services/interfaces/interfaces';
import { RulesService } from '../../services/rules.service';
import { OrderService } from '../order/order.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { UserService } from '../user/user.service';
import { DateRange } from './interfaces/date-range.type';
import { PortfolioItem } from './interfaces/portfolio-item.interface';
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
import {
HistoricalDataItem,
PortfolioPositionDetail
@ -38,6 +43,7 @@ import {
@Injectable()
export class PortfolioService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
@ -48,6 +54,337 @@ export class PortfolioService {
private readonly userService: UserService
) {}
public async createPortfolio(aUserId: string): Promise<Portfolio> {
let portfolio: Portfolio;
const stringifiedPortfolio = await this.redisCacheService.get(
`${aUserId}.portfolio`
);
const user = await this.userService.user({ id: aUserId });
if (stringifiedPortfolio) {
// Get portfolio from redis
const {
orders,
portfolioItems
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } =
JSON.parse(stringifiedPortfolio);
portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
).createFromData({ orders, portfolioItems, user });
} else {
// Get portfolio from database
const orders = await this.orderService.orders({
include: {
Account: true,
SymbolProfile: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
});
portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
);
portfolio.setUser(user);
await portfolio.setOrders(orders);
// Cache data for the next time...
const portfolioData = {
orders: portfolio.getOrders(),
portfolioItems: portfolio.getPortfolioItems()
};
await this.redisCacheService.set(
`${aUserId}.portfolio`,
JSON.stringify(portfolioData)
);
}
// Enrich portfolio with current data
await portfolio.addCurrentPortfolioItems();
// Enrich portfolio with future data
await portfolio.addFuturePortfolioItems();
return portfolio;
}
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
try {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
return portfolio.get();
} catch (error) {
console.error(error);
}
}
public async getChart(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
if (portfolio.getOrders().length <= 0) {
return [];
}
const dateRangeDate = this.convertDateRangeToDate(
aDateRange,
portfolio.getMinDate()
);
return portfolio
.get()
.filter((portfolioItem) => {
if (isAfter(parseISO(portfolioItem.date), endOfToday())) {
// Filter out future dates
return false;
}
if (dateRangeDate === undefined) {
return true;
}
return (
isSameDay(parseISO(portfolioItem.date), dateRangeDate) ||
isAfter(parseISO(portfolioItem.date), dateRangeDate)
);
})
.map((portfolioItem) => {
return {
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
grossPerformancePercent: portfolioItem.grossPerformancePercent,
marketPrice: portfolioItem.value ?? null,
value: portfolioItem.value - portfolioItem.investment ?? null
};
});
}
public async getOverview(
aImpersonationId: string
): Promise<PortfolioOverview> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const { balance } = await this.accountService.getCashDetails(
impersonationUserId || this.request.user.id,
this.request.user.Settings.currency
);
const committedFunds = portfolio.getCommittedFunds();
const fees = portfolio.getFees();
return {
committedFunds,
fees,
cash: balance,
ordersCount: portfolio.getOrders().length,
totalBuy: portfolio.getTotalBuy(),
totalSell: portfolio.getTotalSell()
};
}
public async getPosition(
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const position = portfolio.getPositions(new Date())[aSymbol];
if (position) {
const {
averagePrice,
currency,
firstBuyDate,
investment,
quantity,
transactionCount
} = position;
let marketPrice = position.marketPrice;
const orders = portfolio.getOrders(aSymbol);
const historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
parseISO(firstBuyDate),
new Date()
);
if (marketPrice === 0) {
marketPrice = averagePrice;
}
const historicalDataArray: HistoricalDataItem[] = [];
let currentAveragePrice: number;
let maxPrice = marketPrice;
let minPrice = marketPrice;
if (historicalData[aSymbol]) {
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
)) {
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
if (
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
) {
// Get snapshot of first day of next month
const snapshot = portfolio.get(
addMonths(setDate(currentDate, 1), 1)
)?.[0]?.positions[aSymbol];
orders.shift();
if (snapshot?.averagePrice) {
currentAveragePrice = snapshot.averagePrice;
}
}
historicalDataArray.push({
date,
averagePrice: currentAveragePrice,
value: marketPrice
});
if (
marketPrice &&
(marketPrice > maxPrice || maxPrice === undefined)
) {
maxPrice = marketPrice;
}
if (
marketPrice &&
(marketPrice < minPrice || minPrice === undefined)
) {
minPrice = marketPrice;
}
}
}
return {
averagePrice,
currency,
firstBuyDate,
investment,
marketPrice,
maxPrice,
minPrice,
quantity,
transactionCount,
grossPerformance: this.exchangeRateDataService.toCurrency(
marketPrice - averagePrice,
currency,
this.request.user.Settings.currency
),
grossPerformancePercent: roundTo(
(marketPrice - averagePrice) / averagePrice,
4
),
historicalData: historicalDataArray,
symbol: aSymbol
};
} else if (portfolio.getMinDate()) {
const currentData = await this.dataProviderService.get([aSymbol]);
let historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
portfolio.getMinDate(),
new Date()
);
if (isEmpty(historicalData)) {
historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
portfolio.getMinDate(),
new Date()
);
}
const historicalDataArray: HistoricalDataItem[] = [];
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
).reverse()) {
historicalDataArray.push({
date,
value: marketPrice
});
}
return {
averagePrice: undefined,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: undefined,
marketPrice: currentData[aSymbol]?.marketPrice,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol,
transactionCount: undefined
};
}
return {
averagePrice: undefined,
currency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: [],
investment: undefined,
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol,
transactionCount: undefined
};
}
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
let currentDate = new Date();
@ -94,292 +431,4 @@ export class PortfolioService {
return undefined;
}
}
public async createPortfolio(aUserId: string): Promise<Portfolio> {
let portfolio: Portfolio;
let stringifiedPortfolio = await this.redisCacheService.get(
`${aUserId}.portfolio`
);
const user = await this.userService.user({ id: aUserId });
if (stringifiedPortfolio) {
// Get portfolio from redis
const {
orders,
portfolioItems
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = JSON.parse(
stringifiedPortfolio
);
portfolio = new Portfolio(
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
).createFromData({ orders, portfolioItems, user });
} else {
// Get portfolio from database
const orders = await this.orderService.orders({
include: {
Platform: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
});
portfolio = new Portfolio(
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
);
portfolio.setUser(user);
await portfolio.setOrders(orders);
// Cache data for the next time...
const portfolioData = {
orders: portfolio.getOrders(),
portfolioItems: portfolio.getPortfolioItems()
};
await this.redisCacheService.set(
`${aUserId}.portfolio`,
JSON.stringify(portfolioData)
);
}
// Enrich portfolio with current data
return await portfolio.addCurrentPortfolioItems();
}
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
try {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
return portfolio.get();
} catch (error) {
console.error(error);
}
}
public async getChart(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
if (portfolio.getOrders().length <= 0) {
return [];
}
const dateRangeDate = this.convertDateRangeToDate(
aDateRange,
portfolio.getMinDate()
);
return portfolio
.get()
.filter((portfolioItem) => {
if (dateRangeDate === undefined) {
return true;
}
return (
isSameDay(parseISO(portfolioItem.date), dateRangeDate) ||
isAfter(parseISO(portfolioItem.date), dateRangeDate)
);
})
.map((portfolioItem) => {
return {
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
grossPerformancePercent: portfolioItem.grossPerformancePercent,
marketPrice: portfolioItem.value || null,
value: portfolioItem.value || null
};
});
}
public async getOverview(
aImpersonationId: string
): Promise<PortfolioOverview> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const committedFunds = portfolio.getCommittedFunds();
const fees = portfolio.getFees();
return {
committedFunds,
fees,
ordersCount: portfolio.getOrders().length,
totalBuy: portfolio.getTotalBuy(),
totalSell: portfolio.getTotalSell()
};
}
public async getPosition(
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const positions = portfolio.getPositions(new Date())[aSymbol];
if (positions) {
let {
averagePrice,
currency,
firstBuyDate,
investment,
marketPrice,
quantity
} = portfolio.getPositions(new Date())[aSymbol];
const historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
parseISO(firstBuyDate),
new Date()
);
if (marketPrice === 0) {
marketPrice = averagePrice;
}
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = marketPrice;
let minPrice = marketPrice;
if (historicalData[aSymbol]) {
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
)) {
historicalDataArray.push({
averagePrice,
date,
value: marketPrice
});
if (
marketPrice &&
(marketPrice > maxPrice || maxPrice === undefined)
) {
maxPrice = marketPrice;
}
if (
marketPrice &&
(marketPrice < minPrice || minPrice === undefined)
) {
minPrice = marketPrice;
}
}
}
return {
averagePrice,
currency,
firstBuyDate,
investment,
marketPrice,
maxPrice,
minPrice,
quantity,
grossPerformance: this.exchangeRateDataService.toCurrency(
marketPrice - averagePrice,
currency,
this.request.user.Settings.currency
),
grossPerformancePercent: roundTo(
(marketPrice - averagePrice) / averagePrice,
4
),
historicalData: historicalDataArray,
symbol: aSymbol
};
} else if (portfolio.getMinDate()) {
const currentData = await this.dataProviderService.get([aSymbol]);
let historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
portfolio.getMinDate(),
new Date()
);
if (isEmpty(historicalData)) {
historicalData = await this.dataProviderService.getHistoricalRaw(
[aSymbol],
portfolio.getMinDate(),
new Date()
);
}
const historicalDataArray: HistoricalDataItem[] = [];
for (const [date, { marketPrice, performance }] of Object.entries(
historicalData[aSymbol]
).reverse()) {
historicalDataArray.push({
date,
value: marketPrice
});
}
return {
averagePrice: undefined,
currency: currentData[aSymbol].currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: undefined,
marketPrice: currentData[aSymbol].marketPrice,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol
};
}
return {
averagePrice: undefined,
currency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: [],
investment: undefined,
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol
};
}
}

View File

@ -1,8 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CacheModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import * as redisStore from 'cache-manager-redis-store';
import { ConfigurationService } from '../../services/configuration.service';
import { RedisCacheService } from './redis-cache.service';
@Module({

View File

@ -1,8 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { ConfigurationService } from '../../services/configuration.service';
@Injectable()
export class RedisCacheService {
public constructor(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -9,7 +10,6 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { LookupItem } from './interfaces/lookup-item.interface';
@ -28,9 +28,12 @@ export class SymbolController {
*/
@Get('lookup')
@UseGuards(AuthGuard('jwt'))
public async lookupSymbol(@Query() { query }): Promise<LookupItem[]> {
public async lookupSymbol(
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try {
return this.symbolService.lookup(query);
const encodedQuery = encodeURIComponent(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -1,11 +1,12 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ConfigurationService } from '../../services/configuration.service';
import { DataProviderService } from '../../services/data-provider.service';
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '../../services/prisma.service';
import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service';
@ -16,6 +17,7 @@ import { SymbolService } from './symbol.service';
AlphaVantageService,
ConfigurationService,
DataProviderService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
SymbolService,

View File

@ -1,64 +1,54 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { convertFromYahooSymbol } from 'apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service';
import * as bent from 'bent';
import { Currency, DataSource } from '@prisma/client';
import { DataProviderService } from '../../services/data-provider.service';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable()
export class SymbolService {
public constructor(
private readonly dataProviderService: DataProviderService
private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService
) {}
public async get(aSymbol: string): Promise<SymbolItem> {
const response = await this.dataProviderService.get([aSymbol]);
const { currency, marketPrice } = response[aSymbol];
const { currency, dataSource, marketPrice } = response[aSymbol];
return {
dataSource,
marketPrice,
currency: <Currency>(<unknown>currency)
};
}
public async lookup(aQuery: string): Promise<LookupItem[]> {
const get = bent(
`https://query1.finance.yahoo.com/v1/finance/search?q=${aQuery}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] };
try {
const { quotes } = await get();
return quotes
.filter(({ isYahooFinance }) => {
return isYahooFinance;
})
.filter(({ quoteType }) => {
return (
quoteType === 'CRYPTOCURRENCY' ||
quoteType === 'EQUITY' ||
quoteType === 'ETF'
);
})
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
return symbol.includes('USD');
if (!aQuery) {
return results;
}
return true;
})
.map(({ longname, shortname, symbol }) => {
return {
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
try {
const { items } = await this.dataProviderService.search(aQuery);
results.items = items;
// Add custom symbols
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
scraperConfigurations.forEach((scraperConfiguration) => {
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
results.items.push({
dataSource: DataSource.GHOSTFOLIO,
name: scraperConfiguration.name,
symbol: scraperConfiguration.symbol
});
}
});
return results;
} catch (error) {
console.error(error);

View File

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

View File

@ -1,20 +0,0 @@
import { Currency } from '@prisma/client';
import { Access } from './access.interface';
export interface User {
access: Access[];
alias?: string;
id: string;
permissions: string[];
settings: UserSettings;
subscription: {
expiresAt: Date;
type: 'Trial';
};
}
export interface UserSettings {
baseCurrency: Currency;
locale: string;
}

View File

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

View File

@ -1,6 +1,14 @@
import { User } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
@ -13,12 +21,11 @@ import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
import { UserItem } from './interfaces/user-item.interface';
import { User } from './interfaces/user.interface';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service';
@ -30,6 +37,27 @@ export class UserController {
private readonly userService: UserService
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteUser
) ||
id === this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.userService.deleteUser({
id
});
}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getUser(@Param('id') id: string): Promise<User> {
@ -65,9 +93,20 @@ export class UserController {
);
}
return await this.userService.updateUserSettings({
currency: data.currency,
const userSettings: UserSettingsParams = {
currency: data.baseCurrency,
userId: this.request.user.id
});
};
if (
hasPermission(
getPermissions(this.request.user.role),
permissions.updateViewMode
)
) {
userSettings.viewMode = data.viewMode;
}
return await this.userService.updateUserSettings(userSettings);
}
}

View File

@ -1,8 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigurationService } from '../../services/configuration.service';
import { PrismaService } from '../../services/prisma.service';
import { UserController } from './user.controller';
import { UserService } from './user.service';

View File

@ -1,13 +1,14 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { locale } from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User } from '@prisma/client';
import { add } from 'date-fns';
import { locale, permissions, resetHours } from 'libs/helper/src';
import { getPermissions } from 'libs/helper/src';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { isBefore } from 'date-fns';
import { ConfigurationService } from '../../services/configuration.service';
import { PrismaService } from '../../services/prisma.service';
import { UserWithSettings } from '../interfaces/user-with-settings';
import { User as IUser } from './interfaces/user.interface';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
const crypto = require('crypto');
@ -21,10 +22,12 @@ export class UserService {
) {}
public async getUser({
Account,
alias,
id,
role,
Settings
permissions,
Settings,
subscription
}: UserWithSettings): Promise<IUser> {
const access = await this.prisma.access.findMany({
include: {
@ -34,33 +37,22 @@ export class UserService {
where: { GranteeUser: { id } }
});
const currentPermissions = getPermissions(role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
currentPermissions.push(permissions.useSocialLogin);
}
return {
alias,
id,
permissions,
subscription,
access: access.map((accessItem) => {
return {
alias: accessItem.User.alias,
id: accessItem.id
};
}),
permissions: currentPermissions,
accounts: Account,
settings: {
baseCurrency: Settings?.currency || UserService.DEFAULT_CURRENCY,
locale
},
subscription: {
expiresAt: resetHours(add(new Date(), { days: 7 })),
type: 'Trial'
locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
}
};
}
@ -68,25 +60,64 @@ export class UserService {
public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
const user = await this.prisma.user.findUnique({
include: { Settings: true },
const userFromDatabase = await this.prisma.user.findUnique({
include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput
});
if (user?.Settings) {
if (!user.Settings.currency) {
// Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY;
const user: UserWithSettings = userFromDatabase;
const currentPermissions = getPermissions(userFromDatabase.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
} else if (user) {
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
user.Settings = {
userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY,
updatedAt: new Date(),
userId: user?.id
userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (userFromDatabase?.Subscription?.length > 0) {
const latestSubscription = userFromDatabase.Subscription.reduce(
(a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}
);
user.subscription = {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
user.subscription = {
type: SubscriptionType.Basic
};
}
if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => {
return permission !== permissions.updateViewMode;
});
user.Settings.viewMode = ViewMode.ZEN;
}
}
return user;
}
@ -116,7 +147,15 @@ export class UserService {
public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
let user = await this.prisma.user.create({
data
data: {
...data,
Account: {
create: {
isDefault: true,
name: 'Default Account'
}
}
}
});
if (data.provider === Provider.ANONYMOUS) {
@ -153,11 +192,60 @@ export class UserService {
}
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
await this.prisma.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
});
await this.prisma.account.deleteMany({
where: { userId: where.id }
});
await this.prisma.analytics.delete({
where: { userId: where.id }
});
await this.prisma.order.deleteMany({
where: { userId: where.id }
});
try {
await this.prisma.settings.delete({
where: { userId: where.id }
});
} catch {}
return this.prisma.user.delete({
where
});
}
public async updateUserSettings({
currency,
userId,
viewMode
}: UserSettingsParams) {
await this.prisma.settings.upsert({
create: {
currency,
User: {
connect: {
id: userId
}
},
viewMode
},
update: {
currency,
viewMode
},
where: {
userId: userId
}
});
return;
}
private getRandomString(length: number) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const result = [];
@ -169,31 +257,4 @@ export class UserService {
}
return result.join('');
}
public async updateUserSettings({
currency,
userId
}: {
currency: Currency;
userId: string;
}) {
await this.prisma.settings.upsert({
create: {
currency,
User: {
connect: {
id: userId
}
}
},
update: {
currency
},
where: {
userId: userId
}
});
return;
}
}

View File

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

View File

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

View File

@ -1,7 +1,4 @@
import {
PortfolioItem,
Position
} from 'apps/api/src/app/portfolio/interfaces/portfolio-item.interface';
import { PortfolioItem, Position } from '@ghostfolio/common/interfaces';
import { Order } from '../order';

View File

@ -1,4 +1,5 @@
import { PortfolioPosition } from '../../app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './evaluation-result.interface';
export interface RuleInterface {

View File

@ -1,35 +1,42 @@
import { Currency, Platform } from '@prisma/client';
import { Account, Currency, SymbolProfile } from '@prisma/client';
import { endOfToday, isAfter, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
import { OrderType } from './order-type';
export class Order {
private account: Account;
private currency: Currency;
private fee: number;
private date: string;
private id: string;
private quantity: number;
private platform: Platform;
private symbol: string;
private symbolProfile: SymbolProfile;
private total: number;
private type: OrderType;
private unitPrice: number;
public constructor(data: IOrder) {
this.account = data.account;
this.currency = data.currency;
this.fee = data.fee;
this.date = data.date;
this.id = data.id || uuidv4();
this.platform = data.platform;
this.quantity = data.quantity;
this.symbol = data.symbol;
this.symbolProfile = data.symbolProfile;
this.type = data.type;
this.unitPrice = data.unitPrice;
this.total = this.quantity * data.unitPrice;
}
public getAccount() {
return this.account;
}
public getCurrency() {
return this.currency;
}
@ -46,8 +53,8 @@ export class Order {
return this.id;
}
public getPlatform() {
return this.platform;
public getIsDraft() {
return isAfter(parseISO(this.date), endOfToday());
}
public getQuantity() {
@ -58,6 +65,10 @@ export class Order {
return this.symbol;
}
getSymbolProfile() {
return this.symbolProfile;
}
public getTotal() {
return this.total;
}

View File

@ -1,76 +1,151 @@
import { Test } from '@nestjs/testing';
import { Currency, Role, Type } from '@prisma/client';
import { baseCurrency } from 'libs/helper/src';
import { getYesterday } from 'libs/helper/src';
import { getUtc } from 'libs/helper/src';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import {
AccountType,
Currency,
DataSource,
Role,
Type,
ViewMode
} from '@prisma/client';
import { format } from 'date-fns';
import { ConfigurationService } from '../services/configuration.service';
import { DataProviderService } from '../services/data-provider.service';
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { PrismaService } from '../services/prisma.service';
import { MarketState } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio';
jest.mock('../app/account/account.service', () => {
return {
AccountService: jest.fn().mockImplementation(() => {
return {
getCashDetails: () => Promise.resolve({ accounts: [], balance: 0 })
};
})
};
});
jest.mock('../services/data-provider.service', () => {
return {
DataProviderService: jest.fn().mockImplementation(() => {
const today = format(new Date(), 'yyyy-MM-dd');
const yesterday = format(getYesterday(), 'yyyy-MM-dd');
return {
get: () => {
return Promise.resolve({
BTCUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
type: 'Cryptocurrency'
},
ETHUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 3915.337,
marketState: MarketState.open,
name: 'Ethereum USD',
type: 'Cryptocurrency'
}
});
},
getHistorical: () => {
return Promise.resolve({
BTCUSD: {
[yesterday]: 56710.122,
[today]: 57973.008
},
ETHUSD: {
[yesterday]: 3641.984,
[today]: 3915.337
}
});
}
};
})
};
});
jest.mock('../services/exchange-rate-data.service', () => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
toCurrency: (value: number) => value
};
})
};
});
jest.mock('../services/data-provider.service');
jest.mock('../services/exchange-rate-data.service');
jest.mock('../services/rules.service');
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => {
let alphaVantageService: AlphaVantageService;
let configurationService: ConfigurationService;
let accountService: AccountService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolio: Portfolio;
let prismaService: PrismaService;
let rakutenRapidApiService: RakutenRapidApiService;
let rulesService: RulesService;
let yahooFinanceService: YahooFinanceService;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [],
providers: [
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
PrismaService,
RakutenRapidApiService,
RulesService,
YahooFinanceService
]
}).compile();
alphaVantageService = app.get<AlphaVantageService>(AlphaVantageService);
configurationService = app.get<ConfigurationService>(ConfigurationService);
dataProviderService = app.get<DataProviderService>(DataProviderService);
exchangeRateDataService = app.get<ExchangeRateDataService>(
ExchangeRateDataService
accountService = new AccountService(null, null, null);
dataProviderService = new DataProviderService(
null,
null,
null,
null,
null,
null
);
prismaService = app.get<PrismaService>(PrismaService);
rakutenRapidApiService = app.get<RakutenRapidApiService>(
RakutenRapidApiService
);
rulesService = app.get<RulesService>(RulesService);
yahooFinanceService = app.get<YahooFinanceService>(YahooFinanceService);
exchangeRateDataService = new ExchangeRateDataService(null);
rulesService = new RulesService();
await exchangeRateDataService.initialize();
portfolio = new Portfolio(
accountService,
dataProviderService,
exchangeRateDataService,
rulesService
);
portfolio.setUser({
accessToken: null,
alias: 'Test',
Account: [
{
accountType: AccountType.SECURITIES,
balance: 0,
createdAt: new Date(),
id: '',
currency: Currency.USD,
id: DEFAULT_ACCOUNT_ID,
isDefault: true,
name: 'Default Account',
platformId: null,
updatedAt: new Date(),
userId: USER_ID
}
],
alias: 'Test',
authChallenge: null,
createdAt: new Date(),
id: USER_ID,
provider: null,
role: Role.USER,
Settings: {
currency: Currency.CHF,
updatedAt: new Date(),
userId: ''
userId: USER_ID,
viewMode: ViewMode.DEFAULT
},
thirdPartyId: null,
updatedAt: new Date()
@ -86,12 +161,52 @@ describe('Portfolio', () => {
it('should return empty details', async () => {
const details = await portfolio.getDetails('1d');
expect(details).toEqual({});
expect(details).toMatchObject({
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
});
it('should return empty details', async () => {
const details = await portfolio.getDetails('max');
expect(details).toEqual({});
expect(details).toMatchObject({
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
});
it('should return zero performance for 1d', async () => {
@ -121,18 +236,21 @@ describe('Portfolio', () => {
it('should return ["BTC"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(),
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
platformId: null,
quantity: 1,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 49631.24,
updatedAt: null,
userId: null
userId: USER_ID
}
]);
@ -147,20 +265,8 @@ describe('Portfolio', () => {
const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({
BTCUSD: {
currency: Currency.USD,
exchange: 'Other',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),
isMarketOpen: true,
// marketPrice: 57973.008,
name: 'Bitcoin USD',
platforms: {
Other: {
accounts: {
[UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
@ -173,10 +279,24 @@ describe('Portfolio', () => {
)
}
},
allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
quantity: 1,
// shareCurrent: 0.9999999559148652,
shareInvestment: 1,
symbol: 'BTCUSD',
transactionCount: 1,
type: 'Cryptocurrency'
}
});
@ -211,7 +331,9 @@ describe('Portfolio', () => {
expect(portfolio.getPositions(getYesterday())).toMatchObject({});
expect(portfolio.getSymbols(getYesterday())).toEqual(['BTCUSD']);
expect(portfolio.getSymbols(getYesterday())).toEqual([]);
expect(portfolio.getSymbols(new Date())).toEqual(['BTCUSD']);
});
});
@ -219,18 +341,21 @@ describe('Portfolio', () => {
it('should return ["ETHUSD"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: null
userId: USER_ID
}
]);
@ -242,27 +367,16 @@ describe('Portfolio', () => {
)
);
const details = await portfolio.getDetails('1d');
/*const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({
ETHUSD: {
currency: Currency.USD,
exchange: 'Other',
// grossPerformance: 0,
// grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
accounts: {
[UNKNOWN_KEY]: {
current: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
// marketPrice: 57973.008,
name: 'Ethereum USD',
platforms: {
Other: {
/*current: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),*/
original: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
@ -270,13 +384,26 @@ describe('Portfolio', () => {
)
}
},
// allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
// grossPerformance: 0,
// grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
marketPrice: 3915.337,
name: 'Ethereum USD',
quantity: 0.2,
shareCurrent: 1,
shareInvestment: 1,
transactionCount: 1,
symbol: 'ETHUSD',
type: 'Cryptocurrency'
}
});
});*/
expect(portfolio.getFees()).toEqual(0);
@ -300,7 +427,7 @@ describe('Portfolio', () => {
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49,
// marketPrice: 0,
// marketPrice: 3915.337,
quantity: 0.2
}
});
@ -311,32 +438,38 @@ describe('Portfolio', () => {
it('should return ["ETHUSD"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: null
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
quantity: 0.3,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,
userId: null
userId: USER_ID
}
]);
@ -372,7 +505,7 @@ describe('Portfolio', () => {
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
// marketPrice: 0,
// marketPrice: 3641.984,
quantity: 0.5
}
});
@ -383,32 +516,38 @@ describe('Portfolio', () => {
it('should return ["BTCUSD", "ETHUSD"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.EUR,
dataSource: DataSource.YAHOO,
date: new Date(getUtc('2017-08-16')),
fee: 2.99,
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
platformId: null,
quantity: 0.05614682,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 3562.089535970158,
updatedAt: null,
userId: null
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 2.99,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: null
userId: USER_ID
}
]);
@ -468,51 +607,59 @@ describe('Portfolio', () => {
it('should work with buy and sell', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: null
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0,
date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
quantity: 0.1,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.SELL,
unitPrice: 1050,
updatedAt: null,
userId: null
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0,
date: new Date(getUtc('2018-01-31')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,
userId: null
userId: USER_ID
}
]);
// TODO: Fix
/*expect(portfolio.getCommittedFunds()).toEqual(
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
@ -528,7 +675,7 @@ describe('Portfolio', () => {
Currency.USD,
baseCurrency
)
);*/
);
expect(portfolio.getFees()).toEqual(
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
@ -540,12 +687,11 @@ describe('Portfolio', () => {
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z',
// TODO: Fix
/*investment: exchangeRateDataService.toCurrency(
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
Currency.USD,
baseCurrency
),*/
),
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
// marketPrice: 0,
quantity: 0.2 - 0.1 + 0.2
@ -555,8 +701,4 @@ describe('Portfolio', () => {
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
});
});
afterAll(async () => {
prismaService.$disconnect();
});
});

View File

@ -1,7 +1,20 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import {
PortfolioItem,
Position
} from 'apps/api/src/app/portfolio/interfaces/portfolio-item.interface';
PortfolioPerformance,
PortfolioPosition,
PortfolioReport,
Position,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Currency, Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import {
add,
format,
@ -18,30 +31,24 @@ import {
setMonth,
sub
} from 'date-fns';
import { getToday, getYesterday, resetHours } from 'libs/helper/src';
import { cloneDeep, isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { UserWithSettings } from '../app/interfaces/user-with-settings';
import { OrderWithPlatform } from '../app/order/interfaces/order-with-platform.type';
import { DateRange } from '../app/portfolio/interfaces/date-range.type';
import { PortfolioPerformance } from '../app/portfolio/interfaces/portfolio-performance.interface';
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioReport } from '../app/portfolio/interfaces/portfolio-report.interface';
import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { IOrder } from '../services/interfaces/interfaces';
import { IOrder, MarketState, Type } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order';
import { OrderType } from './order-type';
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment';
import { PlatformClusterRiskCurrentInvestment } from './rules/platform-cluster-risk/current-investment';
import { PlatformClusterRiskInitialInvestment } from './rules/platform-cluster-risk/initial-investment';
import { PlatformClusterRiskSinglePlatform } from './rules/platform-cluster-risk/single-platform';
export class Portfolio implements PortfolioInterface {
private orders: Order[] = [];
@ -49,6 +56,7 @@ export class Portfolio implements PortfolioInterface {
private user: UserWithSettings;
public constructor(
private accountService: AccountService,
private dataProviderService: DataProviderService,
private exchangeRateDataService: ExchangeRateDataService,
private rulesService: RulesService
@ -57,7 +65,7 @@ export class Portfolio implements PortfolioInterface {
public async addCurrentPortfolioItems() {
const currentData = await this.dataProviderService.get(this.getSymbols());
let currentDate = new Date();
const currentDate = new Date();
const year = getYear(currentDate);
const month = getMonth(currentDate);
@ -68,7 +76,7 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsYesterday] = this.get(yesterday);
let positions: { [symbol: string]: Position } = {};
const positions: { [symbol: string]: Position } = {};
this.getSymbols().forEach((symbol) => {
positions[symbol] = {
@ -79,8 +87,12 @@ export class Portfolio implements PortfolioInterface {
investmentInOriginalCurrency:
portfolioItemsYesterday?.positions[symbol]
?.investmentInOriginalCurrency,
marketPrice: currentData[symbol]?.marketPrice,
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity
marketPrice:
currentData[symbol]?.marketPrice ??
portfolioItemsYesterday.positions[symbol]?.marketPrice,
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity,
transactionCount:
portfolioItemsYesterday?.positions[symbol]?.transactionCount
};
});
@ -96,14 +108,45 @@ export class Portfolio implements PortfolioInterface {
);
// Set value after pushing today's portfolio items
this.portfolioItems[portfolioItemsLength - 1].value = this.getValue(
today
);
this.portfolioItems[portfolioItemsLength - 1].value =
this.getValue(today);
}
return this;
}
public async addFuturePortfolioItems() {
let investment = this.getInvestment(new Date());
this.getOrders()
.filter((order) => order.getIsDraft() === true)
.forEach((order) => {
investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
const portfolioItem = this.portfolioItems.find((item) => {
return item.date === order.getDate();
});
if (portfolioItem) {
portfolioItem.investment = investment;
} else {
this.portfolioItems.push({
investment,
date: order.getDate(),
grossPerformancePercent: 0,
positions: {},
value: 0
});
}
});
return this;
}
public createFromData({
orders,
portfolioItems,
@ -115,25 +158,27 @@ export class Portfolio implements PortfolioInterface {
}): Portfolio {
orders.forEach(
({
account,
currency,
fee,
date,
id,
platform,
quantity,
symbol,
symbolProfile,
type,
unitPrice
}) => {
this.orders.push(
new Order({
account,
currency,
fee,
date,
id,
platform,
quantity,
symbol,
symbolProfile,
type,
unitPrice
})
@ -158,53 +203,6 @@ export class Portfolio implements PortfolioInterface {
return this;
}
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
let currentDate = new Date();
const normalizedMinDate =
getDate(aMinDate) === 1
? aMinDate
: add(setDate(aMinDate, 1), { months: 1 });
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
currentDate = new Date(Date.UTC(year, month, day, 0));
switch (aDateRange) {
case '1d':
return sub(currentDate, {
days: 1
});
case 'ytd':
currentDate = setDate(currentDate, 1);
currentDate = setMonth(currentDate, 0);
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '1y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 1
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '5y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 5
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
default:
// Gets handled as all data
return undefined;
}
}
public get(aDate?: Date): PortfolioItem[] {
if (aDate) {
const filteredPortfolio = this.portfolioItems.find((item) => {
@ -214,6 +212,8 @@ export class Portfolio implements PortfolioInterface {
if (filteredPortfolio) {
return [cloneDeep(filteredPortfolio)];
}
return [];
}
return cloneDeep(this.portfolioItems);
@ -235,17 +235,23 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsNow] = await this.get(new Date());
const investment = this.getInvestment(new Date());
const cashDetails = await this.accountService.getCashDetails(
this.user.id,
this.user.Settings.currency
);
const investment = this.getInvestment(new Date()) + cashDetails.balance;
const portfolioItems = this.get(new Date());
const symbols = this.getSymbols(new Date());
const value = this.getValue();
const value = this.getValue() + cashDetails.balance;
const details: { [symbol: string]: PortfolioPosition } = {};
const data = await this.dataProviderService.get(symbols);
symbols.forEach((symbol) => {
const platforms: PortfolioPosition['platforms'] = {};
const accounts: PortfolioPosition['accounts'] = {};
let countriesOfSymbol: Country[];
let sectorsOfSymbol: Sector[];
const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => {
@ -270,25 +276,51 @@ export class Portfolio implements PortfolioInterface {
originalValueOfSymbol *= -1;
}
if (platforms[orderOfSymbol.getPlatform()?.name || 'Other']?.current) {
platforms[
orderOfSymbol.getPlatform()?.name || 'Other'
].current += currentValueOfSymbol;
platforms[
orderOfSymbol.getPlatform()?.name || 'Other'
].original += originalValueOfSymbol;
if (
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
) {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
platforms[orderOfSymbol.getPlatform()?.name || 'Other'] = {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
}
countriesOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
[]
).map((country) => {
const { code, weight } = country as Prisma.JsonObject;
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
});
sectorsOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.sectors as Prisma.JsonArray) ?? []
).map((sector) => {
const { name, weight } = sector as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
weight: weight as number
};
});
});
let now = portfolioItemsNow.positions[symbol].marketPrice;
// 1d
let before = portfolioItemsBefore.positions[symbol].marketPrice;
let before = portfolioItemsBefore?.positions[symbol].marketPrice;
if (aDateRange === 'ytd') {
before =
@ -305,7 +337,7 @@ export class Portfolio implements PortfolioInterface {
if (
!isBefore(
parseISO(portfolioItemsNow.positions[symbol].firstBuyDate),
parseISO(portfolioItemsBefore.date)
parseISO(portfolioItemsBefore?.date)
)
) {
// Trade was not before the date of portfolioItemsBefore, then override it with average price
@ -319,8 +351,17 @@ export class Portfolio implements PortfolioInterface {
details[symbol] = {
...data[symbol],
platforms,
accounts,
symbol,
allocationCurrent:
this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
) / value,
allocationInvestment:
portfolioItem.positions[symbol].investment / investment,
countries: countriesOfSymbol,
grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before),
2
@ -328,16 +369,22 @@ export class Portfolio implements PortfolioInterface {
grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity,
shareCurrent:
this.exchangeRateDataService.toCurrency(
sectors: sectorsOfSymbol,
transactionCount: portfolioItem.positions[symbol].transactionCount,
value: this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
) / value,
shareInvestment: portfolioItem.positions[symbol].investment / investment
)
};
});
details[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails,
investment,
value
});
return details;
}
@ -362,7 +409,11 @@ export class Portfolio implements PortfolioInterface {
}
public getMinDate() {
if (this.orders.length > 0) {
const orders = this.getOrders().filter(
(order) => order.getIsDraft() === false
);
if (orders.length > 0) {
return new Date(this.orders[0].getDate());
}
@ -437,6 +488,19 @@ export class Portfolio implements PortfolioInterface {
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
this,
[
new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
],
{ baseCurrency: this.user.Settings.currency }
),
currencyClusterRisk: await this.rulesService.evaluate(
this,
[
@ -455,19 +519,6 @@ export class Portfolio implements PortfolioInterface {
],
{ baseCurrency: this.user.Settings.currency }
),
platformClusterRisk: await this.rulesService.evaluate(
this,
[
new PlatformClusterRiskSinglePlatform(this.exchangeRateDataService),
new PlatformClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new PlatformClusterRiskCurrentInvestment(
this.exchangeRateDataService
)
],
{ baseCurrency: this.user.Settings.currency }
),
fees: await this.rulesService.evaluate(
this,
[new FeeRatioInitialInvestment(this.exchangeRateDataService)],
@ -489,7 +540,9 @@ export class Portfolio implements PortfolioInterface {
}
}
} else {
symbols = this.orders.map((order) => {
symbols = this.orders
.filter((order) => order.getIsDraft() === false)
.map((order) => {
return order.getSymbol();
});
}
@ -500,7 +553,9 @@ export class Portfolio implements PortfolioInterface {
public getTotalBuy() {
return this.orders
.filter((order) => order.getType() === 'BUY')
.filter(
(order) => order.getIsDraft() === false && order.getType() === 'BUY'
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getTotal(),
@ -513,7 +568,9 @@ export class Portfolio implements PortfolioInterface {
public getTotalSell() {
return this.orders
.filter((order) => order.getType() === 'SELL')
.filter(
(order) => order.getIsDraft() === false && order.getType() === 'SELL'
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getTotal(),
@ -524,14 +581,14 @@ export class Portfolio implements PortfolioInterface {
.reduce((previous, current) => previous + current, 0);
}
public getOrders() {
return this.orders;
public getOrders(aSymbol?: string) {
if (aSymbol) {
return this.orders.filter((order) => {
return order.getSymbol() === aSymbol;
});
}
private getOrdersByType(aFilter: string[]) {
return this.orders.filter((order) => {
return aFilter.includes(order.getType());
});
return this.orders;
}
public getValue(aDate = getToday()) {
@ -569,20 +626,21 @@ export class Portfolio implements PortfolioInterface {
return isFinite(value) ? value : null;
}
public async setOrders(aOrders: OrderWithPlatform[]) {
public async setOrders(aOrders: OrderWithAccount[]) {
this.orders = [];
// Map data
aOrders.forEach((order) => {
this.orders.push(
new Order({
currency: <any>order.currency,
account: order.Account,
currency: order.currency,
date: order.date.toISOString(),
fee: order.fee,
platform: order.Platform,
quantity: order.quantity,
symbol: order.symbol,
type: <any>order.type,
symbolProfile: order.SymbolProfile,
type: <OrderType>order.type,
unitPrice: order.unitPrice
})
);
@ -599,6 +657,46 @@ export class Portfolio implements PortfolioInterface {
return this;
}
private async getCashPosition({
cashDetails,
investment,
value
}: {
cashDetails: CashDetails;
investment: number;
value: number;
}) {
const accounts = {};
const cashValue = cashDetails.balance;
cashDetails.accounts.forEach((account) => {
accounts[account.name] = {
current: account.balance,
original: account.balance
};
});
return {
accounts,
allocationCurrent: cashValue / value,
allocationInvestment: cashValue / investment,
countries: [],
currency: Currency.CHF,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: cashValue,
marketPrice: 0,
marketState: MarketState.open,
name: Type.Cash,
quantity: 0,
sectors: [],
symbol: ghostfolioCashSymbol,
type: Type.Cash,
transactionCount: 0,
value: cashValue
};
}
/**
* TODO: Refactor
*/
@ -633,7 +731,8 @@ export class Portfolio implements PortfolioInterface {
marketPrice:
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
?.marketPrice || 0,
quantity: 0
quantity: 0,
transactionCount: 0
};
});
@ -674,16 +773,17 @@ export class Portfolio implements PortfolioInterface {
marketPrice:
historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')]
?.marketPrice || 0,
quantity: 0
quantity: 0,
transactionCount: 0
};
});
this.portfolioItems.push(
cloneDeep({
positions,
date: yesterday.toISOString(),
grossPerformancePercent: 0,
investment: 0,
positions: positions,
value: 0
})
);
@ -692,9 +792,54 @@ export class Portfolio implements PortfolioInterface {
this.updatePortfolioItems();
}
private updatePortfolioItems() {
// console.time('update-portfolio-items');
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
let currentDate = new Date();
const normalizedMinDate =
getDate(aMinDate) === 1
? aMinDate
: add(setDate(aMinDate, 1), { months: 1 });
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
currentDate = new Date(Date.UTC(year, month, day, 0));
switch (aDateRange) {
case '1d':
return sub(currentDate, {
days: 1
});
case 'ytd':
currentDate = setDate(currentDate, 1);
currentDate = setMonth(currentDate, 0);
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '1y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 1
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '5y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 5
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
default:
// Gets handled as all data
return undefined;
}
}
private updatePortfolioItems() {
let currentDate = new Date();
const year = getYear(currentDate);
@ -718,6 +863,7 @@ export class Portfolio implements PortfolioInterface {
}
this.orders.forEach((order) => {
if (order.getIsDraft() === false) {
let index = this.portfolioItems.findIndex((item) => {
const dateOfOrder = setDate(parseISO(order.getDate()), 1);
return isSameDay(parseISO(item.date), dateOfOrder);
@ -730,27 +876,25 @@ export class Portfolio implements PortfolioInterface {
for (let i = index; i < this.portfolioItems.length; i++) {
// Set currency
this.portfolioItems[i].positions[order.getSymbol()].currency =
order.getCurrency();
this.portfolioItems[i].positions[
order.getSymbol()
].currency = order.getCurrency();
].transactionCount += 1;
if (order.getType() === 'BUY') {
if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
) {
this.portfolioItems[i].positions[
order.getSymbol()
].firstBuyDate = resetHours(
parseISO(order.getDate())
).toISOString();
this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate =
resetHours(parseISO(order.getDate())).toISOString();
}
this.portfolioItems[i].positions[
order.getSymbol()
].quantity += order.getQuantity();
this.portfolioItems[i].positions[
order.getSymbol()
].investment += this.exchangeRateDataService.toCurrency(
this.portfolioItems[i].positions[order.getSymbol()].quantity +=
order.getQuantity();
this.portfolioItems[i].positions[order.getSymbol()].investment +=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
@ -759,29 +903,28 @@ export class Portfolio implements PortfolioInterface {
order.getSymbol()
].investmentInOriginalCurrency += order.getTotal();
this.portfolioItems[
i
].investment += this.exchangeRateDataService.toCurrency(
this.portfolioItems[i].investment +=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
} else if (order.getType() === 'SELL') {
this.portfolioItems[i].positions[
order.getSymbol()
].quantity -= order.getQuantity();
this.portfolioItems[i].positions[order.getSymbol()].quantity -=
order.getQuantity();
if (
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
) {
this.portfolioItems[i].positions[order.getSymbol()].investment = 0;
this.portfolioItems[i].positions[
order.getSymbol()
].investment = 0;
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency = 0;
} else {
this.portfolioItems[i].positions[
order.getSymbol()
].investment -= this.exchangeRateDataService.toCurrency(
this.portfolioItems[i].positions[order.getSymbol()].investment -=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
@ -791,9 +934,8 @@ export class Portfolio implements PortfolioInterface {
].investmentInOriginalCurrency -= order.getTotal();
}
this.portfolioItems[
i
].investment -= this.exchangeRateDataService.toCurrency(
this.portfolioItems[i].investment -=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
@ -813,8 +955,7 @@ export class Portfolio implements PortfolioInterface {
currentValue / this.portfolioItems[i].investment - 1 || 0;
this.portfolioItems[i].value = currentValue;
}
}
});
// console.timeEnd('update-portfolio-items');
}
}

View File

@ -1,7 +1,7 @@
import { groupBy } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { groupBy } from 'libs/helper/src';
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface';

View File

@ -1,9 +1,9 @@
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class PlatformClusterRiskCurrentInvestment extends Rule {
export class AccountClusterRiskCurrentInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, {
name: 'Current Investment'
@ -18,24 +18,22 @@ export class PlatformClusterRiskCurrentInvestment extends Rule {
}
) {
const ruleSettings =
aRuleSettingsMap[PlatformClusterRiskCurrentInvestment.name];
aRuleSettingsMap[AccountClusterRiskCurrentInvestment.name];
const platforms: {
const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number;
};
} = {};
Object.values(aPositions).forEach((position) => {
for (const [platform, { current }] of Object.entries(
position.platforms
)) {
if (platforms[platform]?.investment) {
platforms[platform].investment += current;
for (const [account, { current }] of Object.entries(position.accounts)) {
if (accounts[account]?.investment) {
accounts[account].investment += current;
} else {
platforms[platform] = {
accounts[account] = {
investment: current,
name: platform
name: account
};
}
}
@ -44,17 +42,17 @@ export class PlatformClusterRiskCurrentInvestment extends Rule {
let maxItem;
let totalInvestment = 0;
Object.values(platforms).forEach((platform) => {
Object.values(accounts).forEach((account) => {
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;
}
});

View File

@ -1,9 +1,9 @@
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
export class PlatformClusterRiskInitialInvestment extends Rule {
export class AccountClusterRiskInitialInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, {
name: 'Initial Investment'
@ -18,7 +18,7 @@ export class PlatformClusterRiskInitialInvestment extends Rule {
}
) {
const ruleSettings =
aRuleSettingsMap[PlatformClusterRiskInitialInvestment.name];
aRuleSettingsMap[AccountClusterRiskInitialInvestment.name];
const platforms: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
@ -27,15 +27,13 @@ export class PlatformClusterRiskInitialInvestment extends Rule {
} = {};
Object.values(aPositions).forEach((position) => {
for (const [platform, { original }] of Object.entries(
position.platforms
)) {
if (platforms[platform]?.investment) {
platforms[platform].investment += original;
for (const [account, { original }] of Object.entries(position.accounts)) {
if (platforms[account]?.investment) {
platforms[account].investment += original;
} else {
platforms[platform] = {
platforms[account] = {
investment: original,
name: platform
name: account
};
}
}

View File

@ -1,35 +1,35 @@
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
export class PlatformClusterRiskSinglePlatform extends Rule {
export class AccountClusterRiskSingleAccount extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, {
name: 'Single Platform'
name: 'Single Account'
});
}
public evaluate(positions: { [symbol: string]: PortfolioPosition }) {
const platforms: string[] = [];
const accounts: string[] = [];
Object.values(positions).forEach((position) => {
for (const [platform] of Object.entries(position.platforms)) {
if (!platforms.includes(platform)) {
platforms.push(platform);
for (const [account] of Object.entries(position.accounts)) {
if (!accounts.includes(account)) {
accounts.push(account);
}
}
});
if (platforms.length === 1) {
if (accounts.length === 1) {
return {
evaluation: `All your investment is managed by a single platform`,
evaluation: `All your investment is managed by a single account`,
value: false
};
}
return {
evaluation: `Your investment is managed by ${platforms.length} platforms`,
evaluation: `Your investment is managed by ${accounts.length} accounts`,
value: true
};
}

View File

@ -1,5 +1,5 @@
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';

View File

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

View File

@ -1,6 +1,14 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
import {
getUtc,
isGhostfolioScraperApiSymbol,
resetHours
} from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import {
differenceInHours,
endOfToday,
format,
getDate,
getMonth,
@ -8,11 +16,11 @@ import {
isBefore,
subDays
} from 'date-fns';
import { benchmarks, currencyPairs } from 'libs/helper/src';
import { getUtc, resetHours } from 'libs/helper/src';
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@Injectable()
@ -20,6 +28,7 @@ export class DataGatheringService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
private prisma: PrismaService
) {}
@ -109,15 +118,13 @@ export class DataGatheringService {
}
}
public async gatherSymbols(
aSymbolsWithStartDate: { date: Date; symbol: string }[]
) {
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false;
for (const { date, symbol } of aSymbolsWithStartDate) {
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[symbol],
[{ dataSource, symbol }],
date,
new Date()
);
@ -178,9 +185,25 @@ export class DataGatheringService {
}
}
private getBenchmarksToGather(startDate: Date) {
const benchmarksToGather = benchmarks.map((symbol) => {
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const scraperConfigurations =
await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => {
return {
dataSource: DataSource.GHOSTFOLIO,
date: startDate,
symbol: scraperConfiguration.symbol
};
});
}
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
@ -188,6 +211,7 @@ export class DataGatheringService {
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
benchmarksToGather.push({
dataSource: DataSource.RAKUTEN,
date: startDate,
symbol: 'GF.FEAR_AND_GREED_INDEX'
});
@ -196,54 +220,84 @@ export class DataGatheringService {
return benchmarksToGather;
}
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> {
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
let distinctOrders = await this.prisma.order.findMany({
const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { symbol: true }
select: { dataSource: true, symbol: true },
where: {
date: {
lt: endOfToday() // no draft
}
}
});
const distinctOrdersWithDate = distinctOrders.map((distinctOrder) => {
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
.filter((distinctOrder) => {
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
})
.map((distinctOrder) => {
return {
...distinctOrder,
date: startDate
};
});
const currencyPairsToGather = currencyPairs.map((symbol) => {
const currencyPairsToGather = currencyPairs.map(
({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
}
);
const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate
);
return [
...this.getBenchmarksToGather(startDate),
...customSymbolsToGather,
...currencyPairsToGather,
...distinctOrdersWithDate
];
}
private async getSymbolsMax() {
const startDate = new Date(getUtc('2000-01-01'));
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate = new Date(getUtc('2015-01-01'));
let distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ date: 'asc' }],
select: { date: true, symbol: true }
});
const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate
);
const currencyPairsToGather = currencyPairs.map((symbol) => {
const currencyPairsToGather = currencyPairs.map(
({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
}
);
const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ date: 'asc' }],
select: { dataSource: true, date: true, symbol: true },
where: {
date: {
lt: endOfToday() // no draft
}
}
});
return [
...this.getBenchmarksToGather(startDate),
...customSymbolsToGather,
...currencyPairsToGather,
...distinctOrders
];

View File

@ -1,25 +1,30 @@
import { isCrypto, isRakutenRapidApi } from '@ghostfolio/helper';
import {
isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { MarketData } from '@prisma/client';
import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns';
import { ConfigurationService } from './configuration.service';
import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from './interfaces/data-provider.interface';
import { Granularity } from './interfaces/granularity.type';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
IDataProviderResponse
} from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@Injectable()
export class DataProviderService implements DataProviderInterface {
export class DataProviderService {
public constructor(
private readonly alphaVantageService: AlphaVantageService,
private readonly configurationService: ConfigurationService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
private prisma: PrismaService,
private readonly rakutenRapidApiService: RakutenRapidApiService,
private readonly yahooFinanceService: YahooFinanceService
@ -33,12 +38,33 @@ export class DataProviderService implements DataProviderInterface {
if (aSymbols.length === 1) {
const symbol = aSymbols[0];
if (isRakutenRapidApi(symbol)) {
if (isGhostfolioScraperApiSymbol(symbol)) {
return this.ghostfolioScraperApiService.get(aSymbols);
} else if (isRakutenRapidApiSymbol(symbol)) {
return this.rakutenRapidApiService.get(aSymbols);
}
}
return this.yahooFinanceService.get(aSymbols);
const yahooFinanceSymbols = aSymbols.filter((symbol) => {
return !isGhostfolioScraperApiSymbol(symbol);
});
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
const ghostfolioScraperApiSymbols = aSymbols.filter((symbol) => {
return isGhostfolioScraperApiSymbol(symbol);
});
for (const symbol of ghostfolioScraperApiSymbols) {
if (symbol) {
const ghostfolioScraperApiResult = await this.ghostfolioScraperApiService.get(
[symbol]
);
response[symbol] = ghostfolioScraperApiResult[symbol];
}
}
return response;
}
public async getHistorical(
@ -53,12 +79,12 @@ export class DataProviderService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {};
let granularityQuery =
const granularityQuery =
aGranularity === 'month'
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
: '';
let rangeQuery =
const rangeQuery =
from && to
? `AND date >= '${format(from, 'yyyy-MM-dd')}' AND date <= '${format(
to,
@ -93,55 +119,57 @@ export class DataProviderService implements DataProviderInterface {
}
public async getHistoricalRaw(
aSymbols: string[],
aDataGatheringItems: IDataGatheringItem[],
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
const dataOfYahoo = await this.yahooFinanceService.getHistorical(
aSymbols,
undefined,
from,
to
const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {};
const promises: Promise<{
data: { [date: string]: IDataProviderHistoricalResponse };
symbol: string;
}>[] = [];
for (const { dataSource, symbol } of aDataGatheringItems) {
const dataProvider = this.getDataProvider(dataSource);
if (dataProvider.canHandle(symbol)) {
promises.push(
dataProvider
.getHistorical([symbol], undefined, from, to)
.then((data) => ({ data: data?.[symbol], symbol }))
);
if (aSymbols.length === 1) {
const symbol = aSymbols[0];
if (
isCrypto(symbol) &&
this.configurationService.get('ALPHA_VANTAGE_API_KEY')
) {
// Merge data from Yahoo with data from Alpha Vantage
const dataOfAlphaVantage = await this.alphaVantageService.getHistorical(
[symbol],
undefined,
from,
to
);
return {
[symbol]: {
...dataOfYahoo[symbol],
...dataOfAlphaVantage[symbol]
}
};
} else if (
isRakutenRapidApi(symbol) &&
this.configurationService.get('RAKUTEN_RAPID_API_KEY')
) {
const dataOfRakutenRapidApi = await this.rakutenRapidApiService.getHistorical(
[symbol],
undefined,
from,
to
);
return dataOfRakutenRapidApi;
}
}
return dataOfYahoo;
const allData = await Promise.all(promises);
for (const { data, symbol } of allData) {
result[symbol] = data;
}
return result;
}
public async search(aSymbol: string) {
return this.getDataProvider(
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
).search(aSymbol);
}
private getDataProvider(providerName: DataSource) {
switch (providerName) {
case DataSource.ALPHA_VANTAGE:
return this.alphaVantageService;
case DataSource.GHOSTFOLIO:
return this.ghostfolioScraperApiService;
case DataSource.RAKUTEN:
return this.rakutenRapidApiService;
case DataSource.YAHOO:
return this.yahooFinanceService;
default:
throw new Error('No data provider has been found.');
}
}
}

View File

@ -1,9 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns';
import { ConfigurationService } from '../../configuration.service';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
@ -22,6 +24,10 @@ export class AlphaVantageService implements DataProviderInterface {
});
}
public canHandle(symbol: string) {
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
@ -77,7 +83,17 @@ export class AlphaVantageService implements DataProviderInterface {
}
}
public search(aSymbol: string) {
return this.alphaVantage.data.search(aSymbol);
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aSymbol);
return {
items: result?.bestMatches?.map((bestMatch) => {
return {
dataSource: DataSource.ALPHA_VANTAGE,
name: bestMatch['2. name'],
symbol: bestMatch['1. symbol']
};
})
};
}
}

View File

@ -0,0 +1,148 @@
import {
getYesterday,
isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { format } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { PrismaService } from '../../prisma.service';
import { ScraperConfig } from './interfaces/scraper-config.interface';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
public constructor(private prisma: PrismaService) {}
public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol);
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const symbol = aSymbols[0];
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
const { marketPrice } = await this.prisma.marketData.findFirst({
orderBy: {
date: 'desc'
},
where: {
symbol
}
});
return {
[symbol]: {
marketPrice,
currency: scraperConfig?.currency,
dataSource: DataSource.GHOSTFOLIO,
marketState: MarketState.delayed,
name: scraperConfig?.name
}
};
} catch (error) {
console.error(error);
}
return {};
}
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
try {
const symbol = aSymbols[0];
const scraperConfiguration = await this.getScraperConfigurationBySymbol(
symbol
);
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
const value = this.extractNumberFromString(
$(scraperConfiguration?.selector).text()
);
return {
[symbol]: {
[format(getYesterday(), 'yyyy-MM-dd')]: {
marketPrice: value
}
}
};
} catch (error) {
console.error(error);
}
return {};
}
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
try {
const {
value: scraperConfigString
} = await this.prisma.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
return JSON.parse(scraperConfigString);
} catch {}
return [];
}
public async search(aSymbol: string) {
return { items: [] };
}
private extractNumberFromString(aString: string): number {
try {
const [numberString] = aString.match(
GhostfolioScraperApiService.NUMERIC_REGEXP
);
return parseFloat(numberString.trim());
} catch {
return undefined;
}
}
private async getScraperConfigurationBySymbol(aSymbol: string) {
const scraperConfigurations = await this.getScraperConfigurations();
return scraperConfigurations.find((scraperConfiguration) => {
return scraperConfiguration.symbol === aSymbol;
});
}
}

View File

@ -0,0 +1,9 @@
import { Currency } from '@prisma/client';
export interface ScraperConfig {
currency: Currency;
name: string;
selector: string;
symbol: string;
url: string;
}

View File

@ -1,14 +1,20 @@
import {
getToday,
getYesterday,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns';
import { getToday, getYesterday } from 'libs/helper/src';
import { ConfigurationService } from '../../configuration.service';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { PrismaService } from '../../prisma.service';
@ -22,6 +28,13 @@ export class RakutenRapidApiService implements DataProviderInterface {
private readonly configurationService: ConfigurationService
) {}
public canHandle(symbol: string) {
return (
isRakutenRapidApiSymbol(symbol) &&
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
);
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
@ -38,8 +51,9 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {
'GF.FEAR_AND_GREED_INDEX': {
currency: undefined,
isMarketOpen: true,
dataSource: DataSource.RAKUTEN,
marketPrice: fgi.now.value,
marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
}
};
@ -114,6 +128,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {};
}
public async search(aSymbol: string) {
return { items: [] };
}
public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService;
}
private async getFearAndGreedIndex(): Promise<{
now: { value: number; valueText: string };
previousClose: { value: number; valueText: string };
@ -144,8 +166,4 @@ export class RakutenRapidApiService implements DataProviderInterface {
return undefined;
}
}
public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService;
}
}

View File

@ -1,24 +0,0 @@
/*
import { Test } from '@nestjs/testing';
import { YahooFinanceService } from './yahoo-finance.service';
describe('AppService', () => {
let service: YahooFinanceService;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [],
providers: [YahooFinanceService]
}).compile();
service = app.get<YahooFinanceService>(YahooFinanceService);
});
describe('get', () => {
it('should return data for USDCHF', () => {
expect(service.get(['USDCHF'])).toEqual('{}');
});
});
});
*/

View File

@ -1,15 +1,18 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { format } from 'date-fns';
import { isCrypto, isCurrency, parseCurrency } from 'libs/helper/src';
import * as yahooFinance from 'yahoo-finance';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
Industry,
Sector,
MarketState,
Type
} from '../../interfaces/interfaces';
import {
@ -19,8 +22,14 @@ import {
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor() {}
public canHandle(symbol: string) {
return true;
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
@ -48,24 +57,17 @@ export class YahooFinanceService implements DataProviderInterface {
response[symbol] = {
currency: parseCurrency(value.price?.currency),
dataSource: DataSource.YAHOO,
exchange: this.parseExchange(value.price?.exchangeName),
isMarketOpen:
value.price?.marketState === 'REGULAR' || isCrypto(symbol),
marketState:
value.price?.marketState === 'REGULAR' || isCrypto(symbol)
? MarketState.open
: MarketState.closed,
marketPrice: value.price?.regularMarketPrice || 0,
name: value.price?.longName || value.price?.shortName || symbol,
type: this.parseType(this.getType(symbol, value))
};
const industry = this.parseIndustry(value.summaryProfile?.industry);
if (industry) {
response[symbol].industry = industry;
}
const sector = this.parseSector(value.summaryProfile?.sector);
if (sector) {
response[symbol].sector = sector;
}
const url = value.summaryProfile?.website;
if (url) {
response[symbol].url = url;
@ -130,6 +132,49 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
let items = [];
try {
const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
const result = await get();
items = result.quotes
.filter((quote) => {
return quote.isYahooFinance;
})
.filter(({ quoteType }) => {
return (
quoteType === 'CRYPTOCURRENCY' ||
quoteType === 'EQUITY' ||
quoteType === 'ETF'
);
})
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
return symbol.includes('USD');
}
return true;
})
.map(({ longname, shortname, symbol }) => {
return {
dataSource: DataSource.YAHOO,
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
});
} catch {}
return { items };
}
/**
* Converts a symbol to a Yahoo symbol
*
@ -165,61 +210,12 @@ export class YahooFinanceService implements DataProviderInterface {
private parseExchange(aString: string): string {
if (aString?.toLowerCase() === 'ccc') {
return 'Other';
return UNKNOWN_KEY;
}
return aString;
}
private parseIndustry(aString: string): Industry {
if (aString === undefined) {
return undefined;
}
if (aString?.toLowerCase() === 'auto manufacturers') {
return Industry.Automotive;
} else if (aString?.toLowerCase() === 'biotechnology') {
return Industry.Biotechnology;
} else if (
aString?.toLowerCase() === 'drug manufacturers—specialty & generic'
) {
return Industry.Pharmaceutical;
} else if (
aString?.toLowerCase() === 'internet content & information' ||
aString?.toLowerCase() === 'internet retail'
) {
return Industry.Internet;
} else if (aString?.toLowerCase() === 'packaged foods') {
return Industry.Food;
} else if (aString?.toLowerCase() === 'software—application') {
return Industry.Software;
}
return Industry.Other;
}
private parseSector(aString: string): Sector {
if (aString === undefined) {
return undefined;
}
if (
aString?.toLowerCase() === 'consumer cyclical' ||
aString?.toLowerCase() === 'consumer defensive'
) {
return Sector.Consumer;
} else if (aString?.toLowerCase() === 'healthcare') {
return Sector.Healthcare;
} else if (
aString?.toLowerCase() === 'communication services' ||
aString?.toLowerCase() === 'technology'
) {
return Sector.Technology;
}
return Sector.Other;
}
private parseType(aString: string): Type {
if (aString?.toLowerCase() === 'cryptocurrency') {
return Type.Cryptocurrency;
@ -229,11 +225,11 @@ export class YahooFinanceService implements DataProviderInterface {
return Type.Stock;
}
return Type.Other;
return Type.Unknown;
}
}
export const convertFromYahooSymbol = (aSymbol: string) => {
let symbol = aSymbol.replace('-', '');
const symbol = aSymbol.replace('-', '');
return symbol.replace('=X', '');
};

View File

@ -1,4 +1,4 @@
import { getYesterday } from '@ghostfolio/helper';
import { getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { format } from 'date-fns';
@ -58,8 +58,8 @@ export class ExchangeRateDataService {
if (!this.currencies[pair]) {
// Not found, calculate indirectly via USD
this.currencies[pair] =
resultExtended[`${currency1}${Currency.USD}`][date].marketPrice *
resultExtended[`${Currency.USD}${currency2}`][date].marketPrice;
resultExtended[`${currency1}${Currency.USD}`]?.[date]?.marketPrice *
resultExtended[`${Currency.USD}${currency2}`]?.[date]?.marketPrice;
// Calculate the opposite direction
this.currencies[`${currency2}${currency1}`] = 1 / this.currencies[pair];

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