Compare commits

...

453 Commits

Author SHA1 Message Date
152fd4fdf8 Release 1.226.0 (#1592) 2023-01-11 20:12:45 +01:00
6b022b8de8 Extract locales (#1591) 2023-01-11 20:10:57 +01:00
7ab699e5fe Feature/uncover french translation feature (#1590)
* Uncover Français

* Update changelog
2023-01-11 19:59:41 +01:00
a7e5a316be Bugfix/fix big.js exception in report endpoint (#1586)
* Fix exception with missing marketPrice

* Update changelog
2023-01-11 19:59:13 +01:00
3f2d3a2da9 Minor improvements (#1589) 2023-01-11 19:45:44 +01:00
0208bd0923 Feature/French translation (#1583)
* Finished the French translation
2023-01-11 19:38:46 +01:00
aeba6e1f03 Feature/configure thousand separator in world map chart (#1588)
* Set thousandSeparator

* Update changelog
2023-01-11 07:47:44 +01:00
1b899da9ff Minor tweaks to README.md (#1585)
* Minor tweaks to README.md

- Reduced reliance on HTML in the README.md file.
- Added alt text on images for improved accessibility
- Very minor formatting tweaks

Signed-off-by: Martin Vandenbussche <vandenbusschemartin@gmail.com>
2023-01-10 20:59:32 +01:00
90a7a84ac5 Feature/add global heat map to landing page (#1584)
* Add global heat map

* Update changelog
2023-01-10 20:52:05 +01:00
fc8e23a9c8 Feature/improve form of import dividends dialog (#1582)
* Disable while loading

* Update changelog
2023-01-10 20:43:48 +01:00
f3c8ec27cb Feature/improve deprecated sass imports (#1581)
* Improve deprecated Sass imports

* Update changelog
2023-01-10 20:06:42 +01:00
38474f54b0 Release 1.225.0 (#1580) 2023-01-07 18:26:38 +01:00
18d25fb6c2 Feature/extend faq page (#1577)
* Extend FAQ page

* Update changelog
2023-01-07 18:21:09 +01:00
a850e8ca22 Import dividend (#1560)
* Import dividend

* Update changelog
2023-01-07 18:20:02 +01:00
b5f565c054 Simplify data source transformation (#1578) 2023-01-07 17:06:42 +01:00
aa6d0a4533 Update dates (#1575) 2023-01-07 08:48:06 +01:00
25e9028a41 Release 1.224.0 (#1573) 2023-01-04 20:15:29 +01:00
925d38703e Feature/add group by year option on analysis page (#1568)
* Add group by year option
2023-01-04 20:13:13 +01:00
158bb00b8a Add missing dutch translations (#1572)
* Add missing dutch translations

* Update changelog
2023-01-03 21:12:01 +01:00
b17111e6f1 Feature/setup fr (#1571)
* Setup fr

* Update changelog
2023-01-02 21:19:19 +01:00
c4765e31cd Feature/setup pt (#1439)
* Setup pt

* Update changelog
2023-01-02 20:52:48 +01:00
d321d56dee Release 1.223.0 (#1566) 2023-01-01 18:53:10 +01:00
07dd22f7fe Feature/extend asset profile details dialog by currency and symbol (#1565)
* Extend asset profile details dialog

* Update changelog
2023-01-01 12:05:28 +01:00
eb4d088a80 Feature/optimize page title for mobile (#1564)
* Optimize page title for mobile

* Update changelog
2023-01-01 09:57:27 +01:00
0509f0101f Feature/add student discount (#1563)
* Add student discount

* Update changelog
2023-01-01 09:22:38 +01:00
8818e09be8 Feature/add prefix to coupon codes (#1562)
* Add prefix

* Update changelog
2022-12-31 17:06:15 +01:00
d97fe4da9c Release 1.222.0 (#1561) 2022-12-29 18:11:26 +01:00
b20fa55b79 Feature/add filters to analytics page (#1559)
* Add filters to analysis page

* Update changelog
2022-12-29 10:31:21 +01:00
dd7a6f1562 Bugfix/fix i18n for account type (#1554)
* Translate account type

* Update changelog
2022-12-29 10:20:23 +01:00
15357bd5b5 Feature/add asset profile details to activities import (#1552)
* Add asset profile details

* Update changelog
2022-12-29 10:19:30 +01:00
52c7adc266 Feature/upgrade internet identity dependencies to version 0.15.1 (#1549)
* Upgrade Internet Identity dependencies to version 0.15.1

* Update changelog
2022-12-28 20:08:30 +01:00
1ae8970045 Feature/add price to subscription (#1551)
* Add price

* Update changelog
2022-12-28 13:57:15 +01:00
7c4c047140 Remove .gitkeep (#1553) 2022-12-28 13:45:30 +01:00
527f7e4faf Feature/upgrade observable store to version 2.2.15 (#1550)
* Upgrade observable-store to version 2.2.15

* Update changelog
2022-12-28 13:43:28 +01:00
50160eb9dc Feature/upgrade countup.js to version 2.3.2 (#1548)
* Upgrade countup.js to version 2.3.2

* Update changelog
2022-12-28 13:22:19 +01:00
58dff8a1e0 Feature/upgrade prisma to version 4.8.0 (#1547)
* Upgrade prisma to version 4.8.0

* Update changelog
2022-12-28 10:40:34 +01:00
2cd41615b2 Feature/change execution time of asset profile data gathering (#1544)
* Change execution time

* Update changelog
2022-12-28 09:31:46 +01:00
66d5793528 Refactoring (#1545) 2022-12-27 10:05:26 +01:00
e8d65e1c85 Feature/upgrade bull to version 4.10.2 (#1542)
* Upgrade bull to version 4.10.2

* Update changelog
2022-12-27 09:12:44 +01:00
da827a08f5 Release 1.221.0 (#1541) 2022-12-26 17:09:51 +01:00
d545e4877c Feature/improve activities import by preview step (#1540)
* Improve activities import

* Update changelog
2022-12-26 17:07:51 +01:00
1918dee9c5 Format code 2022-12-26 16:50:50 +01:00
a08610b603 Feature/improve activities import (#1531) 2022-12-26 16:26:51 +01:00
c22733db56 Feature/resolve blog post titles (#1539)
* Resolve blog post titles

* Update changelog
2022-12-26 16:23:21 +01:00
ee4866eb7d Feature/add blog post the importance of tracking your personal finances (#1537)
* Add blog post: The importance of tracking your personal finances

* Update changelog
2022-12-26 12:23:43 +01:00
327b1fa0d7 Feature/refresh cryptocurrencies list 20221225 (#1536)
* Update cryptocurrencies.json

* Update changelog
2022-12-25 18:17:35 +01:00
b155666d21 Feature/improve label based on type in create or edit activity dialog (#1535)
* Improve label

* Update changelog
2022-12-25 12:41:48 +01:00
c5ee3237ed Refactoring (#1533) 2022-12-25 12:40:29 +01:00
16118d635c Bugfix/fix date conversion of two digit year (#1529)
* Fix date conversion of two digit year

* Update changelog
2022-12-25 12:38:40 +01:00
49ce4803ce Feature/add support to manage tags in create or edit activity dialog (#1532)
* Add support to manage tags

* Update changelog
2022-12-25 12:23:52 +01:00
0b65d05013 Feature/remove rakuten from data source type (#1534)
* Remove RAKUTEN

* Update changelog
2022-12-25 12:20:09 +01:00
8793284e75 Feature/add tags to admin control panel (#1530)
* Add tags

* Update changelog
2022-12-24 12:28:17 +01:00
1c5e4050a8 Release 1.220.0 (#1528) 2022-12-23 11:08:22 +01:00
4f187e1a9f Feature/increase fear and greed index to 365 days (#1519)
* Increase fear and greed index to 365 days

* Update changelog
2022-12-23 11:06:52 +01:00
b56111ae85 Feature/add dry run to import api endpoint (#1526)
* Add dry run to import API endpoint

* Update changelog
2022-12-23 10:51:49 +01:00
61dfc1f819 Feature/upgrade prettier to version 2.8.1 (#1523)
* Upgrade prettier to version 2.8.1

* Update changelog
2022-12-23 10:50:57 +01:00
6137f228a8 Upgrade @types/lodash to version 4.14.191 (#1527) 2022-12-23 10:50:38 +01:00
5293de14cd Bugfix/fix rounding of y axis ticks in benchmark comparator (#1521)
* Fix rounding

* Update changelog
2022-12-22 12:27:34 +01:00
7340a674b5 Feature/upgrade color to version 4.2.3 (#1524)
* Upgrade color to version 4.2.3

* Update changelog
2022-12-21 19:40:29 +01:00
42cb3e2c73 Harmonize style of symbol (#1520) 2022-12-20 13:29:57 +01:00
e8a4a53c9f Feature/support position detail dialog in top performers of analysis page (#1522)
* Add support for position detail dialog

* Update changelog
2022-12-19 09:36:34 +01:00
629f002074 Release 1.219.0 (#1518) 2022-12-17 21:22:26 +01:00
7c65cf6ddd Update locales (#1517) 2022-12-17 21:20:51 +01:00
c38ebec3be Feature/add name to activities table (#1516)
* Add name to symbol column

* Update changelog
2022-12-17 21:14:58 +01:00
2b8ab26e7e Feature/combine name and symbol column in holdings table (#1514)
* Combine name and symbol column

* Update changelog
2022-12-17 20:50:39 +01:00
60f52bb209 Handle user signup for OAuth and Internet Identity (#1515)
Co-Authored-By: gobdevel <99349192+gobdevel@users.noreply.github.com>
2022-12-17 16:18:00 +01:00
616d168a7c Feature/add signup permission (#1512)
* Added support to disable user sign up in the admin control panel

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-12-17 12:39:21 +01:00
b13e4425d3 Feature/extend glossary by deflation inflation and stagflation (#1511)
* Extend glossary by deflation, inflation and stagflation

* Update changelog
2022-12-16 17:38:19 +01:00
1424236c48 Add "Buy me a coffee" badge (#1509) 2022-12-15 17:26:30 +01:00
2a605f850d Refactor requests and responses (#1507) 2022-12-14 08:03:38 +01:00
88ffbfead0 Add purpose (#1508) 2022-12-13 12:56:10 +01:00
5f4a8d505b Release 1.218.0 (#1510) 2022-12-12 20:15:32 +01:00
e87b93f19c Feature/add logo endpoint (#1506)
* Add logo endpoint

* Update changelog
2022-12-12 20:13:45 +01:00
49dcade964 Feature/add date of first activity to holdings (#1505)
* Add date of first activity

* Update changelog
2022-12-11 10:19:35 +01:00
7cd65eed39 Feature/improve asset profile details dialog (#1504)
* Improve asset profile details dialog

* Update changelog
2022-12-11 09:37:07 +01:00
a51b210f79 Feature/upgrade chart.js to version 4.0.1 (#1503)
* Upgrade chart.js to version 4.0.1 (including plugins)

* Migrate border attributes

* Update changelog
2022-12-10 16:47:09 +01:00
285f2220f3 Release 1.217.0 (#1502) 2022-12-10 14:32:28 +01:00
d72123246d Feature/upgrade cheerio to version 1.0.0 rc.12 (#1501)
* Upgrade cheerio to version 1.0.0-rc.12

* Update changelog
2022-12-10 12:19:45 +01:00
3a78d6c3f1 Feature/add dividend timeline (#1498)
* Add dividend timeline

* Update changelog

Co-Authored-By: João Pereira <joao@jpereira.me>
2022-12-10 11:54:49 +01:00
d5e3ff5717 Add locale (#1500) 2022-12-09 15:06:15 +01:00
2efb331370 Feature/improve value redaction interceptor (#1495)
* Improve value redaction interceptor

* Update changelog
2022-12-07 17:48:46 +01:00
f521fe99c5 Feature/upgrade prisma to version 4.7.1 (#1493)
* Upgrade prisma to version 4.7.1

* Update changelog
2022-12-06 09:03:28 +01:00
42306530b8 New strings translated to Spanish (#1496)
* Update messages.es.xlf

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-12-04 20:02:02 +01:00
68c9d1b266 Bugfix/fix activities sorting in account detail dialog (#1494)
* Fix activities sorting

* Update changelog
2022-12-04 18:54:41 +01:00
1ce90a0c06 Release 1.216.0 (#1492) 2022-12-03 19:19:10 +01:00
50f6d154e5 Feature/extend sorting in tables (#1491)
* Extend sorting

* Update changelog
2022-12-03 19:17:30 +01:00
e4c44faee4 Fix sorting by performance field in positions table (#1489) 2022-12-03 18:25:53 +01:00
5209f82cca Feature/upgrade replace in file to version 6.3.5 (#1486)
* Upgrade replace-in-file to version 6.3.5

* Update changelog
2022-12-03 18:23:40 +01:00
292d345ce0 Feature/support manual currency for fee (#1490)
* Support manual currency for fee

* Update changelog
2022-12-03 18:22:19 +01:00
d58400788a Feature/upgrade big.js to version 6.2.1 (#1488)
* Upgrade big.js to version 6.2.1

* Update changelog
2022-12-02 17:56:11 +01:00
7ff61ae839 Feature/upgrade date fns to version 2.29.3 (#1487)
* Upgrade date-fns to version 2.29.3

* Update changelog
2022-12-01 17:14:45 +01:00
b5b7af7741 Feature/improve asset profile management (#1485)
* Improve asset profile management (Add note, fix filter)

* Update changelog
2022-11-30 20:01:17 +01:00
de3e0fad83 Remove link (#1484) 2022-11-29 23:19:07 +01:00
8c8273c4d4 Release 1.215.0 (#1483) 2022-11-27 10:34:17 +01:00
b406bcd17d Feature/update browserslist database 20221127 (#1482)
* Update browserslist database

* Update changelog
2022-11-27 10:32:22 +01:00
fb496431e8 Clean up (#1481) 2022-11-27 10:21:21 +01:00
441b251536 Setup form (#1474)
* Setup form to patch asset profile (for symbolMapping)
2022-11-27 10:19:34 +01:00
1dbb5db611 Feature/improve wording in single account rule (#1479)
* Improve wording

* Update changelog
2022-11-26 08:53:01 +01:00
8567efcd89 Feature/upgrade ionicons to version 6.0.4 (#1478)
* Upgrade ionicons to version 6.0.4

* Update changelog
2022-11-25 20:49:26 +01:00
1cda5dcc0a Feature/upgrade UUID to version 9.0.0 (#1476)
* Upgrade uuid to version 9.0.0

* Update changelog
2022-11-24 20:33:29 +01:00
3fb01c6dcf Added yarn cache to .gitignore (#1477)
* Added yarn cache (.yarn) to .gitignore
2022-11-24 11:46:26 +01:00
6a764fe893 Convert between ZAc and ZAR (#1471)
* Convert between ZAc and ZAR

* Update changelog
2022-11-22 20:18:38 +01:00
d2b75a244c Feature/improve language selector (#1466)
* Improve language selector

* Update changelog
2022-11-21 20:39:52 +01:00
3611684f17 Feature/extend asset profile details dialog (#1469)
* Extend asset profile details dialog

* Update changelog
2022-11-21 20:14:36 +01:00
4b74be50da Release 1.214.0 (#1465) 2022-11-19 12:15:22 +01:00
0d338bb083 Bugfix/fix dynamic decimal places for cryptocurrencies in position detail dialog (#1464)
* Fix dynamic decimal places

* Update changelog
2022-11-19 12:13:02 +01:00
b0d708fb82 Feature/change activities icons (#1463)
* Improve icons

* Update changelog
2022-11-19 11:28:26 +01:00
be14458437 Bugfix/fix division by zero error in cash positions calculation (#1462)
* Handle division by zero

* Update changelog
2022-11-19 10:19:01 +01:00
5978ddb80f Feature/improve support for manual data source (#1460)
* Improve support for MANUAL data source

* Update changelog
2022-11-19 10:06:05 +01:00
18638dd1b7 Bugfix/Fix matsort not working in position detail dialog (#1457)
* Fix matsort in position detail dialog

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-11-19 10:02:36 +01:00
81db3852e6 Feature/add sorting to accounts table (#1459)
* Add sorting

* Update changelog
2022-11-19 09:57:28 +01:00
af27781234 Feature/upgrade prisma to version 4.6.1 (#1456)
* Upgrade prisma to version 4.6.1

* Update changelog
2022-11-18 20:19:14 +01:00
608e7a774d Feature/improve activities tab icon (#1461)
* Improve icon

* Update changelog
2022-11-17 21:37:26 +01:00
ed15eb76fd Feature/upgrade yahoo finance2 to version 2.3.10 (#1458)
* Upgrade yahoo-finance2 to version 2.3.10

* Update changelog
2022-11-16 19:33:07 +01:00
39905e5046 Feature/upgrade yahoo finance2 to version 2.3.7 (#1455)
* Upgrade yahoo-finance2 to version 2.3.7

* Update changelog
2022-11-15 21:33:22 +01:00
7cd3f235df Release 1.213.0 (#1454) 2022-11-14 20:47:21 +01:00
3b4f8c69bb Feature/setup black friday 2022 deal (#1452)
* Setup Black Friday 2022 deal

* Update changelog
2022-11-14 20:45:41 +01:00
c9bdf46b2b Add PWA as feature (#1445) 2022-11-12 17:21:37 +01:00
4169de580b Feature/add indicator for excluded accounts (#1450)
* Add indicator for excluded accounts

* Update changelog
2022-11-12 16:46:17 +01:00
3317fe7c46 Fix missing comma in json body (API example) (#1446) 2022-11-12 08:59:55 +01:00
c8f6fdbaa3 Release 1.212.0 (#1444) 2022-11-11 20:02:20 +01:00
d95fc82f95 Feature/change view mode selector to slide toggle (#1443)
* Change view mode selector to slide toggle

* Update changelog
2022-11-11 20:01:08 +01:00
31c949f9d2 Feature/upgrade nx to version 15.0.13 (#1442)
* Upgrade Nx to version 15.0.13

* Update changelog
2022-11-11 19:44:32 +01:00
f68f40fcc6 Release 1.211.0 (#1441) 2022-11-11 16:53:36 +01:00
9623a363ed Feature/convert into pwa (#1436)
* Setup @angular/pwa
2022-11-11 16:50:23 +01:00
2d42549967 Feature/improvements in pricing page (#1440)
* Improve pricing page

* Update changelog
2022-11-11 16:42:51 +01:00
c934c5088b Add ghostfolio-cli to community projects (#1437) 2022-11-11 09:02:43 +01:00
678b3cc57e Add Buy me a coffee button (#1438) 2022-11-10 19:31:00 +01:00
cd5eb64a4c Removed margin-bottom tag from the body element (#1435)
- This only changes the behaviour in Firefox
- All browsers now show the UI in a single page view
2022-11-09 20:48:13 +01:00
fc1507de4f Clean up (#1423) 2022-11-09 10:08:36 +01:00
d147a66dcd Release 1.210.0 (#1434) 2022-11-08 17:35:43 +01:00
33fd1282e5 Bugfix/fix cash position in user currency (#1433)
* Initialize cash positions with user currency

* Update changelog
2022-11-08 17:32:50 +01:00
693ff9d3ea Setup pt (#1432) 2022-11-07 17:17:07 +01:00
21e87a0055 Clean up (#1431) 2022-11-07 17:03:34 +01:00
43426c9b01 Feature/restructure portfolio page (#1429)
* Restructure portfolio page

* Update changelog
2022-11-07 16:57:26 +01:00
3b4da72ea3 Feature/tighten validation rule of base currency (#1428)
* Tighten validation rule of BASE_CURRENCY

* Update changelog
2022-11-06 17:51:31 +01:00
8d8e55fd0b Release 1.209.0 (#1427) 2022-11-05 13:39:26 +01:00
ca18621ce8 Improve locales (#1426) 2022-11-05 12:49:17 +01:00
b8574d24b2 Feature/improve usability of import (#1425)
* Improve usability by adding expected file format

* Update changelog
2022-11-05 12:22:24 +01:00
6d12c27f9c Feature/rename transactions to activities page (#1424)
* Rename transactions to activities

* Update changelog
2022-11-05 10:42:40 +01:00
c2c5326049 Feature/add buy me a coffee badge to about page (#1422)
* Add button: Buy me a coffee

* Update changelog
2022-11-05 10:22:18 +01:00
2a1339b61e Feature/improve usage of premium indicator component (#1421)
* Improve usage of premium indicator component

* Update changelog
2022-11-05 09:12:41 +01:00
c8a2579624 Feature/remove intro image in dark mode (#1420)
* Remove intro image in dark mode

* Update changelog
2022-11-05 09:06:40 +01:00
832ae063df Release 1.208.0 (#1415) 2022-11-03 20:21:12 +01:00
b5e026934f Harmonize extension (before and after) (#1414) 2022-11-03 20:18:40 +01:00
901c997908 Add pagination to activities table (#1404)
* Add pagination to activities table
2022-11-03 20:16:30 +01:00
3b6e0b20e2 Add test case (#1399)
* Add test case

* Fix calculation for portfolio evolution chart

* Update changelog

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>
2022-11-03 20:06:16 +01:00
e449d51c3c Feature/restructure actions in admin control panel (#1410)
* Restructure actions

* Update changelog
2022-11-02 17:47:03 +01:00
f72d31bab3 Release 1.207.0 (#1409) 2022-10-31 20:27:52 +01:00
4c893c4dcc Bugfix/fix public page (#1408)
* Fix public page

* Update changelog
2022-10-31 20:25:42 +01:00
ffb11cd10e Add instructions for API Authorization (#1406)
* Add instructions for API Authorization
2022-10-31 20:12:42 +01:00
d424b7731e Feature/change background color of dark mode (#1396)
* Darken background color

* Update changelog
2022-10-30 18:48:28 +01:00
6043c87481 Simplify lead (#1401) 2022-10-30 17:02:01 +01:00
fca0a688b6 parse csv date in ISO format (#1303)
* Handle various date formats

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-10-28 11:26:19 +02:00
5c6cc4fed5 Handle division by zero (#1398) 2022-10-26 21:28:26 +02:00
64a7d38ff9 Add more translations (#1394) 2022-10-23 10:28:08 +02:00
68d0d39161 Feature/translate asset and asset sub classes (#1393)
* Translate asset and asset sub classes

* Update changelog
2022-10-22 07:47:05 +02:00
233a8a8a18 Bugfix/improve loading indicator of investment chart (#1392)
* Improve loading indicator

* Update changelog
2022-10-21 20:01:32 +02:00
190779ee35 Release 1.206.2 (#1391) 2022-10-20 23:40:42 +02:00
6ef8121561 Release 1.206.1 (#1390) 2022-10-20 21:49:12 +02:00
58bf57d1e6 Release 1.206.0 (#1389) 2022-10-20 20:58:33 +02:00
71c5412dd5 Add test case: sell partially with huge gain (#1380)
* Add test case: sell partially with huge gain
2022-10-20 20:56:58 +02:00
ae85398c3d Fix test description (#1379) 2022-10-20 20:48:40 +02:00
048900d01b Bugfix/improve performance calculation for sell activitities (#1388)
* Improve performance calculation for SELL activities

* Update changelog

Co-authored-by: gizmodus <11334553+gizmodus@users.noreply.github.com>
2022-10-20 20:47:57 +02:00
074b09b543 Add space (#1381) 2022-10-19 07:53:58 +02:00
f9e04022f4 Remove TWR calculation (#1377) 2022-10-18 21:06:28 +02:00
8fd1fbd44a Feature/upgrade nx to version 15 (#1375)
* Upgrade to Nx 15

* Update changelog
2022-10-18 20:05:58 +02:00
0fb33ae71c Feature/migrate angular.json to project.json (#1374)
* Migrate angular.json to project.json

* Update changelog
2022-10-17 20:41:13 +02:00
3a35d72ec2 Fix disabled placeholder color (#1365) 2022-10-17 17:25:50 +02:00
32fe3e195f Release 1.205.2 (#1369) 2022-10-16 20:49:28 +02:00
805f4b05be Release 1.205.1 (#1368) 2022-10-16 20:19:27 +02:00
5b51a6840a Release 1.205.0 (#1367) 2022-10-16 19:36:51 +02:00
36bd6164e6 Feature/improve wording on landing page (#1366)
* Improve wording

* Update changelog
2022-10-16 19:35:09 +02:00
eac52a215b Feature/refactor appearance to color scheme (#1364)
* Refactor appearance to colorScheme

* Update changelog
2022-10-16 14:54:26 +02:00
9ff8cd5471 Feature/improve portfolio evolution chart (#1362)
* Switch inputs

* Update changelog
2022-10-16 10:01:31 +02:00
33cc7e4e7e Feature/remove rakuten from data source (#1361)
* Remove Rakuten

* Update changelog
2022-10-16 09:42:18 +02:00
47f84dab06 Remove postfix (#1360) 2022-10-16 09:41:41 +02:00
384d18b2a6 Feature/persist user language on url change (#1359)
* Persist user language

* Update changelog
2022-10-16 08:45:52 +02:00
2363983bdc Release 1.204.1 (#1357) 2022-10-15 18:07:18 +02:00
4af76764be Release 1.204.0 (#1356) 2022-10-15 17:47:35 +02:00
a65424aafa Feature/add total amount chart to investment timeline (#1344)
* Add total amount chart

* Update changelog
2022-10-15 17:45:34 +02:00
f9cd629470 Update messages.es.xlf (#1355) 2022-10-15 17:44:20 +02:00
ccb8c86596 Feature/minor improvements in chart components (#1353)
* Move y axis to the right

* Update changelog
2022-10-15 11:27:55 +02:00
246de7aa86 Consider current month in FIRE calculation (#1349) 2022-10-15 10:45:11 +02:00
a323313c71 Bugfix/fix alignment of value component on allocation page (#1351)
* Fix alignment

* Update changelog
2022-10-15 10:32:08 +02:00
538c8947cd Feature/rename data source from rakuten to rapid api (#1350)
* Rename Rakuten to Rapid API

* Update changelog
2022-10-15 10:31:46 +02:00
1ec5fd12fe Feature/setup prettier plugin organize attributes (#1346)
* Setup prettier plugin: prettier-plugin-organize-attributes

* Update changelog
2022-10-15 10:30:12 +02:00
4376b8903e Bugifx/fix url in blog posts (#1347)
* Fix url

* Update changelog
2022-10-14 19:52:00 +02:00
a8e096f9ac Feature/minor improvements for appearance selector (#1345)
* Improve appearance selector

* Update changelog
2022-10-12 13:38:58 +02:00
8e577592f6 Fix issue with $localize in storybook (#1343) 2022-10-12 09:48:33 +02:00
c896bf9199 Add appearance option in settings (#1342)
* Add appearance option in settings
2022-10-11 21:34:52 +02:00
16145f18d9 Improve template (#1324)
* Improve template
2022-10-09 10:42:02 +02:00
5398da0dc8 Feature/simplify admin settings management (#1340)
* Simplify settings management

* Update changelog
2022-10-08 17:35:18 +02:00
2466f4ff5d Release 1.203.0 (#1338) 2022-10-08 14:22:43 +02:00
8f3a9bdfbb Feature/refactor animation configuration (#1337)
* Refactor animation configuration

* Update changelog
2022-10-08 14:21:17 +02:00
44dfd2bd48 Add animation to line chart (#1328) 2022-10-08 13:47:48 +02:00
3fc2228f1d Feature/switch to new performance calculation (#1336)
* Switch to new performance calculation

* Update changelog
2022-10-08 13:20:52 +02:00
b018819a1f Bugfix/fix todays performance and chart calculation (#1333)
* Fix today's performance and chart calculation

* Update changelog
2022-10-08 13:20:25 +02:00
ac9311d783 Bugfix/fix alignment in users table (#1335)
* Fix alignment

* Update changelog
2022-10-08 11:38:58 +02:00
e23ce0f35d Feature/improve gui of benchmark comparator (#1334)
* Improve GUI

* Update changelog
2022-10-08 11:07:42 +02:00
f4b52aa41c Add Italian localization for the 4% rule (#1329)
* Update messages.it.xlf
2022-10-08 11:05:53 +02:00
655b040d4d Add missing title (#1332) 2022-10-07 20:54:05 +02:00
0f637a5d0f Release 1.202.0 (#1331) 2022-10-07 20:49:57 +02:00
3f85c327f5 Bugfix/fix text truncation in value component (#1330)
* Fix text truncation

* Update changelog
2022-10-07 20:48:39 +02:00
c2df99072d Feature/refactor filters (#1299)
* Refactor filters

Co-Authored-By: Zakaria YAHI <9142557+ZakYahi@users.noreply.github.com>
2022-10-07 20:39:29 +02:00
e8afbcad9c Feature/localize 4 percentage rule (#1327)
* Setup translation for 4% rule

* Update changelog
2022-10-07 20:21:52 +02:00
e6d8de781b Feature/improve wording in twitter bot service (#1326)
* Improve wording

* Update changelog
2022-10-06 20:52:34 +02:00
6e1935899f Bugfix/fix cryptocurrency symbols with less than 3 characters (#1325)
* Fix cryptocurrency symbols with less than 3 characters

* Update changelog
2022-10-06 15:15:36 +02:00
169cb85b66 Improve Italian translation (#1318)
* Update messages.it.xlf
2022-10-05 07:53:03 +02:00
fe6658d0ac Update messages.es.xlf (#1319) 2022-10-04 17:43:25 +02:00
1f0381228e Feature/improve caching of benchmarks (#1320)
* Improve caching

* Update changelog
2022-10-04 17:39:51 +02:00
f4b63b5de5 Release 1.201.0 (#1313) 2022-10-01 18:39:15 +02:00
e45a0ad068 Spanish (#1312)
* Update messages.es.xlf
2022-10-01 18:36:41 +02:00
81c6cc021d Feature/add blog post hacktoberfest 2022 (#1310)
* Add blog post: Hacktoberfest 2022

* Update changelog
2022-10-01 18:35:55 +02:00
859b24aa5b Fix alignment (#1311) 2022-10-01 18:35:27 +02:00
2bc325f182 Update messages.es.xlf (#1305)
* Update messages.es.xlf

Co-authored-by: fdp10381 <63880387+fdp10381@users.noreply.github.com>
2022-10-01 16:45:44 +02:00
a6186c23e2 Feature/improve usage of value component (#1308)
* Improve usage of value component

* Update changelog
2022-10-01 13:53:43 +02:00
cf234003ec Release 1.200.0 (#1307) 2022-10-01 11:18:15 +02:00
8d3954304e Feature/add statistics section to landing page (#1306)
* Add pulls on Docker Hub to statistics

* Add statistics to landing page

* Update changelog
2022-10-01 11:16:43 +02:00
9562139fa6 Feature/upgrade prisma to version 4.4.0 (#1304)
* Upgrade prisma to version 4.4.0

* Update changelog
2022-10-01 09:42:07 +02:00
c857ea9a8f Feature/add as seen in section on landing page (#1302)
* Add as seen in section

* Update changelog
2022-09-29 21:59:51 +02:00
5c9fa71d95 Release 1.199.1 (#1301) 2022-09-27 20:44:32 +02:00
fefbfa31d1 Release 1.199.0 (#1300) 2022-09-27 20:28:46 +02:00
93a1fae51c Feature/support sectors of mutual funds (#1298)
* Support sectors

* Update changelog

Co-authored-by: Mitchell <5503199+m11tch@users.noreply.github.com>
2022-09-27 17:38:53 +02:00
3715edd9ba Extract locales (#1297) 2022-09-26 19:44:19 +02:00
e3916e1ba3 Feature/setup espanol (#1293)
* Setup Español

* Update changelog
2022-09-26 18:39:11 +02:00
76ceac4edc Add spanish translation (#1296)
Co-Authored-By: alfredonodo <41476198+alfredonodo@users.noreply.github.com>
Co-Authored-By: casitu <25199636+casitu@users.noreply.github.com>
2022-09-26 18:14:53 +02:00
333b63bfe2 Release 1.198.0 (#1294) 2022-09-25 21:46:19 +02:00
3006c21b12 Add dutch translation (#1291)
* Add dutch translation
2022-09-25 18:12:33 +02:00
f01a3f893d Exclude accounts (#1289)
* Exclude accounts

* Update changelog
2022-09-25 18:02:46 +02:00
72974e888f Clean up spaces (#1288) 2022-09-25 15:14:51 +02:00
0cee7a0b35 Release 1.197.0 (#1287) 2022-09-24 13:16:47 +02:00
f3d337b044 Feature/add value of active filters on allocations page (#1286)
* Add value

* Update changelog
2022-09-24 13:15:16 +02:00
7667af059c Feature/combine performance and chart calculation (#1285)
* Combine performance and chart calculation endpoints

* Update changelog
2022-09-24 13:12:40 +02:00
1095b47f45 Feature/add multi language support to feature overview (#1284)
* Add multi-language support

* Update changelog
2022-09-24 12:29:36 +02:00
dacd7271eb Feature/improve density of various selectors (#1283)
* Improve density

* Update changelog
2022-09-24 09:58:09 +02:00
e093041184 Release 1.196.0 (#1281) 2022-09-22 21:05:05 +02:00
8f2caa508a Feature/extend landing page (#1279)
* Extend landing page

* Update changelog
2022-09-22 20:52:46 +02:00
862f670ccf Feature/setup italiano (#1276)
* Setup italiano

* Update changelog
2022-09-22 20:52:03 +02:00
54bf4c7a43 Update messages.it.xlf (#1280) 2022-09-22 20:51:31 +02:00
c0ace51ee9 Release 1.195.0 (#1277) 2022-09-20 20:23:41 +02:00
b1b5689242 Feature/improve performance of chart calculation (#1271)
* Improve performance chart calculation

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>

* Update changelog

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>

* Improve chart tooltip of benchmark comparator

* Update changelog

Co-authored-by: gizmodus <11334553+gizmodus@users.noreply.github.com>
2022-09-20 20:22:01 +02:00
b68cdaf8ea Add issue template (#1275) 2022-09-19 21:22:54 +02:00
b387a80a0d Add bullet (#1270) 2022-09-17 21:29:37 +02:00
6e4660295a Release 1.194.0 (#1269) 2022-09-17 21:26:08 +02:00
d4c3a9d1e8 Feature/add percentage visualization of the current filter (#1268)
* Add percentage visualization of the active filter

* Update changelog
2022-09-17 21:24:09 +02:00
263f6b32f2 Bugfix/fix performance chart calculation (#1267)
* Respect end date in performance chart calculation

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>

* Update changelog

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>
2022-09-17 08:33:04 +02:00
637f31ae3b Add instruction for NODE_ENV: production (#1266) 2022-09-17 08:31:56 +02:00
547e27c7a1 Feature/set node env in docker compose files (#1261)
* Set NODE_ENV to production

* Update changelog
2022-09-15 17:20:03 +02:00
f10dc176f2 Feature/clean up german localization (#1260)
* Clean up German localization

* Set up Italian

* Update changelog
2022-09-15 16:58:00 +02:00
0a966e46cd Improve translation (#1258) 2022-09-15 10:37:07 +02:00
4f281d25e1 Release 1.193.0 (#1257) 2022-09-14 20:11:47 +02:00
aaba8c35c2 Feature/extend pricing page with referral section (#1256)
* Add referral section

* Update changelog
2022-09-14 20:10:35 +02:00
7d27cb3398 Bugfix/use base currency in exchange rate service instead of usd (#1255)
* Change from USD to base currency

* Update changelog
2022-09-14 19:56:18 +02:00
91678028b5 Bugfix/fix missing assets during local development (#1253)
* Extend setup for development (missing assets)

* Update changelog
2022-09-14 19:26:59 +02:00
5e3cac8ac9 Feature/sort benchmarks by name (#1250)
* Sort benchmarks by name

* Update changelog
2022-09-12 13:34:06 +02:00
33f20b6b48 Release 1.192.0 (#1249) 2022-09-11 09:21:48 +02:00
e4fd255dd7 Bugfix/improve loading indicator of benchmark comparator (#1247)
* Improve loading indicator

* Update changelog
2022-09-11 09:20:15 +02:00
e320aa91f7 Feature/simplify benchmark configuration (#1248)
* Simplify benchmark configuration

* Update changelog
2022-09-11 09:19:50 +02:00
0fcfa6c1bd Bugfix/improve error handling in benchmark calculation (#1246)
* Improve error handling

* Update changelog
2022-09-10 18:58:31 +02:00
42d32ed652 Feature/upgrade yahoo finance2 to version 2.3.6 (#1245)
* Upgrade yahoo-finance2 to version 2.3.6

* Update changelog
2022-09-10 18:57:41 +02:00
21b4b0ef24 Release 1.191.0 (#1244) 2022-09-10 16:13:22 +02:00
4f8fe83a16 Feature/clean up user database schema (#1242)
* Clean up user database schema

* Update changelog
2022-09-10 16:11:49 +02:00
980ad1028c Feature/allow date range change for demo user (#1243)
* Allow date range change

* Update changelog
2022-09-10 16:10:57 +02:00
0d5bc3f51b Release 1.190.0 (#1241) 2022-09-10 13:36:39 +02:00
aece76d98f Feature/add date range component to benchmark comparator (#1240)
* Add date range component

* Update changelog
2022-09-10 13:33:37 +02:00
fc4bb71184 Feature/migrate date range setting to user settings (#1239)
* Migrate date range to user settings

* Refactor currency and view mode in the user user settings

* Update changelog
2022-09-10 11:38:06 +02:00
20bc7ef99c Feature/improve benchmark comparator on mobile (#1238)
* Improve layout for mobile

* Update changelog
2022-09-09 19:44:36 +02:00
7a733ae49b Release 1.189.0 (#1237) 2022-09-08 21:06:37 +02:00
376ce88492 Bugfix/fix benchmark chart (#1236)
* Fix benchmark chart

* Distinguish between currency and unit in tooltip

* Update changelog
2022-09-08 21:05:19 +02:00
c4d83aabe7 Setup tests (#1234) 2022-09-08 17:32:55 +02:00
d4e2cec77e Release 1.188.0 (#1233) 2022-09-06 20:40:59 +02:00
75db7bf79a Bugfix/fix asset profile details dialog (#1232)
* Fix dialog for assets without (first) activity

* Update changelog
2022-09-06 20:39:45 +02:00
3ad99c9991 Add data management (#1230)
* Add data management for benchmarks
2022-09-06 20:39:27 +02:00
00e402d286 Add translations 2022-09-04 09:48:18 +02:00
4ac0484025 Update changelog 2022-09-04 09:45:29 +02:00
75d61bff6d Setup benchmark comparator 2022-09-04 09:45:22 +02:00
0de28d733e Release 1.187.0 (#1227) 2022-09-03 21:42:23 +02:00
3b2f13850c Feature/improve chart calculation (#1226)
* Improve chart calculation

* Update changelog
2022-09-03 21:41:06 +02:00
0cc42ffd7c Add end date parameter (#1224)
* Add end date parameter
2022-09-03 21:28:57 +02:00
3ccb812ac3 Release 1.186.2 (#1223) 2022-09-03 11:37:14 +02:00
0a8549db3e Release 1.186.1 (#1222) 2022-09-03 11:19:31 +02:00
c95e90ff31 Release 1.186.0 (#1221) 2022-09-03 10:28:20 +02:00
b59af0d864 Feature/upgrade nx to version 14.6.4 (#1220)
* Upgrade nx and angular

* Update changelog
2022-09-03 10:26:56 +02:00
408bdbd187 Bugfix/fix GitHub contributors count (#1219)
* Fix GitHub contributors count

* Update changelog
2022-09-03 10:06:16 +02:00
a3bfa46fb0 Feature/remove alias from user (#1218)
* Remove alias

* Update changelog
2022-09-03 09:47:18 +02:00
8cb1b3f925 Feature/decrease rate limiter duration (#1217)
* Decrease rate limiter duration

* Update changelog
2022-09-03 09:09:57 +02:00
15c650f951 Bugfix/fix blog post link (#1216)
* Fix link

* Update sitemap.xml
2022-09-03 09:09:37 +02:00
c198bd78da Add architectures (#1205) 2022-09-03 08:32:01 +02:00
35963580bc Bugfix/improve error handling in portfolio calculations (#1215)
* Improve error handling

* Update changelog
2022-09-03 08:31:47 +02:00
cf2c5bad02 Bugfix/change environment variables redis host and port to mandatory (#1211)
* Change REDIS_HOST and REDIS_PORT to mandatory

* Update changelog
2022-09-01 15:35:37 +02:00
f332aea9b4 Release 1.185.0 (#1207) 2022-08-30 20:53:26 +02:00
7a9fd18407 Feature/improve markets overview (#1206)
* Improve markets overview

* Update changelog
2022-08-30 20:52:13 +02:00
ca08d3154a Bugfix/disable language selector for demo user (#1204)
* Disable language selector for demo user

* Update changelog
2022-08-28 21:11:27 +02:00
01d4ae8757 Feature/move build pipeline from travis to GitHub actions (#1203)
* Remove travis configurations

* Update changelog
2022-08-28 21:10:25 +02:00
43ce2786c1 Fetch all history for all tags and branches (#1202) 2022-08-28 11:01:54 +02:00
de2092c4d2 Release 1.184.2 (#1201) 2022-08-28 10:09:59 +02:00
435a180e54 Release 1.184.1 (#1200) 2022-08-28 09:44:09 +02:00
0ad30ffabe Add version to docker tag (#1199) 2022-08-28 09:43:06 +02:00
0cc5e558f1 Release 1.184.0 (#1198) 2022-08-28 09:12:03 +02:00
63b183cc6f Feature/finalize GitHub action to create arm64 docker image (#1197)
* Change order and refactor secrets

* Update changelog
2022-08-28 09:10:18 +02:00
10bae24c5c Build arm64 docker image and use github actions (#1134)
* Setup build pipeline for arm64 docker images using GitHub Actions
2022-08-27 14:37:22 +02:00
0e29278e96 Feature/add alias to access (#1193)
* Add alias to access

* Update changelog
2022-08-27 11:29:09 +02:00
2db46e5bbf Feature/support localization in date fns (#1195)
* Add locale to date-fns (formatDistanceToNow)

* Update changelog
2022-08-27 10:54:59 +02:00
e757e90e5a Improve translation (#1196) 2022-08-27 10:42:54 +02:00
184ddc6209 Bugfix/fix missing assets in local development (#1194)
* Fix missing assets in local development

* Update changelog
2022-08-27 10:29:50 +02:00
e3662a143c Improve localization (#1191)
* Improve localization

* Update changelog
2022-08-26 20:10:59 +02:00
25afd7e07b Remove database:baseline (#1192) 2022-08-26 18:08:05 +02:00
7fceaa1350 Add language to urls (#1187) 2022-08-26 17:52:42 +02:00
7c8530483c Release 1.183.0 (#1189) 2022-08-24 20:55:38 +02:00
539d3ff754 Feature/add asset sub class filter (#1188)
* Add asset sub class filter

* Update changelog
2022-08-24 20:53:50 +02:00
9d28b63da6 Release 1.182.0 (#1186) 2022-08-23 21:40:57 +02:00
24abbd85e6 Feature/move asset profile details to dialog (#1185)
* Introduce asset profile dialog

* Update changelog
2022-08-23 21:39:04 +02:00
b6f395fd3b Feature/improve i18n (#1183)
* Improve i18n

* Update changelog
2022-08-22 19:57:48 +02:00
04d894cf88 Release 1.181.2 (#1182) 2022-08-21 21:42:05 +02:00
b4d2c4109e Release 1.181.1 (#1181) 2022-08-21 20:58:51 +02:00
823093f4d7 Release 1.181.0 (#1180) 2022-08-21 18:08:54 +02:00
56bf422407 Consider language from user settings (#1179) 2022-08-21 18:06:31 +02:00
df0e9ad03b Bugfix/fix division by zero in benchmarks calculation (#1177)
* Fix division by zero error

* Update changelog
2022-08-21 17:03:03 +02:00
0e3702c2be Feature/improve german translation (#1178)
* Simplify and translate locales

* Add support for translated labels

* Update changelog
2022-08-21 17:02:43 +02:00
11136ae4f8 Eliminate duplicate locales (#1176) 2022-08-20 14:01:15 +02:00
2e6a7d5a91 Extract locales (#1175) 2022-08-20 11:00:53 +02:00
83845c256a Feature/add language selector (#1174)
* Add language selector

* Add translations (german)

* Update changelog
2022-08-20 10:55:27 +02:00
34c9703716 Remove locale (#1173) 2022-08-20 10:25:53 +02:00
48903238c5 Feature/improve documentation of database migration (#1172)
* Improve documentation

* Update changelog
2022-08-20 10:22:02 +02:00
57a14bd945 automate database setup and upgrade (#1163)
* Automate database setup and schema upgrade
2022-08-20 08:54:50 +02:00
4fd0622114 Fix build:dev script (#1171) 2022-08-19 20:39:21 +02:00
52f0fb5ab8 Release 1.180.1 (#1170) 2022-08-19 18:24:41 +02:00
20195b2b1a Release 1.180.0 (#1169) 2022-08-18 21:15:17 +02:00
7fa4e6ebd2 Feature/resolve feature graphic of blog post (#1168)
* Resolve feature graphic of blog post

* Update changelog
2022-08-18 21:13:39 +02:00
d8531ddfcb Bugfix/fix links to blog posts (#1167)
* Fix links

* Update changelog
2022-08-18 21:11:10 +02:00
70d670b711 Bugfix/fix license (#1160)
* Fix license

* Update changelog
2022-08-17 23:23:39 +02:00
27b0663a80 Add translations (#1159) 2022-08-16 22:16:15 +02:00
874dfb0235 Improve links (#1158) 2022-08-16 21:52:37 +02:00
072db0d558 Add translations (#1157) 2022-08-16 21:40:51 +02:00
12e692429a Regenerate xlf (#1156) 2022-08-16 21:30:12 +02:00
e22b8b78b8 Feature/tag route titles with template literal strings (#1155)
* Tagged template literal strings

* Update changelog
2022-08-16 21:03:05 +02:00
dc5052f7dc Feature/set up language localization for german (#1153)
* Set up language localization for German

* Update changelog
2022-08-16 20:58:08 +02:00
335553e891 Feature/tag template literal strings (#1152)
* Tagged template literal strings

* Update changelog
2022-08-16 20:53:14 +02:00
d480ad1023 Extract locales (#1151) 2022-08-15 19:56:42 +02:00
7320751056 Feature/set up ng extract i18n merge (#1149)
* Set up ng-extract-i18n-merge

* Update changelog
2022-08-15 19:52:43 +02:00
108c0c13c4 Release 1.179.5 (#1150) 2022-08-15 18:17:57 +02:00
053a5cc5b5 Release 1.179.4 (#1148) 2022-08-14 10:10:54 +02:00
c456a8bcfe Release/1.179.3 (#1147)
* Clean up

* Release 1.179.3
2022-08-13 20:33:43 +02:00
6fcecb5bc6 Release 1.179.2 (#1145) 2022-08-13 13:39:37 +02:00
e4e0a7d9f0 Release 1.179.1 (#1144) 2022-08-13 12:16:39 +02:00
c7173761a3 Release 1.179.0 (#1143) 2022-08-13 10:44:38 +02:00
185e130d9f Feature/add blog post 500 stars on GitHub (#1138)
* Add blog post

* Update changelog
2022-08-13 10:42:56 +02:00
81245635af Feature/setup i18n (#1139)
* Setup i18n

* Update changelog
2022-08-13 10:29:36 +02:00
55182ac1af Feature/reduce maximum width of performance chart (#1137)
* Reduce maximum width

* Update changelog
2022-08-10 17:26:34 +02:00
0b446a30ae Release 1.178.0 (#1136) 2022-08-09 21:28:02 +02:00
c5e6602102 Improve filter by asset class (#1135) 2022-08-09 21:25:07 +02:00
573038f407 Feature/add default values for countries and sectors (#1133)
* Add default values

* Add database validation script

* Update changelog
2022-08-09 19:31:13 +02:00
dbc38e705e Feature/add url to symbol profile overrides (#1132)
* Add url to symbol profile overrides

* Improve filter by asset class

* Update changelog
2022-08-09 19:29:26 +02:00
f127e7c61a Feature/improve styling of benchmarks (#1131)
* Harmonize benchmark table styling

* Update changelog
2022-08-09 19:28:13 +02:00
4ccabde251 Feature/simplify exchange rate service initialization (#1128)
* Simplify initialization

* Update changelog
2022-08-08 19:25:38 +02:00
86ae88f90f Release 1.177.0 (#1127) 2022-08-04 13:38:25 +02:00
69bc1d67e1 Bugfix/fix database connection error handling (#1125)
* Fix database connection error handling

* Update changelog
2022-08-04 13:36:32 +02:00
03942aecda Feature/upgrade to prisma 4.1.1 (#1126)
* Upgrade prisma to 4.1.1

* Update changelog
2022-08-04 13:35:58 +02:00
7ec9170c0d baseline prisma bdd at first setup (#1124)
* Baseline database (migrations) in setup

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2022-08-04 13:34:32 +02:00
51431a7fb2 Feature/add ghostfolio as default to data sources (#1122)
* Add GHOSTFOLIO

* Update changelog
2022-08-03 21:36:12 +02:00
4adda6783d Feature/add agplv3 logo to landing page (#1121)
* Add AGPLv3 logo

* Update changelog
2022-08-02 22:14:27 +02:00
d5cd4c0dea Feature/upgrade nx to version 14.5.1 (#1120)
* Upgrade Nx including angular and nestjs

* Update changelog
2022-08-01 19:56:44 +02:00
34be10d755 Release 1.176.2 (#1119) 2022-07-31 08:16:30 +02:00
51f586e160 Feature/upgrade node from 14 to 16 (#1118)
* Upgrade to Node.js 16

* Update changelog
2022-07-31 08:14:52 +02:00
ff64a00196 Release 1.176.1 (#1117) 2022-07-31 00:19:40 +02:00
148f6f8762 Feature/refactor env variables access (#1116)
* Refactor env variables access

* Update changelog
2022-07-31 00:18:09 +02:00
bf2c4d1e9e Release 1.176.0 (#1115) 2022-07-30 21:27:35 +02:00
eee1f1c722 Feature/add page titles (#1114)
* Add page titles

* Update changelog
2022-07-30 21:26:08 +02:00
9f2a49a1c7 Feature/introduce max number of symbols per data provider request (#1111)
* Introduce maximum number of symbols per request

* Set log level settings

* Update changelog
2022-07-30 15:48:45 +02:00
44058b2d7a Add descriptions (#1113) 2022-07-30 15:46:58 +02:00
23634f3404 Add environment variables (#1112) 2022-07-30 15:21:14 +02:00
f93dab6086 Add FAQ (#1108) 2022-07-29 19:25:41 +02:00
207859cc22 Release 1.175.0 (#1107) 2022-07-29 18:34:05 +02:00
77181aaaff Feature/setup faq page (#1106)
* Set up FAQ page

* Update changelog
2022-07-29 18:32:26 +02:00
412039badf Bugfix/show symbols of activities in account detail dialog (#1105)
* Show symbols

* Update changelog
2022-07-29 17:04:30 +02:00
7619442895 Feature/add savings rate to investment timeline (#1104)
* Add line for savings rate

* Update changelog
2022-07-29 17:03:23 +02:00
61ecd66e0f Release 1.174.0 (#1098) 2022-07-27 21:09:03 +02:00
81217b35ef Feature/add comment to activity (#1097)
* Add comment to activity

* Update changelog
2022-07-27 21:07:27 +02:00
678f1f0051 Release 1.173.0 (#1095) 2022-07-23 20:38:53 +02:00
71c7e37b5a Bugfix/fix currency inconsistency with usx (#1094)
* Support USX

* Update changelog
2022-07-23 20:37:26 +02:00
80459371f3 Release 1.172.0 (#1093) 2022-07-23 12:09:38 +02:00
35f1f348a8 Feature/add blog post ghostfolio meets internet identity (#1092)
* Add blog post: Ghostfolio meets Internet Identity

* Update changelog
2022-07-23 12:06:49 +02:00
0bb0b12991 Release 1.171.0 (#1091) 2022-07-22 19:57:04 +02:00
d887de50d2 Feature/setup internet identity (#1080)
* Setup Internet Identity as social login provider

* Update changelog
2022-07-22 19:55:33 +02:00
2571e5b8c0 Feature/improve empty states (#1090)
* Improve empty states

* Update changelog
2022-07-22 19:33:06 +02:00
e444d717e5 Add tests for investments by month (#1089)
* Fix investments by month

* Add tests for investments and investments by month

* Update changelog
2022-07-22 19:00:36 +02:00
1866e26c1d Fix distorted tooltip (#1088)
* Fix distorted tooltip

* Update changelog
2022-07-21 20:36:16 +02:00
9923074e04 Add script to open prisma studio with prod env variables (#1087) 2022-07-20 09:16:02 +02:00
c367e61b85 Release 1.170.0 (#1086) 2022-07-19 20:30:59 +02:00
364f1ad9b9 Feature/add support for ust usd (#1085)
* Add UST

* Update changelog
2022-07-19 20:29:42 +02:00
2394cbd6fe Feature/support tags in create and update order dto (#1084)
* Support tags in create or update order

* Update changelog
2022-07-19 20:24:01 +02:00
a74d5cce20 Feature/remove activities import limit for premium users (#1082)
* Remove activities import limit for premium users

* Update changelog
2022-07-17 20:44:28 +02:00
95bcc3f32d Feature/remove alias from user interface (#1083)
* Remove alias from user interface

* Update changelog
2022-07-17 11:05:23 +02:00
e9dbd4a55d Add blog post to resources (#1081) 2022-07-17 10:09:11 +02:00
d440b09dc9 Improve quotes (#1078) 2022-07-15 09:04:44 +02:00
cc16ba5dc8 Release 1.169.0 (#1077) 2022-07-14 16:31:03 +02:00
d10227bc39 Feature/add support for luna2 and songbird cryptocurrencies (#1075)
* Add LUNA2 and SGB1

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Harmonize content

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

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

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

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

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

* Migrate UntypedFormControl to FormControl

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

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

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

* Update changelog
2022-06-14 17:43:42 +02:00
a8fcf09380 Fix link (#1012) 2022-06-13 17:44:55 +02:00
1071f446a8 Release 1.158.1 (#1010) 2022-06-12 19:21:02 +02:00
566 changed files with 46777 additions and 14294 deletions

45
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,45 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG]'
labels: ''
assignees: ''
---
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
**Bug Description**
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
<!-- Steps to reproduce the behavior -->
1.
2.
3.
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**Logs**
<!-- If applicable, add logs to help explain your problem. -->
**Environment**
<!-- Please complete the following information -->
- Ghostfolio Version X.Y.Z
- Browser
- OS
**Additional context**
<!-- Add any other context about the problem here. -->

36
.github/workflows/build-code.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: Build code
on:
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node_version:
- 16
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node_version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Check formatting
run: yarn format:check
- name: Execute tests
run: yarn test
- name: Build application
run: yarn build:all

49
.github/workflows/docker-image.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: Docker image CD
on:
push:
tags:
- '*.*.*'
pull_request:
branches:
- 'main'
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: ghostfolio/ghostfolio
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.output.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

6
.gitignore vendored
View File

@ -5,6 +5,7 @@
/tmp
# dependencies
/.yarn
/node_modules
# IDEs and editors
@ -24,16 +25,17 @@
# misc
/.angular/cache
.env.prod
/.sass-cache
/connect.lock
/coverage
/dist
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
yarn-error.log
# System Files
.DS_Store
Thumbs.db
Thumbs.db

View File

@ -1,4 +1,13 @@
{
"attributeGroups": [
"$ANGULAR_ELEMENT_REF",
"$ANGULAR_STRUCTURAL_DIRECTIVE",
"$DEFAULT",
"$ANGULAR_INPUT",
"$ANGULAR_TWO_WAY_BINDING",
"$ANGULAR_OUTPUT"
],
"attributeSort": "ASC",
"endOfLine": "auto",
"printWidth": 80,
"singleQuote": true,

View File

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

4
.vscode/launch.json vendored
View File

@ -5,11 +5,11 @@
"name": "Debug Jest File",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
"args": [
"test",
"--codeCoverage=false",
"--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts"
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
],
"cwd": "${workspaceFolder}",
"console": "internalConsole"

View File

@ -5,7 +5,892 @@ 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.158.0 - 12.06.2022
## 1.226.0 - 2023-01-11
### Added
- Added the language localization for Français (`fr`)
- Extended the landing page by a global heat map of subscribers
- Added support for the thousand separator in the global heat map component
### Changed
- Improved the form of the import dividends dialog (disable while loading)
- Removed the deprecated `~` in _Sass_ imports
### Fixed
- Fixed an exception in the _X-ray_ section
## 1.225.0 - 2023-01-07
### Added
- Added support for importing dividends from a data provider
### Changed
- Extended the Frequently Asked Questions (FAQ) page
## 1.224.0 - 2023-01-04
### Added
- Added support for the dividend timeline grouped by year
- Added support for the investment timeline grouped by year
- Set up the language localization for Français (`fr`)
- Set up the language localization for Português (`pt`)
### Changed
- Improved the language localization for Dutch (`nl`)
## 1.223.0 - 2023-01-01
### Added
- Added a student discount to the pricing page
- Added a prefix to the codes of the coupon system
### Changed
- Optimized the page titles in the header for mobile
- Extended the asset profile details dialog in the admin control panel
## 1.222.0 - 2022-12-29
### Added
- Added support for filtering on the analysis page
- Added the price to the `Subscription` database schema
### Changed
- Changed the execution time of the asset profile data gathering to every Sunday at lunch time
- Improved the activities import by providing asset profile details
- Upgraded `@codewithdan/observable-store` from version `2.2.11` to `2.2.15`
- Upgraded `bull` from version `4.8.5` to `4.10.2`
- Upgraded `countup.js` from version `2.0.7` to `2.3.2`
- Upgraded the _Internet Identity_ dependencies from version `0.12.1` to `0.15.1`
- Upgraded `prisma` from version `4.7.1` to `4.8.0`
### Fixed
- Fixed the language localization of the account type
## 1.221.0 - 2022-12-26
### Added
- Added support to manage the tags in the create or edit activity dialog
- Added the tags to the admin control panel
- Added a blog post: _The importance of tracking your personal finances_
- Resolved the title of the blog post
### Changed
- Improved the activities import by a preview step
- Improved the labels based on the type in the create or edit activity dialog
- Refreshed the cryptocurrencies list
- Removed the data source type `RAKUTEN`
### Fixed
- Fixed the date conversion for years with only two digits
## 1.220.0 - 2022-12-23
### Added
- Added the position detail dialog to the _Top 3_ and _Bottom 3_ performers of the analysis page
- Added the `dryRun` option to the import activities endpoint
### Changed
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 365 days
- Upgraded `color` from version `4.0.1` to `4.2.3`
- Upgraded `prettier` from version `2.7.1` to `2.8.1`
### Fixed
- Fixed the rounding of the y-axis ticks in the benchmark comparator
## 1.219.0 - 2022-12-17
### Added
- Added support to disable user sign up in the admin control panel
- Extended the glossary of the resources page by _Deflation_, _Inflation_ and _Stagflation_
### Changed
- Added the name to the symbol column in the activities table
- Combined the name and symbol column in the holdings table (former positions table)
## 1.218.0 - 2022-12-12
### Added
- Added the date of the first activity to the positions table
- Added an endpoint to fetch the logo of an asset or a platform
### Changed
- Improved the asset profile details dialog in the admin control panel
- Upgraded `chart.js` from version `3.8.0` to `4.0.1`
## 1.217.0 - 2022-12-10
### Added
- Added the dividend timeline grouped by month
### Changed
- Improved the value redaction interceptor (including `comment`)
- Improved the language localization for Español (`es`)
- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12`
- Upgraded `prisma` from version `4.6.1` to `4.7.1`
### Fixed
- Fixed the activities sorting in the account detail dialog
## 1.216.0 - 2022-12-03
### Added
- Supported a note for asset profiles
- Supported a manual currency for the activity fee
- Extended the support for column sorting in the accounts table (name, platform, transactions)
- Extended the support for column sorting in the activities table (name, symbol)
- Extended the support for column sorting in the positions table (performance)
### Changed
- Upgraded `big.js` from version `6.1.1` to `6.2.1`
- Upgraded `date-fns` from version `2.28.0` to `2.29.3`
- Upgraded `replace-in-file` from version `6.2.0` to `6.3.5`
### Fixed
- Fixed the filter by asset sub class for the asset profiles in the admin control
## 1.215.0 - 2022-11-27
### Changed
- Improved the language selector on the account page
- Improved the wording in the _X-ray_ section (net worth instead of investment)
- Extended the asset profile details dialog in the admin control panel
- Updated the browserslist database
- Upgraded `ionicons` from version `5.5.1` to `6.0.4`
- Upgraded `uuid` from version `8.3.2` to `9.0.0`
## 1.214.0 - 19.11.2022
### Added
- Added support for sorting in the accounts table
### Changed
- Improved the support for the `MANUAL` data source
- Improved the _Activities_ tab icon
- Improved the _Activities_ icons for `BUY`, `DIVIDEND` and `SELL`
- Upgraded `prisma` from version `4.4.0` to `4.6.1`
- Upgraded `yahoo-finance2` from version `2.3.6` to `2.3.10`
### Fixed
- Fixed the activities sorting in the position detail dialog
- Fixed the dynamic number of decimal places for cryptocurrencies in the position detail dialog
- Fixed a division by zero error in the cash positions calculation
## 1.213.0 - 14.11.2022
### Added
- Added an indicator for excluded accounts in the accounts table
- Added a blog post: _Black Friday 2022_
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ZAc` to `ZAR`)
## 1.212.0 - 11.11.2022
### Changed
- Changed the view mode selector to a slide toggle
- Upgraded `Nx` from version `15.0.0` to `15.0.13`
## 1.211.0 - 11.11.2022
### Changed
- Converted the client into a _Progressive Web App_ (PWA) with `@angular/pwa`
- Removed the bottom margin from the body element
- Improved the pricing page
## 1.210.0 - 08.11.2022
### Added
- Added tabs to the portfolio page
### Changed
- Merged the _FIRE_ calculator and the _X-ray_ section to a single page
- Tightened the validation rule of the base currency environment variable (`BASE_CURRENCY`)
### Fixed
- Fixed an issue in the cash positions calculation
## 1.209.0 - 05.11.2022
### Added
- Added the _Buy me a coffee_ button to the about page
### Changed
- Improved the usability of the activities import
- Improved the usage of the premium indicator component
- Removed the intro image in dark mode
- Refactored the `TransactionsPageComponent` to `ActivitiesPageComponent`
## 1.208.0 - 03.11.2022
### Added
- Added pagination to the activities table
### Changed
- Restructured the actions in the admin control panel
### Fixed
- Fixed the calculation in the portfolio evolution chart
## 1.207.0 - 31.10.2022
### Added
- Added support for translated labels of asset and asset sub class
- Added support for dates in _ISO 8601_ date format (`YYYY-MM-DD`) in the activities import
### Changed
- Darkened the background color of the dark mode
### Fixed
- Fixed the public page
- Improved the loading indicator of the portfolio evolution chart
## 1.206.2 - 20.10.2022
### Changed
- Fixed the `rxjs` version to `7.5.6` (resolutions)
- Migrated the `angular.json` to `project.json` files in the `Nx` workspace
- Upgraded `nestjs` from version `9.0.7` to `9.1.4`
- Upgraded `Nx` from version `14.6.4` to `15.0.0`
### Fixed
- Fixed the performance calculation including `SELL` activities with a significant performance gain
## 1.205.2 - 16.10.2022
### Changed
- Persisted the language on url change
- Improved the portfolio evolution chart
- Refactored the appearance (dark mode) in user settings (from `appearance` to `colorScheme`)
- Improved the wording on the landing page
## 1.204.1 - 15.10.2022
### Added
- Added support to change the appearance (dark mode) in user settings
- Added the total amount chart to the investment timeline
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
### Changed
- Respected the current date in the _FIRE_ calculator
- Simplified the settings management in the admin control panel
- Renamed the data source type `RAKUTEN` to `RAPID_API`
### Fixed
- Fixed some links in the blog posts
- Fixed the alignment of the value component on the allocations page
### Todo
- Rename the environment variable from `RAKUTEN_RAPID_API_KEY` to `RAPID_API_API_KEY`
## 1.203.0 - 08.10.2022
### Added
- Supported a progressive line animation in the line chart component
### Changed
- Moved the benchmark comparator from experimental to general availability
- Improved the user interface of the benchmark comparator
### Fixed
- Fixed an issue in the performance and chart calculation of today
- Fixed the alignment of the value component in the admin control panel
## 1.202.0 - 07.10.2022
### Added
- Added support for a translated 4% rule in the _FIRE_ section
### Changed
- Improved the caching of the benchmarks in the markets overview (only cache if fetching was successful)
- Improved the wording in the twitter bot service
### Fixed
- Fixed the support for cryptocurrencies having a symbol with less than 3 characters (e.g. `SC-USD`)
- Fixed the text truncation in the value component
## 1.201.0 - 01.10.2022
### Added
- Added a blog post: _Hacktoberfest 2022_
### Changed
- Improved the usage of the value component in the admin control panel
- Improved the language localization for Español (`es`)
### Fixed
- Fixed the usage of the value component on the allocations page
## 1.200.0 - 01.10.2022
### Added
- Added a mini statistics section to the landing page including pulls on _Docker Hub_
- Added an _As seen in_ section to the landing page
- Added support for an icon in the value component
### Changed
- Upgraded `prisma` from version `4.1.1` to `4.4.0`
## 1.199.1 - 27.09.2022
### Added
- Set up the language localization for Español (`es`)
- Added support for sectors in mutual funds
## 1.198.0 - 25.09.2022
### Added
- Added support to exclude an account from analysis
- Set up the language localization for Nederlands (`nl`)
## 1.197.0 - 24.09.2022
### Added
- Added the value of the active filter in percentage on the allocations page
- Extended the feature overview page by multi-language support (English, German, Italian)
### Changed
- Combined the performance and chart calculation
- Improved the style of various selectors (density)
## 1.196.0 - 22.09.2022
### Added
- Set up the language localization for Italiano (`it`)
- Extended the landing page
## 1.195.0 - 20.09.2022
### Changed
- Improved the algorithm of the performance chart calculation
### Fixed
- Improved the chart tooltip of the benchmark comparator
## 1.194.0 - 17.09.2022
### Added
- Added `NODE_ENV: production` to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
- Visualized the percentage of the active filter on the allocations page
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Respected the end date in the performance chart calculation
### Todo
- Set `NODE_ENV: production` as in [docker-compose.yml](https://github.com/ghostfolio/ghostfolio/blob/main/docker/docker-compose.yml)
## 1.193.0 - 14.09.2022
### Changed
- Sorted the benchmarks by name
- Extended the pricing page
### Fixed
- Fixed the calculations of the exchange rate service by changing `USD` to the base currency
- Fixed the missing assets during the local development
## 1.192.0 - 11.09.2022
### Changed
- Simplified the configuration of the benchmarks: `symbolProfileId` instead of `dataSource` and `symbol`
- Upgraded `yahoo-finance2` from version `2.3.3` to `2.3.6`
### Fixed
- Improved the loading indicator of the benchmark comparator
- Improved the error handling in the benchmark calculation
## 1.191.0 - 10.09.2022
### Changed
- Removed the `currency` and `viewMode` from the `User` database schema
### Fixed
- Allowed the date range change for the demo user
## 1.190.0 - 10.09.2022
### Added
- Added the date range component to the benchmark comparator
### Changed
- Improved the mobile layout of the benchmark comparator
- Migrated the date range setting from the locale storage to the user settings
- Refactored the `currency` and `view mode` in the user settings
## 1.189.0 - 08.09.2022
### Changed
- Distinguished between currency and unit in the chart tooltip
### Fixed
- Fixed the benchmark chart in the benchmark comparator (experimental)
## 1.188.0 - 06.09.2022
### Added
- Added a benchmark comparator (experimental)
### Fixed
- Improved the asset profile details dialog for assets without a (first) activity in the admin control panel
## 1.187.0 - 03.09.2022
### Added
- Supported units in the line chart component
- Added a new chart calculation engine (experimental)
## 1.186.2 - 03.09.2022
### Changed
- Decreased the rate limiter duration of queue jobs from 5 to 4 seconds
- Removed the alias from the `User` database schema
- Upgraded `angular` from version `14.1.0` to `14.2.0`
- Upgraded `Nx` from version `14.5.1` to `14.6.4`
### Fixed
- Fixed the environment variables `REDIS_HOST`, `REDIS_PASSWORD` and `REDIS_PORT` in the Redis configuration
- Handled errors in the portfolio calculation if there is no internet connection
- Fixed the _GitHub_ contributors count on the about page
## 1.185.0 - 30.08.2022
### Added
- Added a skeleton loader to the market mood component in the markets overview
### Changed
- Moved the build pipeline from _Travis_ to _GitHub Actions_
- Increased the caching of the benchmarks
### Fixed
- Disabled the language selector for the demo user
## 1.184.2 - 28.08.2022
### Added
- Added the alias to the `Access` database schema
- Added support for translated time distances
- Added a _GitHub Action_ to create an `arm64` docker image
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed the missing assets during the local development
## 1.183.0 - 24.08.2022
### Added
- Added a filter by asset sub class for the asset profiles in the admin control
### Changed
- Improved the language localization for German (`de`)
## 1.182.0 - 23.08.2022
### Changed
- Improved the language localization for German (`de`)
- Extended and made the columns of the asset profiles sortable in the admin control
- Moved the asset profile details in the admin control panel to a dialog
## 1.181.2 - 21.08.2022
### Added
- Added a language selector to the account page
- Added support for translated labels in the value component
### Changed
- Integrated the commands `database:setup` and `database:migrate` into the container start
### Fixed
- Fixed a division by zero error in the benchmarks calculation
### Todo
- Apply manual data migration (`yarn database:migrate`) is not needed anymore
## 1.180.1 - 18.08.2022
### Added
- Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow
- Set up the language localization for German (`de`)
- Resolved the feature graphic of the blog post
### Changed
- Tagged template literal strings in components for localization with `$localize`
### Fixed
- Fixed the license component in the about page
- Fixed the links to the blog posts
## 1.179.5 - 15.08.2022
### Added
- Set up i18n support
- Added a blog post: _500 Stars on GitHub_
### Changed
- Reduced the maximum width of the performance chart on the home page
## 1.178.0 - 09.08.2022
### Added
- Added `url` to the symbol profile overrides model for manual adjustments
- Added default values for `countries` and `sectors` of the symbol profile overrides model
### Changed
- Simplified the initialization of the exchange rate service
- Improved the orders query for `assetClass` with symbol profile overrides
- Improved the styling of the benchmarks in the markets overview
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.177.0 - 04.08.2022
### Added
- Added `GHOSTFOLIO` as a default to `DATA_SOURCES`
- Added the `AGPLv3` logo to the landing page
### Changed
- Refactored the initialization of the exchange rate service
- Upgraded `angular` from version `14.0.2` to `14.1.0`
- Upgraded `nestjs` from version `8.4.7` to `9.0.7`
- Upgraded `Nx` from version `14.3.5` to `14.5.1`
- Upgraded `prisma` from version `3.15.2` to `4.1.1`
### Fixed
- Handled database connection errors (do not exit process)
## 1.176.2 - 31.07.2022
### Added
- Added page titles
### Changed
- Improved the performance of data provider requests by introducing a maximum number of symbols per request (chunk size)
- Changed the log level settings
- Refactored the access of the environment variables in the bootstrap function (api)
- Upgraded `Node.js` from version `14` to `16` (`Dockerfile`)
### Todo
- Upgrade to `Node.js` 16+
## 1.175.0 - 29.07.2022
### Added
- Set up a Frequently Asked Questions (FAQ) page
- Added the savings rate to the investment timeline grouped by month
### Fixed
- Added the symbols to the activities in the account detail dialog
## 1.174.0 - 27.07.2022
### Added
- Supported a note for activities
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.173.0 - 23.07.2022
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `USX` to `USD`)
## 1.172.0 - 23.07.2022
### Added
- Added a blog post: _Ghostfolio meets Internet Identity_
## 1.171.0 - 22.07.2022
### Added
- Added _Internet Identity_ as a new social login provider
### Changed
- Improved the empty state of the
- _Analysis_ section
- _Holdings_ section
- performance chart on the home page
### Fixed
- Fixed the distorted tooltip in the performance chart on the home page
- Fixed a calculation issue of the current month in the investment timeline grouped by month
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.170.0 - 19.07.2022
### Added
- Added support for the tags in the create or edit transaction dialog
- Added support for the cryptocurrency _TerraUSD_ (`UST-USD`)
### Changed
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema
- Removed the activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022
### Added
- Added support for the cryptocurrency _Songbird_ (`SGB1-USD`)
- Added support for the cryptocurrency _Terra 2.0_ (`LUNA2-USD`)
- Added a blog post
### Changed
- Refreshed the cryptocurrencies list to support more coins by default
- Upgraded `date-fns` from version `2.22.1` to `2.28.0`
## 1.168.0 - 10.07.2022
### Added
- Extended the investment timeline grouped by month
### Changed
- Handled an occasional currency pair inconsistency in the _Yahoo Finance_ service (`GBP=X` instead of `USDGBP=X`)
### Fixed
- Fixed the content height of the account detail dialog
## 1.167.0 - 07.07.2022
### Added
- Added _Markets_ to the public pages
### Changed
- Improved the _Create Account_ link in the _Live Demo_
- Upgraded `ngx-markdown` from version `13.0.0` to `14.0.1`
### Fixed
- Fixed an issue in the _Holdings_ section for users without a subscription
## 1.166.0 - 30.06.2022
### Added
- Added an account detail dialog
### Changed
- Improved the label of the (symbol) search
- Refactored the demo account as a route (`/demo`)
- Upgraded `nestjs` from version `8.2.3` to `8.4.7`
- Upgraded `prisma` from version `3.14.0` to `3.15.2`
- Upgraded `yahoo-finance2` from version `2.3.2` to `2.3.3`
- Upgraded `zone.js` from version `0.11.4` to `0.11.6`
## 1.165.0 - 25.06.2022
### Added
- Added an icon and name column to the positions table
- Added a reusable premium indicator component
### Changed
- Moved the positions table to a dedicated section (_Holdings_)
- Changed the data gathering by symbol endpoint to delete data first
## 1.164.0 - 23.06.2022
### Added
- Added the positions table including performance to the public page
## 1.163.0 - 22.06.2022
### Changed
- Improved the onboarding for iOS
## 1.162.0 - 18.06.2022
### Added
- Added a _Privacy Policy_ page
### Changed
- Simplified the header
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ILA` to `ILS`)
## 1.161.1 - 16.06.2022
### Added
- Added the vertical hover line to inspect data points in the performance chart on the home page
### Changed
- Improved the landing page
- Upgraded `angular` from version `13.3.6` to `14.0.2`
- Upgraded `Nx` from version `14.1.4` to `14.3.5`
- Upgraded `storybook` from version `6.4.22` to `6.5.9`
### Fixed
- Improved the error handling of missing market prices
## 1.160.0 - 15.06.2022
### Fixed
- Fixed the `No data provider has been found` error in the search (regression after `envalid` upgrade to `7.3.1` in Ghostfolio `1.157.0`)
## 1.159.0 - 15.06.2022
### Changed
- Changed the default `HOST` to `0.0.0.0`
- Refactored the endpoint of the public page (filter by equity)
## 1.158.1 - 12.06.2022
### Added
@ -395,8 +1280,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Display the value in base currency in the accounts table on mobile
- Display the value in base currency in the activities table on mobile
- Displayed the value in base currency in the accounts table on mobile
- Displayed the value in base currency in the activities table on mobile
- Renamed `orders` to `activities` in import and export functionality
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
- Improved the pricing page

View File

@ -1,7 +1,6 @@
FROM node:14-alpine as builder
FROM --platform=$BUILDPLATFORM node:16-slim as builder
# Build application and add additional files
WORKDIR /ghostfolio
# Only add basic files without the application itself to avoid rebuilding
@ -10,19 +9,25 @@ COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
COPY ./.yarnrc .yarnrc
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apk add --no-cache python3 g++ make openssl
RUN apt update && apt install -y \
git \
g++ \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install
# See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js
COPY ./angular.json angular.json
COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js
COPY ./jest.preset.ts jest.preset.ts
COPY ./jest.preset.js jest.preset.js
COPY ./jest.config.ts jest.config.ts
COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
@ -45,8 +50,12 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
# Image to run, copy everything needed from builder
FROM node:14-alpine
FROM node:16-slim
RUN apt update && apt install -y \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api
EXPOSE 3333
CMD [ "node", "main" ]
CMD [ "yarn", "start:prod" ]

155
README.md
View File

@ -1,41 +1,33 @@
<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 Wealth Management Software</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p>
<p>
<a href="#contributing">
<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>
</p>
[<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io)
# Ghostfolio
**Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
</div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
<div align="center">
[<img src="./apps/client/src/assets/images/video-preview.jpg" width="600" alt="Preview image of the Ghostfolio video trailer">](https://www.youtube.com/watch?v=yY6ObSQVJZk)
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
</div>
## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
## Why Ghostfolio?
@ -48,23 +40,25 @@ Ghostfolio is for you if you are...
- 🧘 into minimalism
- 🧺 caring about diversifying your financial resources
- 🆓 interested in financial independence
- 🙅 saying no to spreadsheets in 2022
- 🙅 saying no to spreadsheets in 2023
- 😎 still reading this list
## Features
- ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions
- ✅ Dark Mode
- ✅ Zen Mode
-Mobile-first design
-Progressive Web App (PWA) with a mobile-first design
<div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
</div>
## Technology Stack
@ -81,6 +75,31 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## Self-hosting
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
<div align="center">
[<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio)
</div>
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application.<br />`AUD` \| `CAD` \| `CNY` \| `EUR` \| `GBP` \| `JPY` \| `RUB` \| `USD`<br />Caution: Only set if you intend to track cryptocurrencies in a non-`USD` currency. This cannot be changed later! |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running |
### Run with Docker Compose
#### Prerequisites
@ -97,14 +116,6 @@ Run the following command to start the Docker images from [Docker Hub](https://h
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
```
##### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
#### b. Build and run environment
Run the following commands to build and start the Docker images:
@ -114,29 +125,21 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
```
##### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
```
#### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
#### Upgrade Version
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
At each start, the container will automatically apply the database schema migrations if needed.
### Run with _Unraid_ (unofficial)
### Run with _Unraid_ (Community)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
@ -145,26 +148,30 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+)
- [Node.js](https://nodejs.org/en/download) (version 16+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)
### Setup
1. Run `yarn install`
1. Run `yarn build:dev` to build the source code including the assets
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start the server and the client (see [_Development_](#Development))
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
### Start Server
<ol type="a">
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
<li>Serve: Run <code>yarn start:server</code></li>
</ol>
#### Debug
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
#### Serve
Run `yarn start:server`
### Start Client
@ -186,7 +193,17 @@ yarn database:push
Run `yarn test`
## Public API (experimental)
## Public API
### Authorization: Bearer Token
Set the header for each request as follows:
```
"Authorization": "Bearer eyJh..."
```
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
### Import Activities
@ -194,14 +211,6 @@ Run `yarn test`
`POST http://localhost:3333/api/v1/import`
#### Authorization: Bearer Token
Set the header as follows:
```
"Authorization": "Bearer eyJh..."
```
#### Body
```
@ -213,7 +222,7 @@ Set the header as follows:
"date": "2021-09-15T00:00:00.000Z",
"fee": 19,
"quantity": 5,
"symbol": "MSFT"
"symbol": "MSFT",
"type": "BUY",
"unitPrice": 298.58
}
@ -252,16 +261,20 @@ Set the header as follows:
}
```
## Community Projects
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
## License
© 2022 [Ghostfolio](https://ghostfol.io)
© 2023 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -1,325 +0,0 @@
{
"version": 1,
"projects": {
"api": {
"root": "apps/api",
"sourceRoot": "apps/api/src",
"projectType": "application",
"prefix": "api",
"schematics": {},
"architect": {
"build": {
"builder": "@nrwl/node:webpack",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"]
},
"configurations": {
"production": {
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
},
"outputs": ["{options.outputPath}"]
},
"serve": {
"builder": "@nrwl/node:node",
"options": {
"buildTarget": "api:build"
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/api/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/api"]
}
},
"tags": []
},
"client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "apps/client",
"sourceRoot": "apps/client/src",
"prefix": "gf",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/apps/client",
"index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [
"apps/client/src/assets",
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./"
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "apps/client/src/environments/environment.ts",
"with": "apps/client/src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
},
"outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json"
},
"configurations": {
"production": {
"browserTarget": "client:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "client:build"
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/client/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/client"]
}
},
"tags": []
},
"client-e2e": {
"root": "apps/client-e2e",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/client-e2e/cypress.json",
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
"devServerTarget": "client:serve"
},
"configurations": {
"production": {
"devServerTarget": "client:serve:production"
}
}
}
},
"tags": [],
"implicitDependencies": ["client"]
},
"common": {
"root": "libs/common",
"sourceRoot": "libs/common/src",
"projectType": "library",
"architect": {
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/common/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"],
"options": {
"jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true
}
}
},
"tags": []
},
"ui": {
"projectType": "library",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "libs/ui",
"sourceRoot": "libs/ui/src",
"prefix": "gf",
"architect": {
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"],
"options": {
"jestConfig": "libs/ui/jest.config.ts",
"passWithNoTests": true
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
}
},
"storybook": {
"builder": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/angular",
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
},
"projectBuildConfig": "ui:build-storybook"
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"builder": "@nrwl/storybook:build",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/angular",
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
},
"projectBuildConfig": "ui:build-storybook"
},
"configurations": {
"ci": {
"quiet": true
}
}
}
},
"tags": []
},
"ui-e2e": {
"root": "apps/ui-e2e",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/ui-e2e/cypress.json",
"devServerTarget": "ui:storybook",
"tsConfig": "apps/ui-e2e/tsconfig.json"
},
"configurations": {
"ci": {
"devServerTarget": "ui:storybook:ci"
}
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
}
}
},
"tags": [],
"implicitDependencies": ["ui"]
}
}
}

View File

@ -1,4 +1,5 @@
module.exports = {
/* eslint-disable */
export default {
displayName: 'api',
globals: {
@ -13,5 +14,5 @@ module.exports = {
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000,
testEnvironment: 'node',
preset: '../../jest.preset.ts'
preset: '../../jest.preset.js'
};

57
apps/api/project.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "api",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/api/src",
"projectType": "application",
"prefix": "api",
"generators": {},
"targets": {
"build": {
"executor": "@nrwl/webpack:webpack",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"],
"target": "node",
"compiler": "tsc"
},
"configurations": {
"production": {
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
},
"outputs": ["{options.outputPath}"]
},
"serve": {
"executor": "@nrwl/node:node",
"options": {
"buildTarget": "api:build"
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/api/**/*.ts"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["{workspaceRoot}/coverage/apps/api"]
}
},
"tags": []
}

View File

@ -42,14 +42,16 @@ export class AccessController {
return accessesWithGranteeUser.map((access) => {
if (access.GranteeUser) {
return {
granteeAlias: access.GranteeUser?.alias,
alias: access.alias,
grantee: access.GranteeUser?.id,
id: access.id,
type: 'RESTRICTED_VIEW'
};
}
return {
granteeAlias: 'Public',
alias: access.alias,
grantee: 'Public',
id: access.id,
type: 'PUBLIC'
};
@ -71,6 +73,10 @@ export class AccessController {
}
return this.accessService.createAccess({
alias: data.alias || undefined,
GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
User: { connect: { id: this.request.user.id } }
});
}

View File

@ -1 +1,11 @@
export class CreateAccessDto {}
import { IsOptional, IsString } from 'class-validator';
export class CreateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsOptional()
@IsString()
granteeUserId?: string;
}

View File

@ -7,7 +7,10 @@ import {
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import type {
AccountWithValue,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -92,9 +95,10 @@ export class AccountController {
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id
);
await this.portfolioService.getAccountsWithAggregations({
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
if (
impersonationUserId ||
@ -123,13 +127,46 @@ export class AccountController {
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
return this.accountService.account({
id_userId: {
id,
userId: this.request.user.id
}
});
public async getAccountById(
@Headers('impersonation-id') impersonationId,
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations({
filters: [{ id, type: 'ACCOUNT' }],
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
])
};
}
return accountsWithAggregations.accounts[0];
}
@Post()

View File

@ -107,15 +107,23 @@ export class AccountService {
public async getCashDetails({
currency,
filters = [],
userId
userId,
withExcludedAccounts = false
}: {
currency: string;
filters?: Filter[];
userId: string;
withExcludedAccounts?: boolean;
}): Promise<CashDetails> {
let totalCashBalanceInBaseCurrency = new Big(0);
const where: Prisma.AccountWhereInput = { userId };
const where: Prisma.AccountWhereInput = {
userId
};
if (withExcludedAccounts === false) {
where.isExcluded = false;
}
const {
ACCOUNT: filtersByAccount,

View File

@ -1,5 +1,11 @@
import { AccountType } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
ValidateIf
} from 'class-validator';
export class CreateAccountDto {
@IsString()
@ -11,6 +17,10 @@ export class CreateAccountDto {
@IsString()
currency: string;
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
@IsString()
name: string;

View File

@ -1,5 +1,11 @@
import { AccountType } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
ValidateIf
} from 'class-validator';
export class UpdateAccountDto {
@IsString()
@ -14,6 +20,10 @@ export class UpdateAccountDto {
@IsString()
id: string;
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
@IsString()
name: string;

View File

@ -8,7 +8,9 @@ import {
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
AdminMarketDataDetails,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -20,8 +22,10 @@ import {
HttpException,
Inject,
Param,
Patch,
Post,
Put,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -31,6 +35,7 @@ import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin')
@ -226,7 +231,9 @@ export class AdminController {
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
public async getMarketData(): Promise<AdminMarketData> {
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string
): Promise<AdminMarketData> {
if (
!hasPermission(
this.request.user.permissions,
@ -239,7 +246,18 @@ export class AdminController {
);
}
return this.adminService.getMarketData();
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const filters: Filter[] = [
...assetSubClasses.map((assetSubClass) => {
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
return this.adminService.getMarketData(filters);
}
@Get('market-data/:dataSource/:symbol')
@ -317,6 +335,32 @@ export class AdminController {
return this.adminService.deleteProfileData({ dataSource, symbol });
}
@Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol
});
}
@Put('settings/:key')
@UseGuards(AuthGuard('jwt'))
public async updateProperty(

View File

@ -1,6 +1,5 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
@ -12,11 +11,13 @@ import {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
Filter,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { AssetSubClass, Prisma, Property } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@Injectable()
export class AdminService {
@ -24,7 +25,6 @@ export class AdminService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
@ -65,14 +65,27 @@ export class AdminService {
};
}
public async getMarketData(): Promise<AdminMarketData> {
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
const where: Prisma.SymbolProfileWhereInput = {};
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
(filter) => {
return filter.type;
}
);
const marketData = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const currencyPairsToGather: AdminMarketDataItem[] =
this.exchangeRateDataService
let currencyPairsToGather: AdminMarketDataItem[] = [];
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} else {
currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
@ -86,17 +99,25 @@ export class AdminService {
return {
dataSource,
marketDataItemCount,
symbol
symbol,
countriesCount: 0,
sectorsCount: 0
};
});
}
const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({
where,
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
@ -104,10 +125,14 @@ export class AdminService {
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true
}
})
).map((symbolProfile) => {
const countriesCount = symbolProfile.countries
? Object.keys(symbolProfile.countries).length
: 0;
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
@ -115,10 +140,18 @@ export class AdminService {
marketDataItem.symbol === symbolProfile.symbol
);
})?._count ?? 0;
const sectorsCount = symbolProfile.sectors
? Object.keys(symbolProfile.sectors).length
: 0;
return {
countriesCount,
marketDataItemCount,
activityCount: symbolProfile._count.Order,
sectorsCount,
activitiesCount: symbolProfile._count.Order,
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
comment: symbolProfile.comment,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol
@ -134,8 +167,14 @@ export class AdminService {
dataSource,
symbol
}: UniqueAsset): Promise<AdminMarketDataDetails> {
return {
marketData: await this.marketDataService.marketDataItems({
const [[assetProfile], marketData] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
@ -144,16 +183,44 @@ export class AdminService {
symbol
}
})
]);
return {
assetProfile,
marketData
};
}
public async patchAssetProfileData({
comment,
dataSource,
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({
comment,
dataSource,
symbol,
symbolMapping
});
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]);
return symbolProfile;
}
public async putSetting(key: string, value: string) {
let response: Property;
if (value === '') {
response = await this.propertyService.delete({ key });
} else {
if (value) {
response = await this.propertyService.put({ key, value });
} else {
response = await this.propertyService.delete({ key });
}
if (key === PROPERTY_CURRENCIES) {
@ -174,7 +241,6 @@ export class AdminService {
_count: {
select: { Account: true, Order: true }
},
alias: true,
Analytics: {
select: {
activityCount: true,
@ -194,7 +260,7 @@ export class AdminService {
});
return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
({ _count, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration;
@ -206,7 +272,6 @@ export class AdminService {
: undefined;
return {
alias,
createdAt,
engagement,
id,

View File

@ -0,0 +1,13 @@
import { IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateAssetProfileDto {
@IsString()
@IsOptional()
comment?: string;
@IsObject()
@IsOptional()
symbolMapping?: {
[dataProvider: string]: string;
};
}

View File

@ -1,6 +1,17 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Controller } from '@nestjs/common';
@Controller()
export class AppController {
public constructor() {}
public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService
) {
this.initialize();
}
private async initialize() {
try {
await this.exchangeRateDataService.initialize();
} catch {}
}
}

View File

@ -10,7 +10,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
@ -22,9 +22,12 @@ import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module';
import { SubscriptionModule } from './subscription/subscription.module';
@ -51,10 +54,12 @@ import { UserModule } from './user/user.module';
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateModule,
ExchangeRateDataModule,
ExportModule,
ImportModule,
InfoModule,
LogoModule,
OrderModule,
PortfolioModule,
PrismaModule,
@ -82,4 +87,10 @@ import { UserModule } from './user/user.module';
controllers: [AppController],
providers: [CronService]
})
export class AppModule {}
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(FrontendMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

@ -1,5 +1,7 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import {
Body,
Controller,
@ -14,6 +16,7 @@ import {
Version
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AuthService } from './auth.service';
@ -31,7 +34,9 @@ export class AuthController {
) {}
@Get('anonymous/:accessToken')
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
public async accessTokenLogin(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateAnonymousLogin(
accessToken
@ -54,14 +59,42 @@ export class AuthController {
@Get('google/callback')
@UseGuards(AuthGuard('google'))
@Version(VERSION_NEUTRAL)
public googleLoginCallback(@Req() req, @Res() res) {
public googleLoginCallback(
@Req() request: Request,
@Res() response: Response
) {
// Handles the Google OAuth2 callback
const jwt: string = req.user.jwt;
const jwt: string = (<any>request.user).jwt;
if (jwt) {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`);
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
);
} else {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
}
}
@Get('internet-identity/:principalId')
public async internetIdentityLogin(
@Param('principalId') principalId: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
principalId
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}

View File

@ -4,6 +4,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
signOptions: { expiresIn: '180 days' }
}),
PrismaModule,
PropertyModule,
SubscriptionModule,
UserModule
],

View File

@ -1,7 +1,9 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client';
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -10,10 +12,11 @@ export class AuthService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService,
private readonly propertyService: PropertyService,
private readonly userService: UserService
) {}
public async validateAnonymousLogin(accessToken: string) {
public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken(
@ -26,7 +29,7 @@ export class AuthService {
});
if (user) {
const jwt: string = this.jwtService.sign({
const jwt = this.jwtService.sign({
id: user.id
});
@ -40,6 +43,40 @@ export class AuthService {
});
}
public async validateInternetIdentityLogin(principalId: string) {
try {
const provider: Provider = 'INTERNET_IDENTITY';
let [user] = await this.userService.users({
where: { provider, thirdPartyId: principalId }
});
if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) {
throw new Error('Sign up forbidden');
}
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId: principalId
});
}
return this.jwtService.sign({
id: user.id
});
} catch (error) {
throw new InternalServerErrorException(
'validateInternetIdentityLogin',
error.message
);
}
}
public async validateOAuthLogin({
provider,
thirdPartyId
@ -50,6 +87,13 @@ export class AuthService {
});
if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) {
throw new Error('Sign up forbidden');
}
// Create new user if not found
user = await this.userService.createUser({
provider,
@ -57,13 +101,14 @@ export class AuthService {
});
}
const jwt: string = this.jwtService.sign({
return this.jwtService.sign({
id: user.id
});
return jwt;
} catch (err) {
throw new InternalServerErrorException('validateOAuthLogin', err.message);
} catch (error) {
throw new InternalServerErrorException(
'validateOAuthLogin',
error.message
);
}
}
}

View File

@ -54,7 +54,7 @@ export class WebAuthService {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userName: user.alias,
userName: '',
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: {

View File

@ -1,32 +1,48 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common';
import {
BenchmarkMarketDataDetails,
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import {
Controller,
Get,
Param,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { BenchmarkService } from './benchmark.service';
@Controller('benchmark')
export class BenchmarkController {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly propertyService: PropertyService
) {}
public constructor(private readonly benchmarkService: BenchmarkService) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
return {
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets)
benchmarks: await this.benchmarkService.getBenchmarks()
};
}
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
});
}
}

View File

@ -1,4 +1,5 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
@ -18,6 +19,7 @@ import { BenchmarkService } from './benchmark.service';
MarketDataModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule
],
providers: [BenchmarkService]

View File

@ -0,0 +1,15 @@
import { BenchmarkService } from './benchmark.service';
describe('BenchmarkService', () => {
let benchmarkService: BenchmarkService;
beforeAll(async () => {
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
});
it('calculateChangeInPercentage', async () => {
expect(benchmarkService.calculateChangeInPercentage(1, 2)).toEqual(1);
expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0);
expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5);
});
});

View File

@ -1,10 +1,24 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import {
MAX_CHART_ITEMS,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
BenchmarkMarketDataDetails,
BenchmarkResponse,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { format } from 'date-fns';
import ms from 'ms';
@Injectable()
export class BenchmarkService {
@ -13,72 +27,184 @@ export class BenchmarkService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileService: SymbolProfileService,
private readonly symbolService: SymbolService
) {}
public async getBenchmarks(
benchmarkAssets: UniqueAsset[]
): Promise<BenchmarkResponse['benchmarks']> {
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
if (baseValue && currentValue) {
return new Big(currentValue).div(baseValue).minus(1).toNumber();
}
return 0;
}
public async getBenchmarks({ useCache = true } = {}): Promise<
BenchmarkResponse['benchmarks']
> {
let benchmarks: BenchmarkResponse['benchmarks'];
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (useCache) {
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (benchmarks) {
return benchmarks;
}
} catch {}
if (benchmarks) {
return benchmarks;
}
} catch {}
}
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
const promises: Promise<number>[] = [];
const [quotes, assetProfiles] = await Promise.all([
this.dataProviderService.getQuotes(benchmarkAssets),
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
]);
const quotes = await this.dataProviderService.getQuotes(
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
);
for (const benchmarkAsset of benchmarkAssets) {
promises.push(this.marketDataService.getMax(benchmarkAsset));
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
}
const allTimeHighs = await Promise.all(promises);
let storeInCache = true;
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
const performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh,
marketPrice
);
} else {
storeInCache = false;
}
return {
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: assetProfiles.find(({ dataSource, symbol }) => {
return (
dataSource === benchmarkAssets[index].dataSource &&
symbol === benchmarkAssets[index].symbol
);
})?.name,
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
performancePercent: performancePercentFromAllTimeHigh.toNumber()
performancePercent: performancePercentFromAllTimeHigh
}
}
};
});
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks)
);
if (storeInCache) {
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks),
ms('4 hours') / 1000
);
}
return benchmarks;
}
private getMarketCondition(aPerformanceInPercent: Big) {
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
const symbolProfileIds: string[] = (
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
symbolProfileId: string;
}[]) ?? []
).map(({ symbolProfileId }) => {
return symbolProfileId;
});
const assetProfiles =
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
return assetProfiles
.map(({ dataSource, id, name, symbol }) => {
return {
dataSource,
id,
name,
symbol
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
public async getMarketDataBySymbol({
dataSource,
startDate,
symbol
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
gte: startDate
}
}
})
]);
const step = Math.round(
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
);
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
const response = {
marketData: [
...marketDataItems
.filter((marketDataItem, index) => {
return index % step === 0;
})
.map((marketDataItem) => {
return {
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice
) * 100
};
})
]
};
if (currentSymbolItem?.marketPrice) {
response.marketData.push({
date: format(new Date(), DATE_FORMAT),
value:
this.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice
) * 100
});
}
return response;
}
private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}
}

View File

@ -0,0 +1,26 @@
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ExchangeRateService } from './exchange-rate.service';
@Controller('exchange-rate')
export class ExchangeRateController {
public constructor(
private readonly exchangeRateService: ExchangeRateService
) {}
@Get(':symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async getExchangeRate(
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString);
return this.exchangeRateService.getExchangeRate({
date,
symbol
});
}
}

View File

@ -0,0 +1,13 @@
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { Module } from '@nestjs/common';
import { ExchangeRateController } from './exchange-rate.controller';
import { ExchangeRateService } from './exchange-rate.service';
@Module({
controllers: [ExchangeRateController],
exports: [ExchangeRateService],
imports: [ExchangeRateDataModule],
providers: [ExchangeRateService]
})
export class ExchangeRateModule {}

View File

@ -0,0 +1,29 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExchangeRateService {
public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService
) {}
public async getExchangeRate({
date,
symbol
}: {
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
const [currency1, currency2] = symbol.split('-');
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
1,
currency1,
currency2,
date
);
return { marketPrice };
}
}

View File

@ -18,6 +18,7 @@ export class ExportService {
orderBy: { date: 'desc' },
select: {
accountId: true,
comment: true,
date: true,
fee: true,
id: true,
@ -40,6 +41,7 @@ export class ExportService {
activities: activities.map(
({
accountId,
comment,
date,
fee,
id,
@ -50,6 +52,7 @@ export class ExportService {
}) => {
return {
accountId,
comment,
fee,
id,
quantity,

View File

@ -0,0 +1,194 @@
import * as fs from 'fs';
import * as path from 'path';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class FrontendMiddleware implements NestMiddleware {
public indexHtmlDe = '';
public indexHtmlEn = '';
public indexHtmlEs = '';
public indexHtmlFr = '';
public indexHtmlIt = '';
public indexHtmlNl = '';
public indexHtmlPt = '';
public isProduction: boolean;
public constructor(
private readonly configService: ConfigService,
private readonly configurationService: ConfigurationService
) {
const NODE_ENV =
this.configService.get<'development' | 'production'>('NODE_ENV') ??
'development';
this.isProduction = NODE_ENV === 'production';
try {
this.indexHtmlDe = fs.readFileSync(
this.getPathOfIndexHtmlFile('de'),
'utf8'
);
this.indexHtmlEn = fs.readFileSync(
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
'utf8'
);
this.indexHtmlEs = fs.readFileSync(
this.getPathOfIndexHtmlFile('es'),
'utf8'
);
this.indexHtmlFr = fs.readFileSync(
this.getPathOfIndexHtmlFile('fr'),
'utf8'
);
this.indexHtmlIt = fs.readFileSync(
this.getPathOfIndexHtmlFile('it'),
'utf8'
);
this.indexHtmlNl = fs.readFileSync(
this.getPathOfIndexHtmlFile('nl'),
'utf8'
);
this.indexHtmlPt = fs.readFileSync(
this.getPathOfIndexHtmlFile('pt'),
'utf8'
);
} catch {}
}
public use(request: Request, response: Response, next: NextFunction) {
const currentDate = format(new Date(), DATE_FORMAT);
let featureGraphicPath = 'assets/cover.png';
let title = 'Ghostfolio Open Source Wealth Management Software';
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
title = `500 Stars - ${title}`;
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
title = `Hacktoberfest 2022 - ${title}`;
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
title = `Black Friday 2022 - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
)
) {
featureGraphicPath = 'assets/images/blog/20221226.jpg';
title = `The importance of tracking your personal finances - ${title}`;
}
if (
request.path.startsWith('/api/') ||
this.isFileRequest(request.url) ||
!this.isProduction
) {
// Skip
next();
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
response.send(
this.interpolate(this.indexHtmlDe, {
currentDate,
featureGraphicPath,
title,
languageCode: 'de',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
response.send(
this.interpolate(this.indexHtmlEs, {
currentDate,
featureGraphicPath,
title,
languageCode: 'es',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
response.send(
this.interpolate(this.indexHtmlFr, {
featureGraphicPath,
languageCode: 'fr',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
response.send(
this.interpolate(this.indexHtmlIt, {
currentDate,
featureGraphicPath,
title,
languageCode: 'it',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
response.send(
this.interpolate(this.indexHtmlNl, {
currentDate,
featureGraphicPath,
title,
languageCode: 'nl',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
response.send(
this.interpolate(this.indexHtmlPt, {
featureGraphicPath,
languageCode: 'pt',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else {
response.send(
this.interpolate(this.indexHtmlEn, {
currentDate,
featureGraphicPath,
title,
languageCode: DEFAULT_LANGUAGE_CODE,
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
}
}
private getPathOfIndexHtmlFile(aLocale: string) {
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
}
private interpolate(template: string, context: any) {
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
const properties = objectPath.split('.');
return properties.reduce(
(previous, current) => previous?.[current],
context
);
});
}
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (filename.includes('auth/ey')) {
return false;
}
return filename.split('.').pop() !== filename;
}
}

View File

@ -1,17 +1,26 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Logger,
Param,
Post,
UseGuards
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash';
import { ImportDataDto } from './import-data.dto';
import { ImportService } from './import.service';
@ -26,7 +35,10 @@ export class ImportController {
@Post()
@UseGuards(AuthGuard('jwt'))
public async import(@Body() importData: ImportDataDto): Promise<void> {
public async import(
@Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -34,11 +46,29 @@ export class ImportController {
);
}
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Premium'
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
const userCurrency = this.request.user.Settings.settings.baseCurrency;
try {
return await this.importService.import({
activities: importData.activities,
const activities = await this.importService.import({
maxActivitiesToImport,
isDryRun,
userCurrency,
activitiesDto: importData.activities,
userId: this.request.user.id
});
return { activities };
} catch (error) {
Logger.error(error, ImportController);
@ -51,4 +81,23 @@ export class ImportController {
);
}
}
@Get('dividends/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async gatherDividends(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<ImportResponse> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.importService.getDividends({
dataSource,
symbol,
userCurrency
});
return { activities };
}
}

View File

@ -1,11 +1,14 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
@ -19,9 +22,12 @@ import { ImportService } from './import.service';
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
OrderModule,
PortfolioModule,
PrismaModule,
RedisCacheModule
RedisCacheModule,
SymbolProfileModule
],
providers: [ImportService]
})

View File

@ -1,28 +1,118 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import {
AccountWithPlatform,
OrderWithAccount
} from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { isSameDay, parseISO } from 'date-fns';
import { SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class ImportService {
public constructor(
private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async getDividends({
dataSource,
symbol,
userCurrency
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
try {
const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol);
const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
await this.dataProviderService.getDividends({
dataSource,
symbol,
from: parseDate(firstBuyDate),
granularity: 'day',
to: new Date()
})
]);
const accounts = orders.map((order) => {
return order.Account;
});
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
const quantity =
historicalData.find((historicalDataItem) => {
return historicalDataItem.date === dateString;
})?.quantity ?? 0;
const value = new Big(quantity).mul(marketPrice).toNumber();
return {
Account,
quantity,
value,
accountId: Account?.id,
accountUserId: undefined,
comment: undefined,
createdAt: undefined,
date: parseDate(dateString),
fee: 0,
feeInBaseCurrency: 0,
id: assetProfile.id,
isDraft: false,
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
unitPrice: marketPrice,
updatedAt: undefined,
userId: Account?.userId,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
assetProfile.currency,
userCurrency
)
};
});
} catch {
return [];
}
}
public async import({
activities,
activitiesDto,
isDryRun = false,
maxActivitiesToImport,
userCurrency,
userId
}: {
activities: Partial<CreateOrderDto>[];
activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean;
maxActivitiesToImport: number;
userCurrency: string;
userId: string;
}): Promise<void> {
for (const activity of activities) {
}): Promise<Activity[]> {
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (activity.type === 'ITEM') {
activity.dataSource = 'MANUAL';
@ -32,7 +122,11 @@ export class ImportService {
}
}
await this.validateActivities({ activities, userId });
const assetProfiles = await this.validateActivities({
activitiesDto,
maxActivitiesToImport,
userId
});
const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => {
@ -40,62 +134,138 @@ export class ImportService {
}
);
const activities: Activity[] = [];
for (const {
accountId,
comment,
currency,
dataSource,
date,
date: dateString,
fee,
quantity,
symbol,
type,
unitPrice
} of activities) {
await this.orderService.createOrder({
fee,
quantity,
type,
unitPrice,
userId,
accountId: accountIds.includes(accountId) ? accountId : undefined,
date: parseISO(<string>(<unknown>date)),
SymbolProfile: {
connectOrCreate: {
create: {
currency,
dataSource,
symbol
},
where: {
dataSource_symbol: {
} of activitiesDto) {
const date = parseISO(<string>(<unknown>dateString));
const validatedAccountId = accountIds.includes(accountId)
? accountId
: undefined;
let order: OrderWithAccount;
if (isDryRun) {
order = {
comment,
date,
fee,
quantity,
type,
unitPrice,
userId,
accountId: validatedAccountId,
accountUserId: undefined,
createdAt: new Date(),
id: uuidv4(),
isDraft: isAfter(date, endOfToday()),
SymbolProfile: {
currency,
dataSource,
symbol,
assetClass: null,
assetSubClass: null,
comment: null,
countries: null,
createdAt: undefined,
id: undefined,
name: null,
scraperConfiguration: null,
sectors: null,
symbolMapping: null,
updatedAt: undefined,
url: null,
...assetProfiles[symbol]
},
symbolProfileId: undefined,
updatedAt: new Date()
};
} else {
order = await this.orderService.createOrder({
comment,
date,
fee,
quantity,
type,
unitPrice,
userId,
accountId: validatedAccountId,
SymbolProfile: {
connectOrCreate: {
create: {
currency,
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
}
},
User: { connect: { id: userId } }
},
User: { connect: { id: userId } }
});
}
const value = new Big(quantity).mul(unitPrice).toNumber();
activities.push({
...order,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee,
currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
currency,
userCurrency
)
});
}
return activities;
}
private isUniqueAccount(accounts: AccountWithPlatform[]) {
const uniqueAccountIds = new Set<string>();
for (const account of accounts) {
uniqueAccountIds.add(account.id);
}
return uniqueAccountIds.size === 1;
}
private async validateActivities({
activities,
activitiesDto,
maxActivitiesToImport,
userId
}: {
activities: Partial<CreateOrderDto>[];
activitiesDto: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string;
}) {
if (
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) {
throw new Error(
`Too many activities (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT'
)} at most)`
);
if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const assetProfiles: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
const existingActivities = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' },
@ -105,7 +275,7 @@ export class ImportService {
for (const [
index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
] of activities.entries()) {
] of activitiesDto.entries()) {
const duplicateActivity = existingActivities.find((activity) => {
return (
activity.SymbolProfile.currency === currency &&
@ -124,22 +294,28 @@ export class ImportService {
}
if (dataSource !== 'MANUAL') {
const quotes = await this.dataProviderService.getQuotes([
{ dataSource, symbol }
]);
const assetProfile = (
await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
])
)?.[symbol];
if (quotes[symbol] === undefined) {
if (assetProfile === undefined) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (quotes[symbol].currency !== currency) {
if (assetProfile.currency !== currency) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"`
);
}
assetProfiles[symbol] = assetProfile;
}
}
return assetProfiles;
}
}

View File

@ -1,5 +1,6 @@
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { InfoService } from './info.service';
@ -8,6 +9,7 @@ export class InfoController {
public constructor(private readonly infoService: InfoService) {}
@Get()
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getInfo(): Promise<InfoItem> {
return this.infoService.get();
}

View File

@ -1,3 +1,4 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@ -16,6 +17,7 @@ import { InfoService } from './info.service';
@Module({
controllers: [InfoController],
imports: [
BenchmarkModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,

View File

@ -1,19 +1,23 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DEMO_USER_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE,
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper';
import {
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
@ -21,6 +25,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { subDays } from 'date-fns';
@Injectable()
@ -28,9 +33,9 @@ export class InfoService {
private static CACHE_KEY_STATISTICS = 'STATISTICS';
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
@ -63,6 +68,8 @@ export class InfoService {
} else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
}
globalPermissions.push(permissions.enableFearAndGreedIndex);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
@ -86,6 +93,10 @@ export class InfoService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
globalPermissions.push(permissions.enableSubscription);
info.countriesOfSubscribers =
((await this.propertyService.getByKey(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) as string[]) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
@ -97,6 +108,13 @@ export class InfoService {
)) as string;
}
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (isUserSignupEnabled) {
globalPermissions.push(permissions.createUserAccount);
}
return {
...info,
globalPermissions,
@ -104,6 +122,7 @@ export class InfoService {
platforms,
systemMessage,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
statistics: await this.getStatistics(),
@ -138,10 +157,10 @@ export class InfoService {
});
}
private async countGitHubContributors(): Promise<number> {
private async countDockerHubPulls(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
'GET',
'json',
200,
@ -150,8 +169,33 @@ export class InfoService {
}
);
const contributors = await get();
return contributors?.length;
const { pull_count } = await get();
return pull_count;
} catch (error) {
Logger.error(error, 'InfoService');
return undefined;
}
}
private async countGitHubContributors(): Promise<number> {
try {
const get = bent(
'https://github.com/ghostfolio/ghostfolio',
'GET',
'string',
200,
{}
);
const html = await get();
const $ = cheerio.load(html);
return extractNumberFromString(
$(
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
).text()
);
} catch (error) {
Logger.error(error, 'InfoService');
@ -234,6 +278,8 @@ export class InfoService {
const activeUsers1d = await this.countActiveUsers(1);
const activeUsers30d = await this.countActiveUsers(30);
const newUsers30d = await this.countNewUsers(30);
const dockerHubPulls = await this.countDockerHubPulls();
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
const slackCommunityUsers = await this.countSlackCommunityUsers();
@ -241,6 +287,7 @@ export class InfoService {
statistics = {
activeUsers1d,
activeUsers30d,
dockerHubPulls,
gitHubContributors,
gitHubStargazers,
newUsers30d,
@ -260,14 +307,14 @@ export class InfoService {
return undefined;
}
const stripeConfig = await this.prismaService.property.findUnique({
let subscriptions: Subscription[] = [];
const stripeConfig = (await this.prismaService.property.findUnique({
where: { key: PROPERTY_STRIPE_CONFIG }
});
})) ?? { value: '{}' };
if (stripeConfig) {
return [JSON.parse(stripeConfig.value)];
}
subscriptions = [JSON.parse(stripeConfig.value)];
return [];
return subscriptions;
}
}

View File

@ -0,0 +1,54 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import {
Controller,
Get,
HttpStatus,
Param,
Query,
Res,
UseInterceptors
} from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { Response } from 'express';
import { LogoService } from './logo.service';
@Controller('logo')
export class LogoController {
public constructor(private readonly logoService: LogoService) {}
@Get(':dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getLogoByDataSourceAndSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string,
@Res() response: Response
) {
try {
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
dataSource,
symbol
});
response.contentType('image/png');
response.send(buffer);
} catch {
response.status(HttpStatus.NOT_FOUND).send();
}
}
@Get()
public async getLogoByUrl(
@Query('url') url: string,
@Res() response: Response
) {
try {
const buffer = await this.logoService.getLogoByUrl(url);
response.contentType('image/png');
response.send(buffer);
} catch {
response.status(HttpStatus.NOT_FOUND).send();
}
}
}

View File

@ -0,0 +1,13 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { LogoController } from './logo.controller';
import { LogoService } from './logo.service';
@Module({
controllers: [LogoController],
imports: [ConfigurationModule, SymbolProfileModule],
providers: [LogoService]
})
export class LogoModule {}

View File

@ -0,0 +1,55 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class LogoService {
public constructor(
private readonly symbolProfileService: SymbolProfileService
) {}
public async getLogoByDataSourceAndSymbol({
dataSource,
symbol
}: UniqueAsset) {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return this.getBuffer(assetProfile.url);
}
public async getLogoByUrl(aUrl: string) {
return this.getBuffer(aUrl);
}
private getBuffer(aUrl: string) {
const get = bent(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
'GET',
'buffer',
200,
{
'User-Agent': 'request'
}
);
return get();
}
}

View File

@ -1,30 +1,46 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
import { isString } from 'lodash';
export class CreateOrderDto {
@IsString()
@IsOptional()
@IsString()
accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
@IsEnum(AssetClass, { each: true })
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
@IsEnum(AssetSubClass, { each: true })
assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString()
currency: string;
@IsEnum(DataSource, { each: true })
@IsOptional()
@IsEnum(DataSource, { each: true })
dataSource?: DataSource;
@IsISO8601()
@ -39,6 +55,10 @@ export class CreateOrderDto {
@IsString()
symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsEnum(Type, { each: true })
type: Type;

View File

@ -6,5 +6,6 @@ export interface Activities {
export interface Activity extends OrderWithAccount {
feeInBaseCurrency: number;
value: number;
valueInBaseCurrency: number;
}

View File

@ -1,8 +1,7 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -17,6 +16,7 @@ import {
Param,
Post,
Put,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
@ -34,10 +34,10 @@ import { UpdateOrderDto } from './update-order.dto';
@Controller('order')
export class OrderController {
public constructor(
private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@ -66,35 +66,32 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId
@Headers('impersonation-id') impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<Activities> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const userCurrency = this.request.user.Settings.currency;
const userCurrency = this.request.user.Settings.settings.baseCurrency;
let activities = await this.orderService.getOrders({
const activities = await this.orderService.getOrders({
filters,
userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
activities = nullifyValuesInObjects(activities, [
'fee',
'feeInBaseCurrency',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
]);
}
return { activities };
}

View File

@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -18,6 +19,7 @@ import { OrderService } from './order.service';
controllers: [OrderController],
exports: [OrderService],
imports: [
ApiModule,
CacheModule,
ConfigurationModule,
DataGatheringModule,

View File

@ -16,6 +16,7 @@ import {
DataSource,
Order,
Prisma,
Tag,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js';
@ -71,6 +72,7 @@ export class OrderService {
currency?: string;
dataSource?: DataSource;
symbol?: string;
tags?: Tag[];
userId: string;
}
): Promise<Order> {
@ -80,6 +82,8 @@ export class OrderService {
return account.isDefault === true;
});
const tags = data.tags ?? [];
let Account = {
connect: {
id_userId: {
@ -139,9 +143,15 @@ export class OrderService {
delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
if (!data.comment) {
delete data.comment;
}
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.tags;
delete data.userId;
const orderData: Prisma.OrderCreateInput = data;
@ -150,7 +160,12 @@ export class OrderService {
data: {
...orderData,
Account,
isDraft
isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
}
});
}
@ -174,13 +189,15 @@ export class OrderService {
includeDrafts = false,
types,
userCurrency,
userId
userId,
withExcludedAccounts = false
}: {
filters?: Filter[];
includeDrafts?: boolean;
types?: TypeOfOrder[];
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId };
@ -215,9 +232,10 @@ export class OrderService {
})
},
{
SymbolProfileOverrides: {
is: null
}
OR: [
{ SymbolProfileOverrides: { is: null } },
{ SymbolProfileOverrides: { assetClass: null } }
]
}
]
},
@ -268,24 +286,28 @@ export class OrderService {
},
orderBy: { date: 'asc' }
})
).map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
)
.filter((order) => {
return withExcludedAccounts || order.Account?.isExcluded === false;
})
.map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
return {
...order,
value,
order.SymbolProfile.currency,
userCurrency
)
};
});
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.SymbolProfile.currency,
userCurrency
)
};
});
}
public async updateOrder({
@ -298,6 +320,7 @@ export class OrderService {
currency?: string;
dataSource?: DataSource;
symbol?: string;
tags?: Tag[];
};
where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> {
@ -305,6 +328,12 @@ export class OrderService {
delete data.Account;
}
if (!data.comment) {
data.comment = null;
}
const tags = data.tags ?? [];
let isDraft = false;
if (data.type === 'ITEM') {
@ -331,11 +360,23 @@ export class OrderService {
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.tags;
// Remove existing tags
await this.prismaService.order.update({
data: { tags: { set: [] } },
where
});
return this.prismaService.order.update({
data: {
...data,
isDraft
isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
},
where
});

View File

@ -1,11 +1,20 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
import { isString } from 'lodash';
export class UpdateOrderDto {
@IsOptional()
@ -20,6 +29,13 @@ export class UpdateOrderDto {
@IsOptional()
assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString()
currency: string;
@ -41,6 +57,10 @@ export class UpdateOrderDto {
@IsString()
symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsString()
type: Type;

View File

@ -21,6 +21,17 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 };
case 'BTCUSD':
if (isSameDay(parseDate('2015-01-01'), date)) {
return { marketPrice: 314.25 };
} else if (isSameDay(parseDate('2017-12-31'), date)) {
return { marketPrice: 14156.4 };
} else if (isSameDay(parseDate('2018-01-01'), date)) {
return { marketPrice: 13657.2 };
}
return { marketPrice: 0 };
case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 };

View File

@ -78,6 +78,7 @@ describe('CurrentRateService', () => {
null,
null,
null,
null,
null
);
marketDataService = new MarketDataService(null);

View File

@ -62,6 +62,11 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
expect(currentPositions).toEqual({
@ -91,6 +96,15 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('12.6') }
]);
});
});
});

View File

@ -51,6 +51,11 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-30')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
expect(currentPositions).toEqual({
@ -80,6 +85,14 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('273.2')
});
expect(investments).toEqual([
{ date: '2021-11-30', investment: new Big('273.2') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('273.2') }
]);
});
});
});

View File

@ -0,0 +1,111 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BTCUSD buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2015-01-01',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bitcoin USD',
quantity: new Big(2),
symbol: 'BTCUSD',
type: 'BUY',
unitPrice: new Big(320.43)
},
{
currency: 'CHF',
date: '2017-12-31',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bitcoin USD',
quantity: new Big(1),
symbol: 'BTCUSD',
type: 'SELL',
unitPrice: new Big(14156.4)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2015-01-01')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('13657.2'),
errors: [],
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),
hasErrors: false,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'),
positions: [
{
averagePrice: new Big('320.43'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),
investment: new Big('320.43'),
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'),
marketPrice: 13657.2,
quantity: new Big('1'),
symbol: 'BTCUSD',
transactionCount: 2
}
],
totalInvestment: new Big('320.43')
});
expect(investments).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2017-12-31', investment: new Big('320.43') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2017-12-01', investment: new Big('-14156.4') }
]);
});
});
});

View File

@ -39,6 +39,11 @@ describe('PortfolioCalculator', () => {
new Date()
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
expect(currentPositions).toEqual({
@ -51,6 +56,10 @@ describe('PortfolioCalculator', () => {
positions: [],
totalInvestment: new Big(0)
});
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]);
});
});
});

View File

@ -22,7 +22,7 @@ describe('PortfolioCalculator', () => {
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
it.only('with NOVN.SW buy and sell partially', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
@ -62,6 +62,11 @@ describe('PortfolioCalculator', () => {
parseDate('2022-03-07')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
expect(currentPositions).toEqual({
@ -91,6 +96,16 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('75.80')
});
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-85.73') }
]);
});
});
});

View File

@ -0,0 +1,131 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'SELL',
unitPrice: new Big(85.73)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const chartData = await portfolioCalculator.getChartData(
parseDate('2022-03-07')
);
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
expect(chartData[0]).toEqual({
date: '2022-03-07',
netPerformanceInPercentage: 0,
netPerformance: 0,
totalInvestment: 151.6,
value: 151.6
});
expect(chartData[chartData.length - 1]).toEqual({
date: '2022-04-11',
netPerformanceInPercentage: 13.100263852242744,
netPerformance: 19.86,
totalInvestment: 0,
value: 19.86
});
expect(currentPositions).toEqual({
currentValue: new Big('0'),
errors: [],
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
hasErrors: false,
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
investment: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
marketPrice: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('0')
});
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-171.46') }
]);
});
});
});

View File

@ -2,6 +2,7 @@ import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import { GroupBy } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
@ -14,10 +15,14 @@ import {
format,
isAfter,
isBefore,
isSameDay,
isSameMonth,
isSameYear,
max,
min
min,
set
} from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash';
import { first, flatten, isNumber, last, sortBy } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
@ -164,13 +169,164 @@ export class PortfolioCalculator {
this.transactionPoints = transactionPoints;
}
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) {
public async getChartData(start: Date, end = new Date(Date.now()), step = 1) {
const symbols: { [symbol: string]: boolean } = {};
const transactionPointsBeforeEndDate =
this.transactionPoints?.filter((transactionPoint) => {
return isBefore(parseDate(transactionPoint.date), end);
}) ?? [];
const firstIndex = transactionPointsBeforeEndDate.length;
const dates: Date[] = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
let day = start;
while (isBefore(day, end)) {
dates.push(resetHours(day));
day = addDays(day, step);
}
if (!isSameDay(last(dates), end)) {
dates.push(resetHours(end));
}
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
symbols[item.symbol] = true;
}
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const dateString = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[dateString]) {
marketSymbolMap[dateString] = {};
}
if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[dateString][marketSymbol.symbol] = new Big(
marketSymbol.marketPriceInBaseCurrency
);
}
}
const netPerformanceValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const investmentValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const maxInvestmentValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const totalNetPerformanceValues: { [date: string]: Big } = {};
const totalInvestmentValues: { [date: string]: Big } = {};
const maxTotalInvestmentValues: { [date: string]: Big } = {};
for (const symbol of Object.keys(symbols)) {
const { investmentValues, maxInvestmentValues, netPerformanceValues } =
this.getSymbolMetrics({
end,
marketSymbolMap,
start,
step,
symbol,
isChartMode: true
});
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
investmentValuesBySymbol[symbol] = investmentValues;
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
}
for (const currentDate of dates) {
const dateString = format(currentDate, DATE_FORMAT);
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
totalNetPerformanceValues[dateString] =
totalNetPerformanceValues[dateString] ?? new Big(0);
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
dateString
].add(netPerformanceValuesBySymbol[symbol][dateString]);
}
totalInvestmentValues[dateString] =
totalInvestmentValues[dateString] ?? new Big(0);
maxTotalInvestmentValues[dateString] =
maxTotalInvestmentValues[dateString] ?? new Big(0);
if (investmentValuesBySymbol[symbol]?.[dateString]) {
totalInvestmentValues[dateString] = totalInvestmentValues[
dateString
].add(investmentValuesBySymbol[symbol][dateString]);
}
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
dateString
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
}
}
}
return Object.keys(totalNetPerformanceValues).map((date) => {
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
? 0
: totalNetPerformanceValues[date]
.div(maxTotalInvestmentValues[date])
.mul(100)
.toNumber();
return {
date,
netPerformanceInPercentage,
netPerformance: totalNetPerformanceValues[date].toNumber(),
totalInvestment: totalInvestmentValues[date].toNumber(),
value: totalInvestmentValues[date]
.plus(totalNetPerformanceValues[date])
.toNumber()
};
});
}
public async getCurrentPositions(
start: Date,
end = new Date(Date.now())
): Promise<CurrentPositions> {
const transactionPointsBeforeEndDate =
this.transactionPoints?.filter((transactionPoint) => {
return isBefore(parseDate(transactionPoint.date), end);
}) ?? [];
if (!transactionPointsBeforeEndDate.length) {
return {
currentValue: new Big(0),
hasErrors: false,
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
@ -179,39 +335,38 @@ export class PortfolioCalculator {
}
const lastTransactionPoint =
this.transactionPoints[this.transactionPoints.length - 1];
// use Date.now() to use the mock for today
const today = new Date(Date.now());
transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1];
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length;
let firstIndex = transactionPointsBeforeEndDate.length;
const dates = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
}
for (let i = 0; i < this.transactionPoints.length; i++) {
for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) {
if (
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
!isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) &&
firstTransactionPoint === null
) {
firstTransactionPoint = this.transactionPoints[i];
firstTransactionPoint = transactionPointsBeforeEndDate[i];
firstIndex = i;
}
if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
dates.push(
resetHours(parseDate(transactionPointsBeforeEndDate[i].date))
);
}
}
dates.push(resetHours(today));
dates.push(resetHours(end));
const marketSymbols = await this.currentRateService.getValues({
currencies,
@ -238,7 +393,7 @@ export class PortfolioCalculator {
}
}
const todayString = format(today, DATE_FORMAT);
const endDateString = format(end, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
@ -251,7 +406,7 @@ export class PortfolioCalculator {
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const marketValue = marketSymbolMap[endDateString]?.[item.symbol];
const {
grossPerformance,
@ -261,6 +416,7 @@ export class PortfolioCalculator {
netPerformance,
netPerformancePercentage
} = this.getSymbolMetrics({
end,
marketSymbolMap,
start,
symbol: item.symbol
@ -323,6 +479,67 @@ export class PortfolioCalculator {
});
}
public getInvestmentsByGroup(
groupBy: GroupBy
): { date: string; investment: Big }[] {
if (this.orders.length === 0) {
return [];
}
const investments = [];
let currentDate: Date;
let investmentByGroup = new Big(0);
for (const [index, order] of this.orders.entries()) {
if (
isSameYear(parseDate(order.date), currentDate) &&
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
) {
// Same group: Add up investments
investmentByGroup = investmentByGroup.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
);
} else {
// New group: Store previous group and reset
if (currentDate) {
investments.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup
});
}
currentDate = parseDate(order.date);
investmentByGroup = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
}
if (index === this.orders.length - 1) {
// Store current group (latest order)
investments.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup
});
}
}
return investments;
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
@ -382,30 +599,36 @@ export class PortfolioCalculator {
}
}
let minNetPerformance = new Big(0);
let maxNetPerformance = new Big(0);
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises
);
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
const maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
try {
minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
} catch {}
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
@ -644,14 +867,20 @@ export class PortfolioCalculator {
}
private getSymbolMetrics({
end,
isChartMode = false,
marketSymbolMap,
start,
step = 1,
symbol
}: {
end: Date;
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
symbol: string;
}) {
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
@ -670,13 +899,12 @@ export class PortfolioCalculator {
}
const dateOfFirstTransaction = new Date(first(orders).date);
const endDate = new Date(Date.now());
const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate =
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol];
if (
!unitPriceAtEndDate ||
@ -701,12 +929,11 @@ export class PortfolioCalculator {
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let investmentAtStartDate: Big;
const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0);
let maxTotalInvestment = new Big(0);
let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1);
const netPerformanceValues: { [date: string]: Big } = {};
let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0);
@ -729,7 +956,7 @@ export class PortfolioCalculator {
orders.push({
symbol,
currency: null,
date: format(endDate, DATE_FORMAT),
date: format(end, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'end',
@ -739,6 +966,41 @@ export class PortfolioCalculator {
unitPrice: unitPriceAtEndDate
});
let day = start;
let lastUnitPrice: Big;
if (isChartMode) {
const datesWithOrders = {};
for (const order of orders) {
datesWithOrders[order.date] = true;
}
while (isBefore(day, end)) {
const hasDate = datesWithOrders[format(day, DATE_FORMAT)];
if (!hasDate) {
orders.push({
symbol,
currency: null,
date: format(day, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice:
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
lastUnitPrice
});
}
lastUnitPrice = last(orders).unitPrice;
day = addDays(day, step);
}
}
// Sort orders so that the start and end placeholder order are at the right
// position
orders = sortBy(orders, (order) => {
@ -766,6 +1028,12 @@ export class PortfolioCalculator {
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(i + 1, order.type, order.itemType);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
@ -793,9 +1061,21 @@ export class PortfolioCalculator {
valueAtStartDate = valueOfInvestmentBeforeTransaction;
}
const transactionInvestment = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
const transactionInvestment =
order.type === 'BUY'
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
: totalUnits.gt(0)
? totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(this.getFactor(order.type))
: new Big(0);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
}
totalInvestment = totalInvestment.plus(transactionInvestment);
@ -844,71 +1124,50 @@ export class PortfolioCalculator {
? new Big(0)
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
const newGrossPerformance = valueOfInvestment
.minus(totalInvestmentWithGrossPerformanceFromSell)
.plus(grossPerformanceFromSells);
if (
i > indexOfStartOrder &&
!lastValueOfInvestmentBeforeTransaction
.plus(lastTransactionInvestment)
.eq(0)
) {
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.mul(
new Big(1).plus(grossHoldingPeriodReturn)
);
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(fees.minus(feesAtStartDate))
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(netHoldingPeriodReturn)
);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'totalInvestmentWithGrossPerformanceFromSell',
totalInvestmentWithGrossPerformanceFromSell.toNumber()
);
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
grossPerformance = newGrossPerformance;
lastTransactionInvestment = transactionInvestment;
lastValueOfInvestmentBeforeTransaction =
valueOfInvestmentBeforeTransaction;
if (order.itemType === 'start') {
feesAtStartDate = fees;
grossPerformanceAtStartDate = grossPerformance;
}
if (isChartMode && i > indexOfStartOrder) {
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
investmentValues[order.date] = totalInvestment;
maxInvestmentValues[order.date] = maxTotalInvestment;
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
}
if (i === indexOfEndOrder) {
break;
}
}
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.minus(1);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.minus(1);
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
@ -972,6 +1231,7 @@ export class PortfolioCalculator {
Average price: ${averagePriceAtStartDate.toFixed(
2
)} -> ${averagePriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
@ -986,7 +1246,10 @@ export class PortfolioCalculator {
return {
initialValue,
grossPerformancePercentage,
investmentValues,
maxInvestmentValues,
netPerformancePercentage,
netPerformanceValues,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
grossPerformance: totalGrossPerformance

View File

@ -7,20 +7,22 @@ import {
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import {
Filter,
PortfolioChart,
PortfolioDetails,
PortfolioDividends,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary
PortfolioReport
} from '@ghostfolio/common/interfaces';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import type {
DateRange,
GroupBy,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -30,11 +32,12 @@ import {
Param,
Query,
UseGuards,
UseInterceptors
UseInterceptors,
Version
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ViewMode } from '@prisma/client';
import Big from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
@ -47,6 +50,7 @@ export class PortfolioController {
public constructor(
private readonly accessService: AccessService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService,
@ -56,55 +60,6 @@ export class PortfolioController {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('chart')
@UseGuards(AuthGuard('jwt'))
public async getChart(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioService.getChart(
impersonationId,
range
);
let chartData = historicalDataContainer.items;
let hasError = false;
chartData.forEach((chartDataItem) => {
if (hasNotDefinedValuesInObject(chartDataItem)) {
hasError = true;
}
});
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
let maxValue = 0;
chartData.forEach((portfolioItem) => {
if (portfolioItem.value > maxValue) {
maxValue = portfolioItem.value;
}
});
chartData = chartData.map((historicalDataItem) => {
return {
...historicalDataItem,
marketPrice: Number((historicalDataItem.value / maxValue).toFixed(2))
};
});
}
return {
hasError,
chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow
};
}
@Get('details')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@ -113,48 +68,43 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') range?: DateRange,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> {
let hasDetails = true;
let hasError = false;
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
}
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
range,
filters
);
const {
accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasErrors,
holdings,
summary,
totalValueInBaseCurrency
} = await this.portfolioService.getDetails({
dateRange,
filters,
impersonationId,
userId: this.request.user.id
});
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true;
}
let portfolioSummary = summary;
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
@ -170,7 +120,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user.Settings.currency
this.request.user.Settings.settings.baseCurrency
);
})
.reduce((a, b) => a + b, 0);
@ -190,35 +140,123 @@ export class PortfolioController {
}
}
const isBasicUser =
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic';
if (
hasDetails === false ||
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'dividend',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'items',
'netWorth',
'totalBuy',
'totalSell'
]);
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = {
...portfolioPosition,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined,
sectors: hasDetails ? portfolioPosition.sectors : []
};
}
return {
accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasError,
holdings: isBasicUser ? {} : holdings
holdings,
totalValueInBaseCurrency,
summary: portfolioSummary
};
}
@Get('dividends')
@UseGuards(AuthGuard('jwt'))
public async getDividends(
@Headers('impersonation-id') impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioDividends> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
let dividends = await this.portfolioService.getDividends({
dateRange,
filters,
groupBy,
impersonationId
});
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxDividend = dividends.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
dividends = dividends.map((item) => ({
date: item.date,
investment: item.investment / maxDividend
}));
}
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
dividends = dividends.map((item) => {
return nullifyValuesInObject(item, ['investment']);
});
}
return { dividends };
}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string
@Headers('impersonation-id') impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
let investments = await this.portfolioService.getInvestments(
let investments = await this.portfolioService.getInvestments({
dateRange,
filters,
groupBy,
impersonationId
);
});
if (
impersonationId ||
@ -235,29 +273,81 @@ export class PortfolioController {
}));
}
return { firstOrderDate: parseDate(investments[0]?.date), investments };
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
investments = investments.map((item) => {
return nullifyValuesInObject(item, ['investment']);
});
}
return { investments };
}
@Get('performance')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPerformance(
@Version('2')
public async getPerformanceV2(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioService.getPerformance(
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const performanceInformation = await this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId,
range
);
userId: this.request.user.id
});
if (
impersonationId ||
this.request.user.Settings.viewMode === ViewMode.ZEN ||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
this.userService.isRestrictedView(this.request.user)
) {
performanceInformation.chart = performanceInformation.chart.map(
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
return {
date,
netPerformanceInPercentage,
totalInvestment: new Big(totalInvestment)
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
value: new Big(value)
.div(performanceInformation.performance.currentValue)
.toNumber()
};
}
);
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
['currentGrossPerformance', 'currentValue']
[
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'totalInvestment'
]
);
}
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
performanceInformation.chart = performanceInformation.chart.map(
(item) => {
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
}
);
}
@ -269,12 +359,22 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions(
impersonationId,
range
);
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const result = await this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
if (
impersonationId ||
@ -294,6 +394,7 @@ export class PortfolioController {
}
@Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic(
@Param('accessId') accessId
): Promise<PortfolioPublicDetails> {
@ -314,86 +415,50 @@ export class PortfolioController {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioService.getDetails(
access.userId,
access.userId
);
const { holdings } = await this.portfolioService.getDetails({
dateRange: 'max',
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId,
userId: user.id
});
const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
alias: access.alias,
holdings: {}
};
const totalValue = Object.values(holdings)
.filter((holding) => {
return holding.assetClass === 'EQUITY';
})
.map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user?.Settings?.currency ?? this.baseCurrency
this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency
);
})
.reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
if (portfolioPosition.assetClass === 'EQUITY') {
portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.allocationCurrent,
countries: hasDetails ? portfolioPosition.countries : [],
currency: portfolioPosition.currency,
markets: portfolioPosition.markets,
name: portfolioPosition.name,
sectors: hasDetails ? portfolioPosition.sectors : [],
value: portfolioPosition.value / totalValue
};
}
portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.value / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name,
netPerformancePercent: portfolioPosition.netPerformancePercent,
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
value: portfolioPosition.value / totalValue
};
}
return portfolioPublicDetails;
}
@Get('summary')
@UseGuards(AuthGuard('jwt'))
public async getSummary(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let summary = await this.portfolioService.getSummary(impersonationId);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
summary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'dividend',
'emergencyFund',
'fees',
'items',
'netWorth',
'totalBuy',
'totalSell'
]);
}
return summary;
}
@Get('position/:dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@ -438,16 +503,19 @@ export class PortfolioController {
public async getReport(
@Headers('impersonation-id') impersonationId: string
): Promise<PortfolioReport> {
const report = await this.portfolioService.getReport(impersonationId);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
for (const rule in report.rules) {
if (report.rules[rule]) {
report.rules[rule] = [];
}
}
}
return await this.portfolioService.getReport(impersonationId);
return report;
}
}

View File

@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -22,6 +23,7 @@ import { RulesService } from './rules.service';
exports: [PortfolioService],
imports: [
AccessModule,
ApiModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,7 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CacheModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store';
import { RedisCacheService } from './redis-cache.service';
@ -9,16 +8,18 @@ import { RedisCacheService } from './redis-cache.service';
@Module({
imports: [
CacheModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configurationService: ConfigurationService) => ({
host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT'),
store: redisStore,
ttl: configurationService.get('CACHE_TTL')
})
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => {
return <CacheManagerOptions>{
host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT'),
store: redisStore,
ttl: configurationService.get('CACHE_TTL')
};
}
}),
ConfigurationModule
],

View File

@ -1,6 +1,9 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_COUPONS
} from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -18,6 +21,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service';
@ -59,6 +63,7 @@ export class SubscriptionController {
await this.subscriptionService.createSubscription({
duration: coupon.duration,
price: 0,
userId: this.request.user.id
});
@ -83,9 +88,12 @@ export class SubscriptionController {
}
@Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) {
public async stripeCallback(
@Req() request: Request,
@Res() response: Response
) {
const userId = await this.subscriptionService.createSubscriptionViaStripe(
req.query.checkoutSessionId
<string>request.query.checkoutSessionId
);
Logger.log(
@ -93,7 +101,11 @@ export class SubscriptionController {
'SubscriptionController'
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`
);
}
@Post('stripe/checkout-session')

View File

@ -1,5 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
@ -33,7 +38,9 @@ export class SubscriptionService {
userId: string;
}) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
cancel_url: `${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`,
client_reference_id: userId,
line_items: [
{
@ -67,13 +74,16 @@ export class SubscriptionService {
public async createSubscription({
duration = '1 year',
price,
userId
}: {
duration?: StringValue;
price: number;
userId: string;
}) {
await this.prismaService.subscription.create({
data: {
price,
expiresAt: addMilliseconds(new Date(), ms(duration)),
User: {
connect: {
@ -90,7 +100,21 @@ export class SubscriptionService {
aCheckoutSessionId
);
await this.createSubscription({ userId: session.client_reference_id });
let subscriptions: SubscriptionInterface[] = [];
const stripeConfig = (await this.prismaService.property.findUnique({
where: { key: PROPERTY_STRIPE_CONFIG }
})) ?? { value: '{}' };
subscriptions = [JSON.parse(stripeConfig.value)];
const coupon = subscriptions[0]?.coupon ?? 0;
const price = subscriptions[0]?.price ?? 0;
await this.createSubscription({
price: price - coupon,
userId: session.client_reference_id
});
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id

View File

@ -46,7 +46,6 @@ export class SymbolController {
* Must be after /lookup
*/
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData(
@ -92,10 +91,19 @@ export class SymbolController {
);
}
return this.symbolService.getForDate({
const result = await this.symbolService.getForDate({
dataSource,
date,
symbol
});
if (!result || isEmpty(result)) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return result;
}
}

View File

@ -7,7 +7,6 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface';
@ -32,7 +31,7 @@ export class SymbolService {
]);
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) {
if (dataGatheringItem.dataSource && marketPrice >= 0) {
let historicalData: HistoricalDataItem[] = [];
if (includeHistoricalData > 0) {
@ -65,13 +64,9 @@ export class SymbolService {
public async getForDate({
dataSource,
date,
date = new Date(),
symbol
}: {
dataSource: DataSource;
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
date,

View File

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

View File

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

View File

@ -1,14 +1,49 @@
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
import type {
ColorScheme,
DateRange,
ViewMode
} from '@ghostfolio/common/types';
import {
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class UpdateUserSettingDto {
@IsOptional()
@IsString()
baseCurrency?: string;
@IsString()
@IsOptional()
benchmark?: string;
@IsIn(<ColorScheme[]>['DARK', 'LIGHT'])
@IsOptional()
colorScheme?: ColorScheme;
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
@IsOptional()
dateRange?: DateRange;
@IsNumber()
@IsOptional()
emergencyFund?: number;
@IsBoolean()
@IsOptional()
isExperimentalFeatures?: boolean;
@IsBoolean()
@IsOptional()
isRestrictedView?: boolean;
@IsString()
@IsOptional()
language?: string;
@IsString()
@IsOptional()
locale?: string;
@ -16,4 +51,8 @@ export class UpdateUserSettingDto {
@IsNumber()
@IsOptional()
savingsRate?: number;
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
@IsOptional()
viewMode?: ViewMode;
}

View File

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

View File

@ -1,7 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config';
import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -22,12 +22,10 @@ import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { size } from 'lodash';
import { UserItem } from './interfaces/user-item.interface';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service';
@Controller('user')
@ -71,17 +69,14 @@ export class UserController {
@Post()
public async signupUser(): Promise<UserItem> {
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
const isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (isReadOnlyMode) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (!isUserSignupEnabled) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const hasAdmin = await this.userService.hasAdmin();
@ -103,6 +98,12 @@ export class UserController {
@UseGuards(AuthGuard('jwt'))
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if (
size(data) === 1 &&
(data.benchmark || data.dateRange) &&
this.request.user.role === 'DEMO'
) {
// Allow benchmark or date range change for demo user
} else if (
!hasPermission(
this.request.user.permissions,
permissions.updateUserSettings
@ -130,33 +131,4 @@ export class UserController {
userId: this.request.user.id
});
}
@Put('settings')
@UseGuards(AuthGuard('jwt'))
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
if (
!hasPermission(
this.request.user.permissions,
permissions.updateUserSettings
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const userSettings: UserSettingsParams = {
currency: data.baseCurrency,
userId: this.request.user.id
};
if (
hasPermission(this.request.user.permissions, permissions.updateViewMode)
) {
userSettings.viewMode = data.viewMode;
}
return await this.userService.updateUserSettings(userSettings);
}
}

View File

@ -4,19 +4,20 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import {
User as IUser,
UserSettings,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { Prisma, Role, User, ViewMode } from '@prisma/client';
import { Prisma, Role, User } from '@prisma/client';
import { sortBy } from 'lodash';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
const crypto = require('crypto');
@Injectable()
@ -36,21 +37,14 @@ export class UserService {
}
public async getUser(
{
Account,
alias,
id,
permissions,
Settings,
subscription
}: UserWithSettings,
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const access = await this.prismaService.access.findMany({
include: {
User: true
},
orderBy: { User: { alias: 'asc' } },
orderBy: { alias: 'asc' },
where: { GranteeUser: { id } }
});
let tags = await this.tagService.getByUser(id);
@ -63,23 +57,20 @@ export class UserService {
}
return {
alias,
id,
permissions,
subscription,
tags,
access: access.map((accessItem) => {
return {
alias: accessItem.User.alias,
alias: accessItem.alias,
id: accessItem.id
};
}),
accounts: Account,
settings: {
...(<UserSettings>Settings.settings),
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
}
};
}
@ -97,7 +88,7 @@ export class UserService {
}
public isRestrictedView(aUser: UserWithSettings) {
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
return aUser.Settings.settings.isRestrictedView ?? false;
}
public async user(
@ -106,7 +97,6 @@ export class UserService {
const {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
@ -124,7 +114,6 @@ export class UserService {
const user: UserWithSettings = {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
@ -136,21 +125,35 @@ export class UserService {
};
if (user?.Settings) {
if (!user.Settings.currency) {
// Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY;
if (!user.Settings.settings) {
user.Settings.settings = {};
}
} else if (user) {
// Set default settings if needed
user.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
settings: {},
updatedAt: new Date(),
userId: user?.id,
viewMode: ViewMode.DEFAULT
userId: user?.id
};
}
// Set default value for base currency
if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
(user.Settings.settings as UserSettings).baseCurrency =
UserService.DEFAULT_CURRENCY;
}
// Set default value for date range
(user.Settings.settings as UserSettings).dateRange =
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
? 'max'
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
// Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
@ -158,10 +161,6 @@ export class UserService {
let currentPermissions = getPermissions(user.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
}
@ -235,7 +234,9 @@ export class UserService {
},
Settings: {
create: {
currency: this.baseCurrency
settings: {
currency: this.baseCurrency
}
}
}
}
@ -309,7 +310,7 @@ export class UserService {
userId: string;
userSettings: UserSettings;
}) {
const settings = userSettings as Prisma.JsonObject;
const settings = userSettings as unknown as Prisma.JsonObject;
await this.prismaService.settings.upsert({
create: {
@ -331,33 +332,6 @@ export class UserService {
return;
}
public async updateUserSettings({
currency,
userId,
viewMode
}: UserSettingsParams) {
await this.prismaService.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 = [];

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,7 @@
{
"LUNA1": "Terra",
"UNI1": "Uniswap"
"LUNA2": "Terra",
"SGB1": "Songbird",
"UNI1": "Uniswap",
"UST": "TerraUSD"
}

View File

@ -27,3 +27,40 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
return nullifyValuesInObject(object, keys);
});
}
export function redactAttributes({
object,
options
}: {
object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[];
}): any {
if (!object || !options || !options.length) {
return object;
}
const redactedObject = cloneDeep(object);
for (const option of options) {
if (redactedObject.hasOwnProperty(option.attribute)) {
redactedObject[option.attribute] =
option.valueMap[redactedObject[option.attribute]] ??
option.valueMap['*'] ??
redactedObject[option.attribute];
} else {
// If the attribute is not present on the current object,
// check if it exists on any nested objects
for (const property in redactedObject) {
if (typeof redactedObject[property] === 'object') {
// Recursively call the function on the nested object
redactedObject[property] = redactAttributes({
options,
object: redactedObject[property]
});
}
}
}
}
return redactedObject;
}

View File

@ -1,4 +1,5 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
CallHandler,
ExecutionContext,
@ -12,7 +13,7 @@ import { map } from 'rxjs/operators';
export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor() {}
public constructor(private userService: UserService) {}
public intercept(
context: ExecutionContext,
@ -23,7 +24,10 @@ export class RedactValuesInResponseInterceptor<T>
const request = context.switchToHttp().getRequest();
const hasImpersonationId = !!request.headers?.['impersonation-id'];
if (hasImpersonationId) {
if (
hasImpersonationId ||
this.userService.isRestrictedView(request.user)
) {
if (data.accounts) {
for (const accountId of Object.keys(data.accounts)) {
if (data.accounts[accountId]?.balance !== undefined) {
@ -38,9 +42,45 @@ export class RedactValuesInResponseInterceptor<T>
activity.Account.balance = null;
}
if (activity.comment !== undefined) {
activity.comment = null;
}
if (activity.fee !== undefined) {
activity.fee = null;
}
if (activity.feeInBaseCurrency !== undefined) {
activity.feeInBaseCurrency = null;
}
if (activity.quantity !== undefined) {
activity.quantity = null;
}
if (activity.unitPrice !== undefined) {
activity.unitPrice = null;
}
if (activity.value !== undefined) {
activity.value = null;
}
if (activity.valueInBaseCurrency !== undefined) {
activity.valueInBaseCurrency = null;
}
return activity;
});
}
if (data.filteredValueInBaseCurrency) {
data.filteredValueInBaseCurrency = null;
}
if (data.totalValueInBaseCurrency) {
data.totalValueInBaseCurrency = null;
}
}
return data;

View File

@ -1,3 +1,4 @@
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { encodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
@ -5,6 +6,7 @@ import {
Injectable,
NestInterceptor
} from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@ -27,56 +29,23 @@ export class TransformDataSourceInResponseInterceptor<T>
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
if (data.activities) {
data.activities.map((activity) => {
activity.SymbolProfile.dataSource = encodeDataSource(
activity.SymbolProfile.dataSource
);
return activity;
});
}
if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource);
}
if (data.errors) {
for (const error of data.errors) {
if (error.dataSource) {
error.dataSource = encodeDataSource(error.dataSource);
data = redactAttributes({
options: [
{
attribute: 'dataSource',
valueMap: Object.keys(DataSource).reduce(
(valueMap, dataSource) => {
valueMap[dataSource] = encodeDataSource(
DataSource[dataSource]
);
return valueMap;
},
{}
)
}
}
}
if (data.holdings) {
for (const symbol of Object.keys(data.holdings)) {
if (data.holdings[symbol].dataSource) {
data.holdings[symbol].dataSource = encodeDataSource(
data.holdings[symbol].dataSource
);
}
}
}
if (data.items) {
data.items.map((item) => {
item.dataSource = encodeDataSource(item.dataSource);
return item;
});
}
if (data.positions) {
data.positions.map((position) => {
position.dataSource = encodeDataSource(position.dataSource);
return position;
});
}
if (data.SymbolProfile) {
data.SymbolProfile.dataSource = encodeDataSource(
data.SymbolProfile.dataSource
);
}
],
object: data
});
}
return data;

View File

@ -1,11 +1,24 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService);
const NODE_ENV =
configService.get<'development' | 'production'>('NODE_ENV') ??
'development';
const app = await NestFactory.create(AppModule, {
logger:
NODE_ENV === 'production'
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
});
app.enableCors();
app.enableVersioning({
defaultVersion: '1',
@ -20,11 +33,11 @@ async function bootstrap() {
})
);
const host = process.env.HOST || 'localhost';
const port = process.env.PORT || 3333;
await app.listen(port, host, () => {
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;
await app.listen(PORT, HOST, () => {
logLogo();
Logger.log(`Listening at http://${host}:${port}`);
Logger.log(`Listening at http://${HOST}:${PORT}`);
Logger.log('');
});
}

View File

@ -1,5 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './evaluation-result.interface';

View File

@ -1,3 +0,0 @@
export interface UserSettings {
baseCurrency: string;
}

View File

@ -1,8 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface';

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
@ -20,13 +19,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
if (accounts.length === 1) {
return {
evaluation: `All your investment is managed by a single account`,
evaluation: `Your net worth is managed by a single account`,
value: false
};
}
return {
evaluation: `Your investment is managed by ${accounts.length} accounts`,
evaluation: `Your net worth is managed by ${accounts.length} accounts`,
value: true
};
}

View File

@ -1,14 +1,13 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
private positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Current Investment: Base Currency'
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions,
this.positions,
'currency',
ruleSettings.baseCurrency
);

View File

@ -1,14 +1,13 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
private positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Initial Investment: Base Currency'
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions,
this.positions,
'currency',
ruleSettings.baseCurrency
);

View File

@ -1,14 +1,13 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public constructor(
public exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
protected exchangeRateDataService: ExchangeRateDataService,
private positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Current Investment'
@ -17,7 +16,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions,
this.positions,
'currency',
ruleSettings.baseCurrency
);

View File

@ -1,14 +1,13 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
private positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Initial Investment'
@ -17,7 +16,7 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions,
this.positions,
'currency',
ruleSettings.baseCurrency
);

View File

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

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ApiService } from './api.service';
@Module({
exports: [ApiService],
providers: [ApiService]
})
export class ApiModule {}

View File

@ -0,0 +1,42 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ApiService {
public constructor() {}
public buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
return [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
}
}

View File

@ -12,10 +12,15 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({ default: 'USD' }),
BASE_CURRENCY: str({
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
default: 'USD'
}),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
DATA_SOURCES: json({
default: [DataSource.GHOSTFOLIO, DataSource.MANUAL, DataSource.YAHOO]
}),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
@ -31,12 +36,12 @@ export class ConfigurationService {
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: 'localhost' }),
HOST: host({ default: '0.0.0.0' }),
JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
RAPID_API_API_KEY: str({ default: '' }),
REDIS_HOST: host({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),

View File

@ -11,6 +11,8 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable()
export class CronService {
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
public constructor(
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -28,12 +30,12 @@ export class CronService {
}
@Cron(CronExpression.EVERY_DAY_AT_5PM)
public async runEveryDayAtFivePM() {
public async runEveryDayAtFivePm() {
this.twitterBotService.tweetFearAndGreedIndex();
}
@Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() {
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
public async runEverySundayAtTwelvePm() {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {

View File

@ -10,13 +10,14 @@ import ms from 'ms';
import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { MarketDataModule } from './market-data.module';
import { SymbolProfileModule } from './symbol-profile.module';
@Module({
imports: [
BullModule.registerQueue({
limiter: {
duration: ms('5 seconds'),
duration: ms('4 seconds'),
max: 1
},
name: DATA_GATHERING_QUEUE
@ -25,6 +26,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
DataEnhancerModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
SymbolProfileModule
],

View File

@ -17,6 +17,7 @@ import { DataProviderService } from './data-provider/data-provider.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { MarketDataService } from './market-data.service';
import { PrismaService } from './prisma.service';
@Injectable()
@ -28,6 +29,7 @@ export class DataGatheringService {
private readonly dataGatheringQueue: Queue,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -56,6 +58,8 @@ export class DataGatheringService {
}
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol });
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
@ -276,7 +280,7 @@ export class DataGatheringService {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAKUTEN
dataSource !== DataSource.RAPID_API
);
})
.map(({ dataSource, symbol }) => {

View File

@ -37,6 +37,20 @@ export class AlphaVantageService implements DataProviderInterface {
};
}
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',

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