Compare commits

...

250 Commits

Author SHA1 Message Date
5eff8402db Release/1.258.0 (#1878)
* Release 1.258.0
  * Introduce data source mapping

* Update changelog
2023-04-20 09:07:22 +02:00
ffa020ee2a Release 1.257.0 (#1872) 2023-04-18 20:36:22 +02:00
80a3668aa9 Bugfix/fix world map chart component (#1871)
* Clone countries before manipulation

* Update changelog
2023-04-18 20:32:18 +02:00
7378900050 Bugfix/fix currency inconsistency with GBX and GBp in eod historical data service (#1869)
* Fix currency inconsistency (GBX vs. GBp)

* Update changelog
2023-04-18 20:31:33 +02:00
9be457943c Feature/introduce allocations by etf provider (#1870)
* Introduce allocations by etf provider

* Update changelog
2023-04-18 20:13:03 +02:00
93454c6c15 Release 1.256.0 (#1868) 2023-04-17 14:11:59 +02:00
fccbd76993 Bugfix/fix style of apply current market price button (#1866)
* Fix styles

* Update changelog
2023-04-17 14:09:55 +02:00
922876a893 Feature/add yahoo finance data enhancer (#1865)
* Add Yahoo Finance data enhancer

* Update changelog
2023-04-17 14:09:27 +02:00
654446f068 Feature/improve handling of jobs (#1864)
* Improve handling of jobs
  * Remove jobs on complete
  * Refactor jobs removal

* Update changelog
2023-04-16 19:56:19 +02:00
947460abdd Bugfix/fix ids of gather asset profile process jobs (#1863)
* Fix job ids

* Update changelog
2023-04-16 09:49:27 +02:00
c5635b0050 Release 1.255.0 (#1862) 2023-04-15 16:00:47 +02:00
8a3a6308a3 Feature/make system message expandable (#1861)
* Make system message expandable

* Update changelog
2023-04-15 15:59:04 +02:00
290a07fe79 Feature/upgrade prisma to version 4.12.0 (#1845)
* Update prisma to version 4.12.0

* Update changelog
2023-04-15 15:32:02 +02:00
4c907d56f0 Improve message (#1859) 2023-04-15 15:09:15 +02:00
56b437ca74 Bugfix/improve info message style (#1858)
* Improve info message style

* Update changelog
2023-04-15 15:08:56 +02:00
e23ff33e6f Feature/skip job creation for manual data source without scraper configuration (#1857)
* Skip job creation for MANUAL data source without scraper configuration

* Update changelog
2023-04-15 11:54:47 +02:00
f2d206262e Release 1.254.0 (#1856) 2023-04-14 19:58:49 +02:00
1ed5690b33 Feature/improve queue jobs implementation (#1855)
* Improve queue jobs implementation

* Update changelog
2023-04-14 19:57:23 +02:00
4451514ec5 Release 1.253.0 (#1854) 2023-04-14 07:01:34 +02:00
8f73f85276 Bugfix/fix background color of dialogs in dark mode (#1853)
* Fix background color

* Update changelog
2023-04-13 08:47:19 +02:00
9a5d7b664b Release 1.252.2 (#1852) 2023-04-11 18:06:34 +02:00
7d2d1d971a Feature/deprecate get auth endpoint (#1851)
* Deprecate GET auth endpoint

* Update documentation

* Update changelog
2023-04-11 18:04:18 +02:00
d111493eed Release 1.252.1 (#1849) 2023-04-10 20:52:34 +02:00
e975f92a96 Release 1.252.0 (#1848) 2023-04-10 18:03:02 +02:00
739cb4242d Feature/decrease density of theme (#1846)
* Decrease density

* Update changelog
2023-04-10 17:59:29 +02:00
a37eebc9f1 Feature/upgrade nestjs from version 9.1.4 to 9.4.0 (#1843)
* Upgrade nestjs from version 9.1.4 to 9.4.0

* Update changelog
2023-04-10 13:50:27 +02:00
e92730879e Feature/migrate dialog components to angular material 15 (#1844)
* Migrate MatDialog

* Update changelog
2023-04-10 13:49:53 +02:00
464973f9b0 Feature/migrate form components to angular material 15 (#1842)
* Upgrade @angular/cdk

* Upgrade form components to Angular Material 15

* Update changelog
2023-04-10 10:59:44 +02:00
f6228c099f Migrate MatRadio (#1841) 2023-04-10 09:23:16 +02:00
a57fdfb2bb Feature/migrate slide toggle components to angular material 15 (#1840)
* Upgrade @angular/material

* Change MatSlideToggle to MatCheckbox

* Update changelog
2023-04-09 09:33:36 +02:00
24716f0561 Migrate checkbox and chips components to Angular Material 15 (#1839) 2023-04-09 08:55:09 +02:00
3453100afd Feature/migrate various components to angular material 15 part 2 (#1838)
* Migrate tooltips to Angular Material 15

* Migrate tabs to Angular Material 15
2023-04-09 08:54:32 +02:00
84de2c0c68 Migrate card components to Angular Material 15 (#1837) 2023-04-08 17:08:30 +02:00
1b7b082003 Feature/migrate various components to angular material 15 (#1836)
* Migrate components to Angular Material 15

* Update changelog
2023-04-08 15:33:27 +02:00
1928c2c2cc Release 1.251.0 (#1834) 2023-04-07 19:33:26 +02:00
52e7a7886d Feature/migrate libs components to angular material 15 (#1833)
* Migrate to Angular Material 15

* Update changelog
2023-04-07 17:10:03 +02:00
36298b217e Feature/improve tick abbreviation function (#1828)
* Keep the value if value smaller than 1000

* Update changelog
2023-04-07 17:09:34 +02:00
9bce57894e Feature/increase historical market data gathering to 10 years (#1830)
* Increase historical market data gathering of currency pairs to 10+ years

* Update changelog
2023-04-07 16:35:37 +02:00
9d6bb325cd Improve initialization (#1832) 2023-04-07 16:33:55 +02:00
a5f833c612 Feature/upgrade angular and nx 20230405 (#1826)
* Upgrade angular and Nx

* Update changelog
2023-04-06 19:18:23 +02:00
732b14c6ab Feature/improve activities import for csv files of ibkr (#1824)
* Improve import for csv files by Interactive Brokers

* Update changelog
2023-04-05 20:09:00 +02:00
b74a042da8 Feature/change auth endpoint from get to post (#1823)
* Change auth endpoint from GET to POST
  * Login with security token
  * Login with Internet Identity

* Update changelog
2023-04-05 18:10:29 +02:00
d55c052f57 Feature/improve content of pricing and faq pages (#1822)
* Improve content

* Update changelog
2023-04-03 17:39:32 +02:00
864f585efa Release/1.250.0 (#1821) 2023-04-02 09:47:13 +02:00
6d56146054 Feature/add support for multiple subscription offers (#1818)
* Setup for multiple subscription offers

* Update changelog
2023-04-02 09:44:13 +02:00
2c9f29a3c6 Feature/improve handling of platforms in accounts import (#1820)
* Improve handling of platforms

* Fix issue with pagination

* Update changelog
2023-04-01 10:51:55 +02:00
9bef2e960c Feature/ignore first item in portfolio evolution chart (#1816)
* Ignore first item in portfolio evolution chart

* Update changelog
2023-04-01 10:29:39 +02:00
17b8c41673 Add test file (#1810) 2023-03-30 08:24:44 +02:00
f0afbd7346 Release 1.249.0 (#1815) 2023-03-27 20:38:46 +02:00
5dc7429f6a Feature/add testimonials (#1814)
* Add testimonials

* Update changelog
2023-03-27 20:36:49 +02:00
7b39b32293 Feature/improve allocations page (#1813)
* Improve loading state

* Update changelog
2023-03-26 17:18:48 +02:00
e5b5a9e7e9 Feature/improve language localization for german (#1809)
* Improve language localization

* Update changelog
2023-03-26 17:17:48 +02:00
1f3511368a Feature/always show label in value component (#1812)
* Always show label while loading

* Update changelog
2023-03-26 16:59:52 +02:00
b37df2c84f Bugfix/fix algebraic sign in value component (#1811)
* Fix algebraic sign by resetting member variables

* Update changelog
2023-03-26 16:16:24 +02:00
f92ba54060 Release/1.248.0 (#1808) 2023-03-25 17:45:51 +01:00
a3bbd4030e Improve blog post and add images (#1807) 2023-03-25 17:42:41 +01:00
4b30da2d92 Feature/conditionally hide platform selector (#1805)
* Hide platform selector conditionally

* Update changelog
2023-03-25 16:46:46 +01:00
93d082afbb Feature/add blog post 1000 stars on GitHub (#1804)
* Add blog post: 1000 Stars on GitHub

* Add breadcrumb navigation

* Update changelog
2023-03-25 14:28:06 +01:00
0c85380dbf Feature/refactor portfolio calculator (#1803)
* Refactor chart calculation in portfolio calculator

* Update changelog
2023-03-25 12:20:42 +01:00
fb576376dc Feature/upgrade ng extract i18n merge (#1802)
* Upgrade ng-extract-i18n-merge

* Extract locales

* Update changelog
2023-03-24 17:34:27 +01:00
ff111d4c6c Release 1.247.0 (#1801) 2023-03-23 19:26:01 +01:00
bc6e9a8b68 Bugfix/fix total amount calculation in portfolio evolution chart (#1799)
* Fix total amount calculation

* Update changelog
2023-03-23 19:23:31 +01:00
bd1963ec26 Feature/add asset and asset sub class to search endpoint (#1795)
* Add asset and asset sub class to search endpoint

* Update changelog
2023-03-23 19:11:38 +01:00
a0bec9e97f Feature/remove mail address part 2 (#1800)
* Remove mail address and update Slack url

* Update changelog
2023-03-23 14:14:31 +01:00
c45df20d88 Sort imports (#1797) 2023-03-21 19:34:40 +01:00
fa1d669633 Feature/upgrade prisma to version 4.11.0 (#1798)
* Upgrade prisma to version 4.11.0

* Update changelog
2023-03-20 20:08:08 +01:00
1009b462e9 Feature/add subscription expiration dates to the admin control panel (#1796)
* Add expiration date

* Update changelog
2023-03-19 12:03:47 +01:00
b404858904 Release 1.246.0 (#1794) 2023-03-18 10:36:40 +01:00
7ec033577f Feature/extend trackinsight data enhancer by isin (#1793)
* Extend data enhancer by isin

* Update changelog
2023-03-18 10:34:50 +01:00
c8ca82b803 Feature/extend data source eod historical data by asset class and isin (#1791)
* Extend EodHistoricalDataService

* asset and asset sub class
* isin

* Update changelog
2023-03-18 10:09:11 +01:00
5db2faa17d Bugfix/fix border color in fire calculator (#1792)
* Fix border color

* Update changelog
2023-03-17 19:37:36 +01:00
1605fb8d48 Feature/improve language localization for data gathering (#1790)
* Improve locales

* Update changelog
2023-03-16 20:54:34 +01:00
b6a7804a26 Refactoring (#1784) 2023-03-14 10:46:11 +01:00
de31381fd9 Release 1.245.0 (#1787) 2023-03-12 14:33:00 +01:00
0d92b8d8bb Reduce search requests (#1786) 2023-03-12 14:29:22 +01:00
7c6ff776d9 Feature/add search functionality for eod historical data (#1783)
* Add search functionality for EOD_HISTORICAL_DATA

* Update changelog
2023-03-12 13:13:34 +01:00
e37a34ed6c Feature/improve exchange rate service for specific date (#1785)
* Calculate exchange rate indirectly via base currency

* Update changelog
2023-03-12 12:19:13 +01:00
c4d9c00f92 Feature/upgrade ngx device detector to version 5.0.1 (#1782)
* Upgrade ngx-device-detector to version 5.0.1

* Update changelog
2023-03-12 10:14:35 +01:00
3af8be89e3 Feature/improve usability of fire calculator (#1779)
* Improve usability

* Add debounce
* Persist annualInterestRate
* Partially disable date picker

* Update changelog
2023-03-12 09:55:55 +01:00
0f1db71604 Release 1.244.0 (#1778) 2023-03-09 22:14:28 +01:00
fce9e7fb0c Feature/extend fire calculator by retirement date (#1748)
* Extend fire calculator by retirement date

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-03-09 22:12:01 +01:00
6301c0c21c Clean up (#1767) 2023-03-09 13:33:41 +01:00
30bb484d5a Release 1.243.0 (#1777) 2023-03-08 20:25:27 +01:00
f88ee5e5a0 Feature/improve validation for manual currency (#1769)
* Improve validation

* Update changelog
2023-03-08 20:23:03 +01:00
73b5030972 Remove documentation of BASE_CURRENCY (#1776) 2023-03-08 20:22:39 +01:00
a69a3442ab Feature/add coingecko as default data source (#1775)
* Add CoinGecko as a default data source

* Update changelog
2023-03-08 20:13:53 +01:00
d4dff744b5 Sort imports (#1770) 2023-03-07 22:05:02 +01:00
62c93ad99d Make NODE_ENV optional in production
* Make NODE_ENV optional

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-03-06 21:02:20 +01:00
1e42d6bffa Feature/harmonize axis style of charts (#1768)
* Harmonize axis style

* Update changelog
2023-03-06 19:58:43 +01:00
002ac29f2f Feature/remove environment variable for custom symbols (#1766)
* Remove environment variable

* Update changelog
2023-03-05 09:46:38 +01:00
20ccf389e9 Release 1.242.0 (#1765) 2023-03-04 10:32:51 +01:00
a2f99ed4d2 Feature/upgrade ngx skeleton loader to version 7.0.0 (#1758)
* Upgrade ngx-skeleton-loader to version 7.0.0

* Update changelog
2023-03-04 10:30:15 +01:00
cc6320acfd Feature/simplify database seeding (#1763)
* Simplify database seeding

* Update documentation

* Update changelog
2023-03-04 10:16:16 +01:00
261a0fb0b9 Refactor AuthInterceptor (#1764)
* Refactor AuthInterceptor

* Refactor JwtStrategy
2023-03-04 10:13:04 +01:00
cfc05cce41 Resolve segmentation fault during prisma db migrations (#1761)
* Resolves segmentation fault during prisma db migrations by downgrading Node.js from version 18 to 16

* Update changelog
2023-03-02 19:28:58 +01:00
1f15b70134 Release 1.241.0 (#1760) 2023-03-01 20:53:59 +01:00
a5b49b286d Feature/filter item activities from search results (#1759)
* Filter ITEM activities from search results

* Update changelog
2023-03-01 20:52:12 +01:00
f3333f24da Feature/upgrade stripe dependencies 20230226 (#1755)
* Upgrade Stripe dependencies

* Consider language of user in Stripe checkout

* Update changelog
2023-03-01 20:25:48 +01:00
cad8f0d0e2 Feature/upgrade twitter api v2 to version 1.14.2 (#1754)
* Update twitter-api-v2 to version 1.14.2

* Update changelog
2023-02-27 08:17:13 +01:00
edd3e75730 Release 1.240.0 (#1753) 2023-02-26 17:13:50 +01:00
ab68c2c69a Bugfix/fix feature graphic of umbrel blog post (#1752)
* Fix feature graphic

* Update changelog
2023-02-26 17:12:38 +01:00
cbb95f21a3 Feature/support manual currency for unit price (#1751)
* Support manual currency for unit price

* Update changelog
2023-02-26 17:10:13 +01:00
74d3954335 Release 1.239.0 (#1750) 2023-02-25 20:28:30 +01:00
92449b0369 Feature/remove rimraf (#1739)
* Remove rimraf

* Update changelog
2023-02-25 20:26:56 +01:00
65276483e0 Feature/add umbrel blog post (#1749)
* Add blog post: Ghostfolio meets Umbrel

* Update changelog
2023-02-25 20:14:33 +01:00
dde0d1e465 Add linux/arm/v7 (#1741) 2023-02-25 11:34:22 +01:00
3ad802c6f5 Release 1.238.0 (#1747) 2023-02-25 11:23:05 +01:00
b81377a682 Feature/rename example env file (#1734)
* Rename .env to .env.example

* Ignore .env file

* Update changelog
2023-02-25 11:20:38 +01:00
545180b88f Feature/add reddit and umbrel logos to landing page (#1745)
* Add Reddit and Umbrel logos

* Update changelog
2023-02-25 11:20:04 +01:00
a9819b9e25 Feature/upgrade zone.js to version 0.12.0 (#1740)
* Upgrade zone.js

* Update changelog
2023-02-25 10:33:59 +01:00
897e941e7a Feature/add data provider info to position (#1730)
* Add data provider info

* Update changelog
2023-02-25 10:33:45 +01:00
aef840c2cc Bugfix/fix maximum call stack size exceeded error in value redaction (#1743)
* Bugfix for RangeError: Maximum call stack size exceeded

* Update changelog
2023-02-25 10:15:25 +01:00
80d0638922 Adding Coingecko Data Provider (#1736)
* Adding Coingecko Data Provider

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-02-25 09:45:01 +01:00
494ba36d44 Feature/reset letter spacing in buttons (#1742)
* Reset letter spacing in buttons

* Update changelog
2023-02-24 20:44:20 +01:00
dab9154092 Add support for armv7 processors (#1738)
* Add support for armv7 processors

* Update changelog
2023-02-23 18:09:35 +01:00
cd4a85abbf Improve wording (#1729) 2023-02-21 14:43:21 +01:00
e7977a9fbb Release 1.237.0 (#1733) 2023-02-19 19:26:16 +01:00
684c1e55b0 Bugfix/do not skip manual data source part 2 (#1732)
* Do not skip MANUAL data source

* Update changelog
2023-02-19 19:24:52 +01:00
1ffa831c5c Feature/improve style of symbol search results (#1728)
* Improve style

* Update changelog
2023-02-19 11:20:56 +01:00
40eed0016c Feature/migrate header module to angular material 15 (#1725)
* Migrate GfHeaderModule to Angular Material 15

* Update changelog
2023-02-19 10:36:52 +01:00
b58631083b Increase file size limit for imports (#1726)
* Increase file size limit for imports

* Update changelog
2023-02-19 10:09:13 +01:00
e0c0425d21 Add development guide (#1722) 2023-02-19 10:06:52 +01:00
bf2de5d572 Feature/add support to pricing page (#1723)
* Add support

* Update changelog
2023-02-19 10:02:46 +01:00
2b4a1dc480 Bugfix/fix issue with exact matches in activities filter (#1724)
* Fix issue with exact match

* Update changelog
2023-02-19 10:01:51 +01:00
ce022c024f Feature/upgrade nx to version 15.7.2 (#1721)
* Upgrade Angular and Nx

* Update changelog
2023-02-18 19:34:06 +01:00
0f4bf529d8 Handle impersonation mode with guard (#1714) 2023-02-18 14:59:38 +01:00
dad6bf7095 Release 1.236.0 (#1720) 2023-02-17 19:23:04 +01:00
86ca9eaae6 Bugfix/fix logout url in development (#1715)
* Add language

* Update changelog
2023-02-17 19:20:58 +01:00
9d9b805b0e Bugfix/do not skip manual data source (#1718)
* Do not skip MANUAL data source

* Update changelog
2023-02-17 19:20:14 +01:00
851401be1e Feature/remove ghostfolio as data source type (#1717)
* Remove GHOSTFOLIO

* Update changelog
2023-02-17 19:19:16 +01:00
85052bc9bc Bugfix/fix buying power calculation with emergency fund tag (#1713)
* Fix buying power calculation

* Update changelog
2023-02-17 17:29:48 +01:00
bff09f529d Feature/beautify etf names in asset profiles (#1709)
* Beautify ETF names

* Update changelog
2023-02-17 11:20:46 +01:00
f438458687 Release 1.235.0 (#1708) 2023-02-16 17:20:22 +01:00
7125b12631 Feature/improve styles on about page (#1707)
* Improve styles

* Update changelog
2023-02-16 17:17:30 +01:00
0cbf275a2e Feature/eliminate ghostfolio scraper api service (#1706)
* Eliminate GhostfolioScraperApiService

* Update changelog
2023-02-16 16:25:23 +01:00
0ec50819f5 Release 1.234.0 (#1703) 2023-02-15 10:52:02 +01:00
c9abe818bc Revert import (#1702) 2023-02-15 10:50:19 +01:00
bfa32537a8 Feature/improve usability of import activities action (#1695)
* Improve usability of import activities action

* Update changelog
2023-02-15 10:07:25 +01:00
cef15afab8 Add styling (#1701) 2023-02-15 10:01:35 +01:00
1b9587c454 Update default coupon duration (#1700) 2023-02-15 10:00:04 +01:00
de76b0d8c3 Feature/add data import and export to pricing page (#1697)
* Add data import and export

* Update changelog
2023-02-15 09:52:09 +01:00
e62989c981 Feature/copy logic of ghostfolio scraper api service to manual service (#1691)
* Copy logic of GhostfolioScraperApiService to ManualService

* Update changelog
2023-02-15 09:50:31 +01:00
d6b71e6314 Bugfix/fix links in subscription interstitial dialog (#1696)
* Fix links

* Update changelog
2023-02-14 18:25:12 +01:00
8c59bfd6d7 Feature/upgrade prisma to version 4.10.1 (#1688)
* Upgrade prisma to version 4.10.1

* Update changelog
2023-02-14 11:35:04 +01:00
f32df73256 Feature/migrate pages to angular material 15 (#1689)
* Migrate to Angular Material 15

* Update changelog
2023-02-14 10:04:22 +01:00
9d03a8002c Feature/improve content of faq and landing page (#1687)
* Conditionally show content

* Update changelog
2023-02-13 09:41:25 +01:00
3c36ca29af Feature/upgrade ionicons to version 6.1.2 (#1676)
* Upgrade ionicons to version 6.1.2

* Update changelog
2023-02-12 10:08:04 +01:00
efed7e3c2b Modify default exposed port (#1681)
* Modify default exposed port

* Update changelog
2023-02-11 10:37:44 +01:00
b09d3cea95 Fix landing page by setting a default value for countriesOfSubscribers
* Set default value for countriesOfSubscribers

* Update changelog
2023-02-11 09:57:27 +01:00
eabd2f3934 Add url (#1683) 2023-02-11 09:55:03 +01:00
cc184c2827 Feature/upgrade prettier to version 2.8.4 (#1675)
* Upgrade prettier to version 2.8.4

* Update changelog
2023-02-10 09:27:26 +01:00
436f791fa4 Feature/upgrade chart.js to version 4.2.0 (#1567)
* Upgrade chart.js to version 4.2.0

* Update changelog
2023-02-09 21:22:55 +01:00
e935a57dec Release 1.233.0 (#1678) 2023-02-09 20:30:53 +01:00
203909d917 Feature/upgrade eslint dependencies (#1674)
* Upgrade eslint dependencies

* Update changelog
2023-02-09 10:22:50 +01:00
eed4f57f30 Clean up (#1669) 2023-02-09 09:59:29 +01:00
7878036bac Feature/remove google play badge from landing page (#1672)
* Remove Google Play badge

* Update changelog
2023-02-08 14:17:49 +01:00
75d140b436 Harmonize file name (#1662) 2023-02-07 08:44:22 +01:00
a79f31b006 Feature/add accounts import export (#1635)
* Add accounts to activities export

* Add logic for importing accounts

* Update changelog
2023-02-06 21:59:59 +01:00
45cfd61dbb Feature/improve styling in admin control panel (#1665)
* Improve styling

* Update changelog
2023-02-06 11:35:56 +01:00
7fcfca952e Release 1.232.0 (#1664) 2023-02-05 19:46:18 +01:00
279f16cc67 Feature/extract locales 20230205 (#1663)
* Extract locales

* Update changelog
2023-02-05 19:44:33 +01:00
e7b1d8a5d3 Feature/upgrade ngx markdown to version 15.1.0 (#1657)
* Upgrade ngx-markdown to version 15.1.0

* Update changelog
2023-02-05 18:57:35 +01:00
1b2f8e5586 Feature/extend analytics by country (#1661)
* Extend analytics by country

* Fix Upgrade Plan button of subscription interstitial

* Update changelog
2023-02-05 18:57:12 +01:00
e4468252c6 Feature/upgrade ng extract i18n merge to version 2.5.0 (#1656)
* Upgrade ng-extract-i18n-merge to version 2.5.0

* Update changelog
2023-02-05 11:44:06 +01:00
ad3ebd42bb Feature/migrate mat suffix to angular material 15 (#1655)
* Migrate matSuffix to @angular/material 15

* Update changelog
2023-02-05 09:49:37 +01:00
55b03733f4 Release 1.231.0 (#1654) 2023-02-04 11:28:48 +01:00
0000317041 Upgrade Nx and Angular (#1646)
* Upgrade Nx and Angular

* Update changelog

* Feature/eliminate angular material css vars (#1648)

* Eliminate angular-material-css-vars

* Update changelog
2023-02-04 11:26:06 +01:00
e5f2a3865d Update types of node and papaparse (#1650) 2023-02-03 13:27:59 +01:00
c61561664f Relax validation for REDIS_HOST (#1652)
* Relax validation for REDIS_HOST

* Update changelog
2023-02-02 13:39:13 +01:00
a7d8a63ab8 Remove year (#1649) 2023-02-01 08:58:17 +01:00
5c51c1e825 Reorder features (#1644) 2023-01-30 20:22:03 +01:00
3a67bf9bb4 Add /de/pricing and /de/blog (#1645) 2023-01-30 20:21:37 +01:00
f7597c213d Feature/add dividend and fees to position detail dialog (#1643)
* Add dividend and fees to position detail dialog

* Update changelog
2023-01-30 20:21:16 +01:00
2e7f46ad78 Feature/allow account for activity of type item (#1641)
* Support linking wealth items to account

* Update changelog
2023-01-30 20:00:07 +01:00
cfffb99f52 Feature/extract locales 20230129 (#1642)
* Improve translations

* Update changelog
2023-01-30 19:45:27 +01:00
69ac3408f1 Release 1.230.0 (#1639) 2023-01-29 10:00:28 +01:00
e1806b4bd8 Feature/add sourceforge logo to landing page (#1638)
* Add SourceForge

* Update changelog
2023-01-29 09:58:53 +01:00
6aae0cc1e4 Bugfix/fix issue with value in value redaction interceptor (#1627)
* Fix issue with value in value redaction interceptor

* Fix format of world map

* Update changelog
2023-01-29 09:58:05 +01:00
5d8a50a80d Feature/add interstitial for subscription (#1637)
* Add interstitial

* Improve pricing page

* Update changelog
2023-01-28 09:42:15 +01:00
662231e830 Feature/upgrade prisma to version 4.9.0 (#1630)
* Upgrade prisma to version 4.9.0

* Update changelog
2023-01-28 09:41:33 +01:00
4d84459b5b Clean up imports (#1632) 2023-01-27 08:49:31 +01:00
efba7429c1 Bugfix/fix click of unknown accounts (#1629)
* Check for unknown key

* Update changelog
2023-01-25 12:00:52 +01:00
9cae5a3e79 Feature/improve sackgeld.com blog post (#1628)
* Improve blog post and add quote

* Update changelog
2023-01-24 08:11:21 +01:00
c2ed0a436f Downgrade to Node.js 16 (for development) (#1633) 2023-01-23 21:52:46 +01:00
8486c02575 Update Node to version 18.x (#1595)
* Update Node to version 18.x

* Add .nvmrc

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-01-23 08:28:19 +01:00
5122ef3456 Release 1.229.0 (#1626) 2023-01-21 16:10:04 +01:00
579b86665e Feature/add sackgeld.com blog post (#1625)
* Add blog post: Ghostfolio auf Sackgeld.com vorgestellt

* Update changelog
2023-01-21 16:07:32 +01:00
52b3ad6dc3 Feature/refactor value redaction interceptor (#1624)
* Reuse redactAttributes()

* Update changelog
2023-01-21 11:46:56 +01:00
bf9b60aa74 Feature/add sackgeld.com to as seen in section (#1622)
* Add Sackgeld.com

* Update changelog
2023-01-21 11:31:52 +01:00
6cd51fb044 Feature/hide irrelevant errors in client (#1623)
* Hide irrelevant errors in client

* Update changelog
2023-01-21 11:30:53 +01:00
271001f523 Feature/remove toggle on allocations page (#1620)
* Rename allocationCurrent, remove allocationInvestment

* Update changelog
2023-01-21 09:52:58 +01:00
a7e513a6d1 Bugfix/fix filtered value for emergency fund (#1619)
* Fix filtered value

* Update changelog
2023-01-20 20:51:08 +01:00
b5f256be95 Remove mail address (#1621) 2023-01-20 20:50:32 +01:00
a834ef6b4c Release 1.228.1 (#1617) 2023-01-18 22:01:59 +01:00
e5bd0d1bfa Release 1.228.0 (#1616) 2023-01-18 21:39:11 +01:00
7fa6eda45d Feature/remove emergency fund as asset class (#1615)
* Remove emergency fund as asset class

* Update changelog
2023-01-18 21:37:22 +01:00
f47e4d3b04 Clean up imports (#1613) 2023-01-17 20:10:08 +01:00
0300c6f3b7 Feature/extend hints on account page (#1609)
* Extend hints

* Update changelog
2023-01-17 20:09:50 +01:00
4865c45fd4 Feature/reduce data gathering interval to every four hours (#1611)
* Reduce execution interval

* Update changelog
2023-01-17 10:04:03 +01:00
2beceb36cf Overriding tooltip title for graphs where grouping is defined (#1605)
* Overriding tooltip title for graphs where grouping is defined

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-01-16 10:46:48 +01:00
cd64601482 Release 1.227.1 (#1608) 2023-01-14 19:12:18 +01:00
efac39eb51 Feature/extract locales 20230114 (#1607)
* Extract locales

* Update changelog
2023-01-14 19:11:14 +01:00
4da8a547ca Bugfix/fix create activity dialog caused by missing tags (#1606)
* Add guard

* Update changelog
2023-01-14 19:10:28 +01:00
9e8a9e4670 Release 1.227.0 (#1603) 2023-01-14 13:37:06 +01:00
bb99141e9c Fix group by month/year not working for YTD (#1598)
* Set time to 00:00:00 when getting current timestamp

* Update changelog
2023-01-14 13:30:51 +01:00
d147c2313f Feature/support assets in emergency fund (#1601)
* Support assets in emergency fund

* Update changelog
2023-01-14 13:13:26 +01:00
0878941c4f Feature/support translated tags (#1600)
* Support translated tags

* Update changelog
2023-01-14 12:36:30 +01:00
69a9e77820 Feature/improve logo alignment (#1599)
* Improve logo alignment

* Update changelog
2023-01-14 12:22:52 +01:00
104cca069f New Translations (#1597) 2023-01-13 16:19:48 +01:00
7ad58b1a62 Add i18n (#1594) 2023-01-13 08:58:13 +01:00
e88dbb0181 Fix wording (#1593) 2023-01-12 20:14:00 +01:00
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
368 changed files with 28411 additions and 8376 deletions

View File

View File

@ -41,7 +41,7 @@ jobs:
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.output.labels }}

3
.gitignore vendored
View File

@ -25,6 +25,7 @@
# misc
/.angular/cache
.env
.env.prod
/.sass-cache
/connect.lock
@ -38,4 +39,4 @@ yarn-error.log
# System Files
.DS_Store
Thumbs.db
Thumbs.db

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v16

View File

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

View File

@ -5,6 +5,544 @@ 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.258.0 - 2023-04-20
### Added
- Introduced a data source mapping
## 1.257.0 - 2023-04-18
### Added
- Introduced the allocations by ETF provider chart on the allocations page
### Fixed
- Fixed an issue in the global heat map component caused by manipulating an input property
- Fixed an issue with the currency inconsistency in the _EOD Historical Data_ service (convert from `GBX` to `GBp`)
## 1.256.0 - 2023-04-17
### Added
- Added the _Yahoo Finance_ data enhancer for countries, sectors and urls
### Changed
- Enabled the configuration to immediately remove queue jobs on complete
- Refactored the implementation of removing queue jobs
### Fixed
- Fixed the unique job ids of the gather asset profile process
- Fixed the style of the button to fetch the current market price
## 1.255.0 - 2023-04-15
### Added
- Made the system message expandable
### Changed
- Skipped creating queue jobs for asset profiles with `MANUAL` data source not having a scraper configuration
- Reduced the execution interval of the data gathering to every hour
- Upgraded `prisma` from version `4.11.0` to `4.12.0`
### Fixed
- Improved the style of the system message
## 1.254.0 - 2023-04-14
### Changed
- Improved the queue jobs implementation by adding in bulk
- Improved the queue jobs implementation by introducing unique job ids
- Reverted the execution interval of the data gathering from every 12 hours to every 4 hours
## 1.253.0 - 2023-04-14
### Changed
- Reduced the execution interval of the data gathering to every 12 hours
### Fixed
- Fixed the background color of dialogs in dark mode
## 1.252.2 - 2023-04-11
### Changed
- Deprecated the `auth` endpoint of the login with _Security Token_ (`GET`)
## 1.252.1 - 2023-04-10
### Changed
- Changed the slide toggles to checkboxes on the account page
- Changed the slide toggles to checkboxes in the admin control panel
- Decreased the density of the theme
- Migrated the style of various components to `@angular/material` `15` (mdc)
- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6`
- Upgraded `bull` from version `4.10.2` to `4.10.4`
## 1.251.0 - 2023-04-07
### Changed
- Improved the activities import for `csv` files exported by _Interactive Brokers_
- Improved the rendering of the chart ticks (`0.5K``500`)
- Increased the historical market data gathering of currency pairs to 10+ years
- Improved the content of the Frequently Asked Questions (FAQ) page
- Improved the content of the pricing page
- Changed the `auth` endpoint of the login with _Security Token_ from `GET` to `POST`
- Changed the `auth` endpoint of the _Internet Identity_ login provider from `GET` to `POST`
- Migrated the style of the `libs` components to `@angular/material` `15` (mdc)
- `ActivitiesFilterComponent`
- `ActivitiesTableComponent`
- `BenchmarkComponent`
- `HoldingsTableComponent`
- Upgraded `angular` from version `15.1.5` to `15.2.5`
- Upgraded `Nx` from version `15.7.2` to `15.9.2`
## 1.250.0 - 2023-04-02
### Added
- Added support for multiple subscription offers
### Changed
- Improved the portfolio evolution chart (ignore first item)
- Improved the accounts import by handling the platform
### Fixed
- Fixed an issue with more than 50 activities in the activities import (`dryRun`)
## 1.249.0 - 2023-03-27
### Added
- Extended the testimonial section on the landing page
### Changed
- Improved the loading state of the value component on the allocations page
- Improved the value component by always showing the label (also while loading)
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue with the algebraic sign in the value component
## 1.248.0 - 2023-03-25
### Added
- Added a blog post: _Ghostfolio reaches 1000 Stars on GitHub_
- Added a breadcrumb navigation to the blog post pages
### Changed
- Refactored the calculation of the chart
- Hid the platform selector if no platforms are available in the create or update account dialog
- Upgraded `ng-extract-i18n-merge` from version `2.5.0` to `2.6.0`
## 1.247.0 - 2023-03-23
### Added
- Added the asset and asset sub class to the search functionality
- Added the subscription expiration date to the users table of the admin control panel
### Changed
- Updated the URL of the Ghostfolio Slack channel
- Upgraded `prisma` from version `4.10.1` to `4.11.0`
### Fixed
- Fixed the total amount calculation in the portfolio evolution chart
## 1.246.0 - 2023-03-18
### Added
- Added support for asset and asset sub class to the `EOD_HISTORICAL_DATA` data source type
- Added `isin` to the asset profile model
### Changed
- Extended the _Trackinsight_ data enhancer for asset profile data by `isin`
- Improved the language localization for _Gather Data_
### Fixed
- Fixed the border color in the _FIRE_ calculator (dark mode)
## 1.245.0 - 2023-03-12
### Added
- Added the search functionality for the `EOD_HISTORICAL_DATA` data source type
### Changed
- Improved the usability of the _FIRE_ calculator
- Improved the exchange rate service for a specific date used in activities with a manual currency
- Upgraded `ngx-device-detector` from version `3.0.0` to `5.0.1`
## 1.244.0 - 2023-03-09
### Added
- Extended the _FIRE_ calculator by a retirement date setting
## 1.243.0 - 2023-03-08
### Added
- Added `COINGECKO` as a default to `DATA_SOURCES`
### Changed
- Improved the validation of the manual currency for the activity fee and unit price
- Harmonized the axis style of charts
- Made setting `NODE_ENV: production` optional (to avoid `ENOENT: no such file or directory` errors on startup)
- Removed the environment variable `ENABLE_FEATURE_CUSTOM_SYMBOLS`
## 1.242.0 - 2023-03-04
### Changed
- Simplified the database seeding
- Upgraded `ngx-skeleton-loader` from version `5.0.0` to `7.0.0`
### Fixed
- Downgraded `Node.js` from version `18` to `16` (Dockerfile) to resolve `SIGSEGV` (segmentation fault) during the `prisma` database migrations (see https://github.com/prisma/prisma/issues/10649)
## 1.241.0 - 2023-03-01
### Changed
- Filtered activities with type `ITEM` from search results
- Considered the user's language in the _Stripe_ checkout
- Upgraded the _Stripe_ dependencies
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
## 1.240.0 - 2023-02-26
### Added
- Supported a manual currency for the activity unit price
### Fixed
- Fixed the feature graphic of the _Ghostfolio meets Umbrel_ blog post
## 1.239.0 - 2023-02-25
### Added
- Added a blog post: _Ghostfolio meets Umbrel_
### Changed
- Removed the dependency `rimraf`
## 1.238.0 - 2023-02-25
### Added
- Added `COINGECKO` as a new data source type
- Added support for data provider information to the position detail dialog
- Added the configuration to publish a `linux/arm/v7` docker image
- Added _Reddit_ to the _As seen in_ section on the landing page
- Added _Umbrel_ to the _As seen in_ section on the landing page
### Changed
- Renamed the example environment variable file from `.env` to `.env.example`
- Upgraded `zone.js` from version `0.11.8` to `0.12.0`
### Fixed
- Fixed `RangeError: Maximum call stack size exceeded` for values of type `Big` in the value redaction interceptor for the impersonation mode
- Reset the letter spacing in buttons
### Todo
- Ensure that you still have a `.env` file in your project
## 1.237.0 - 2023-02-19
### Added
- Added the support details to the pricing page
### Changed
- Increased the file size limit for the activities import
- Improved the style of the search results for symbols
- Migrated the style of `GfHeaderModule` to `@angular/material` `15` (mdc)
- Upgraded `angular` from version `15.1.2` to `15.1.5`
- Upgraded `Nx` from version `15.6.3` to `15.7.2`
### Fixed
- Fixed an issue with exact matches in the activities table filter (`VT` vs. `VTI`)
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
## 1.236.0 - 2023-02-17
### Changed
- Beautified the ETF names in the asset profile
- Removed the data source type `GHOSTFOLIO`
### Fixed
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
- Fixed the buying power calculation if no emergency fund is set but an activity is tagged as _Emergency Fund_
- Fixed the url on logout during the local development
## 1.235.0 - 2023-02-16
### Changed
- Improved the styles on the about page
- Eliminated the `GhostfolioScraperApiService`
## 1.234.0 - 2023-02-15
### Added
- Added the data import and export feature to the pricing page
### Changed
- Copy the logic of `GhostfolioScraperApiService` to `ManualService`
- Improved the content of the landing page
- Improved the content of the Frequently Asked Questions (FAQ) page
- Improved the usability of the _Import Activities..._ action
- Eliminated the permission `enableImport`
- Set the exposed port as an environment variable (`PORT`) in `Dockerfile`
- Migrated the style of `AboutPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `BlogPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `ChangelogPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `ResourcesPageModule` to `@angular/material` `15` (mdc)
- Upgraded `chart.js` from version `4.0.1` to `4.2.0`
- Upgraded `ionicons` from version `6.0.4` to `6.1.2`
- Upgraded `prettier` from version `2.8.1` to `2.8.4`
- Upgraded `prisma` from version `4.9.0` to `4.10.1`
### Fixed
- Fixed an issue on the landing page caused by the global heat map of subscribers
- Fixed the links in the interstitial for the subscription
### Todo
- Remove the environment variable `ENABLE_FEATURE_IMPORT`
- Rename the `dataSource` from `GHOSTFOLIO` to `MANUAL`
- Eliminate `GhostfolioScraperApiService`
## 1.233.0 - 2023-02-09
### Added
- Added support to export accounts
- Added support to import accounts
### Changed
- Improved the styling in the admin control panel
- Removed the _Google Play_ badge from the landing page
- Upgraded `eslint` dependencies
## 1.232.0 - 2023-02-05
### Changed
- Improved the language localization for German (`de`)
- Migrated the style of `ActivitiesPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `GfCreateOrUpdateActivityDialogModule` to `@angular/material` `15` (mdc)
- Migrated the style of `GfMarketDataDetailDialogModule` to `@angular/material` `15` (mdc)
- Upgraded `ng-extract-i18n-merge` from version `2.1.2` to `2.5.0`
- Upgraded `ngx-markdown` from version `14.0.1` to `15.1.0`
### Fixed
- Fixed the `Upgrade Plan` button of the interstitial for the subscription
## 1.231.0 - 2023-02-04
### Added
- Added the dividend and fees to the position detail dialog
- Added support to link a (wealth) item to an account
### Changed
- Relaxed the validation rule of the _Redis_ host environment variable (`REDIS_HOST`)
- Improved the language localization for German (`de`)
- Eliminated `angular-material-css-vars`
- Upgraded `angular` from version `14.2.0` to `15.1.2`
- Upgraded `Nx` from version `15.0.13` to `15.6.3`
## 1.230.0 - 2023-01-29
### Added
- Added an interstitial for the subscription
- Added _SourceForge_ to the _As seen in_ section on the landing page
- Added a quote to the blog post _Ghostfolio auf Sackgeld.com vorgestellt_
### Changed
- Improved the unit format (`%`) in the global heat map component of the public page
- Improved the pricing page
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
- Upgraded `prisma` from version `4.8.0` to `4.9.0`
### Fixed
- Fixed the click of unknown accounts in the portfolio proportion chart component
- Fixed an issue with `value` in the value redaction interceptor for the impersonation mode
## 1.229.0 - 2023-01-21
### Added
- Added a blog post: _Ghostfolio auf Sackgeld.com vorgestellt_
- Added _Sackgeld.com_ to the _As seen in_ section on the landing page
### Changed
- Removed the toggle _Original Shares_ vs. _Current Shares_ on the allocations page
- Hid error messages related to no current investment in the client
- Refactored the value redaction interceptor for the impersonation mode
### Fixed
- Fixed the value of the active (emergency fund) filter in percentage on the allocations page
## 1.228.1 - 2023-01-18
### Added
- Extended the hints in user settings
### Changed
- Improved the date formatting in the tooltip of the dividend timeline grouped by month / year
- Improved the date formatting in the tooltip of the investment timeline grouped by month / year
- Reduced the execution interval of the data gathering to every 4 hours
- Removed emergency fund as an asset class
## 1.227.1 - 2023-01-14
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed the create or edit activity dialog
## 1.227.0 - 2023-01-14
### Added
- Added support for assets other than cash in emergency fund (affecting buying power)
- Added support for translated tags
### Changed
- Improved the logo alignment
### Fixed
- Fixed the grouping by month / year of the dividend and investment timeline
## 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
@ -493,7 +1031,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the alias to the `Access` database schema
- Added support for translated time distances
- Added a _GitHub Action_ to create an `arm64` docker image
- Added a _GitHub Action_ to create an `linux/arm64` docker image
### Changed
@ -1106,7 +1644,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Beautified the ETF names in the symbol profile
- Beautified the ETF names in the asset profile
### Fixed
@ -1531,7 +2069,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Extended the historical data view in the admin control panel
- Upgraded _Stripe_ dependencies
- Upgraded the _Stripe_ dependencies
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
### Fixed

25
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,25 @@
# Ghostfolio Development Guide
## Git
### Rebase
`git rebase -i --autosquash main`
## Dependencies
### Nx
#### Upgrade
1. Run `yarn nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn install`
1. Run `yarn nx migrate --run-migrations`
### Prisma
#### Create schema migration (local)
Run `yarn prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate

View File

@ -57,5 +57,5 @@ RUN apt update && apt install -y \
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api
EXPOSE 3333
EXPOSE ${PORT:-3333}
CMD [ "yarn", "start:prod" ]

132
README.md
View File

@ -1,34 +1,26 @@
<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>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/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="https://www.buymeacoffee.com/ghostfolio">
<img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee"/></a>
<a href="#contributing">
<img src="https://img.shields.io/badge/Contributions-Welcome-orange.svg"/></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. The software is designed for personal use in continuous operation.
<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 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>
## Ghostfolio Premium
@ -48,7 +40,7 @@ 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
- 😎 still reading this list
## Features
@ -63,8 +55,10 @@ Ghostfolio is for you if you are...
- ✅ Zen Mode
- ✅ Progressive Web App (PWA) with a mobile-first design
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
<div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
</div>
## Technology Stack
@ -81,34 +75,29 @@ 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`.
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
<div align="center">
<a href="https://www.buymeacoffee.com/ghostfolio">
<img
alt="Buy me a coffee button"
src="./apps/client/src/assets/images/button-buy-me-a-coffee.png"
width="150"
/>
</a>
[<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 |
| Name | Default Value | Description |
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `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
@ -116,7 +105,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
- Basic knowledge of Docker
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
- Local copy of this Git repository (clone)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
#### a. Run environment
@ -135,13 +125,10 @@ 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
```
#### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
#### Setup
1. Open http://localhost:3333 in your browser
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
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
@ -158,31 +145,34 @@ 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 16+)
- [Node.js](https://nodejs.org/en/download) (version 16)
- [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
### 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. Run `yarn database:setup` to initialize the database schema
1. Start the server and the client (see [_Development_](#Development))
1. Open http://localhost:4200/en in your browser
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
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
Run `yarn start:client`
Run `yarn start:client` and open http://localhost:4200/en in your browser
### Start _Storybook_
@ -210,7 +200,9 @@ 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>`.
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
Deprecated: `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
@ -276,12 +268,12 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
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.
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) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
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)**.
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

@ -2,13 +2,14 @@
export default {
displayName: 'api',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json'
}
},
globals: {},
transform: {
'^.+\\.[tj]s$': 'ts-jest'
'^.+\\.[tj]s$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.spec.json'
}
]
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',

View File

@ -1,10 +1,7 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
nullifyValuesInObject,
nullifyValuesInObjects
} from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type {
@ -22,7 +19,8 @@ import {
Param,
Post,
Put,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -39,8 +37,7 @@ export class AccountController {
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@ -85,8 +82,9 @@ export class AccountController {
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
): Promise<Accounts> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
@ -94,41 +92,17 @@ export class AccountController {
this.request.user.id
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations({
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;
return this.portfolioService.getAccountsWithAggregations({
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById(
@Headers('impersonation-id') impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =
@ -137,35 +111,13 @@ export class AccountController {
this.request.user.id
);
let accountsWithAggregations =
const 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];
}

View File

@ -17,6 +17,10 @@ export class CreateAccountDto {
@IsString()
currency: string;
@IsOptional()
@IsString()
id?: string;
@IsBoolean()
@IsOptional()
isExcluded?: boolean;

View File

@ -100,16 +100,21 @@ export class AdminController {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
}
};
})
);
this.dataGatheringService.gatherMax();
}
@ -131,16 +136,21 @@ export class AdminController {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
}
};
})
);
}
@Post('gather/profile-data/:dataSource/:symbol')
@ -161,14 +171,17 @@ export class AdminController {
);
}
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
await this.dataGatheringService.addJobToQueue({
data: {
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
}
});
}
@Post('gather/:dataSource/:symbol')

View File

@ -100,6 +100,7 @@ export class AdminService {
dataSource,
marketDataItemCount,
symbol,
assetClass: 'CASH',
countriesCount: 0,
sectorsCount: 0
};
@ -186,8 +187,11 @@ export class AdminService {
]);
return {
assetProfile,
marketData
marketData,
assetProfile: assetProfile ?? {
symbol,
currency: '-'
}
};
}
@ -231,12 +235,27 @@ export class AdminService {
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy: {
let orderBy: any = {
createdAt: 'desc'
};
let where;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {
Analytics: {
updatedAt: 'desc'
}
},
};
where = {
NOT: {
Analytics: null
}
};
}
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy,
where,
select: {
_count: {
select: { Account: true, Order: true }
@ -244,6 +263,7 @@ export class AdminService {
Analytics: {
select: {
activityCount: true,
country: true,
updatedAt: true
}
},
@ -251,19 +271,16 @@ export class AdminService {
id: true,
Subscription: true
},
take: 30,
where: {
NOT: {
Analytics: null
}
}
take: 30
});
return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration;
const engagement = Analytics
? Analytics.activityCount / daysSinceRegistration
: undefined;
const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
@ -277,7 +294,8 @@ export class AdminService {
id,
subscription,
accountCount: _count.Account || 0,
lastActivity: Analytics.updatedAt,
country: Analytics?.country,
lastActivity: Analytics?.updatedAt,
transactionCount: _count.Order || 0
};
}

View File

@ -4,7 +4,7 @@ import {
} from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { JobStatus, Queue } from 'bull';
@Injectable()
@ -23,14 +23,11 @@ export class QueueService {
}: {
status?: JobStatus[];
}) {
const jobs = await this.dataGatheringQueue.getJobs(status);
for (const job of jobs) {
try {
await job.remove();
} catch (error) {
Logger.warn(error, 'QueueService');
}
for (const statusItem of status) {
await this.dataGatheringQueue.clean(
300,
statusItem === 'waiting' ? 'wait' : statusItem
);
}
}
@ -44,18 +41,23 @@ export class QueueService {
const jobs = await this.dataGatheringQueue.getJobs(status);
const jobsWithState = await Promise.all(
jobs.slice(0, limit).map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,
name: job.name,
stacktrace: job.stacktrace,
state: await job.getState(),
timestamp: job.timestamp
};
})
jobs
.filter((job) => {
return job;
})
.slice(0, limit)
.map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,
name: job.name,
stacktrace: job.stacktrace,
state: await job.getState(),
timestamp: job.timestamp
};
})
);
return {

View File

@ -1,24 +1,23 @@
import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { ConfigurationModule } from '../services/configuration.module';
import { CronService } from '../services/cron.service';
import { DataGatheringModule } from '../services/data-gathering.module';
import { DataProviderModule } from '../services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '../services/exchange-rate-data.module';
import { PrismaModule } from '../services/prisma.module';
import { TwitterBotModule } from '../services/twitter-bot/twitter-bot.module';
import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
@ -30,6 +29,7 @@ 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 { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module';
@ -45,7 +45,7 @@ import { UserModule } from './user/user.module';
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10),
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD
}
}),

View File

@ -33,8 +33,11 @@ export class AuthController {
private readonly webAuthService: WebAuthService
) {}
/**
* @deprecated
*/
@Get('anonymous/:accessToken')
public async accessTokenLogin(
public async accessTokenLoginGet(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
@ -50,6 +53,23 @@ export class AuthController {
}
}
@Post('anonymous')
public async accessTokenLogin(
@Body() body: { accessToken: string }
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateAnonymousLogin(
body.accessToken
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Get('google')
@UseGuards(AuthGuard('google'))
public googleLogin() {
@ -81,13 +101,13 @@ export class AuthController {
}
}
@Get('internet-identity/:principalId')
@Post('internet-identity')
public async internetIdentityLogin(
@Param('principalId') principalId: string
@Body() body: { principalId: string }
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
principalId
body.principalId
);
return { authToken };
} catch {

View File

@ -61,8 +61,10 @@ export class AuthService {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId: principalId
data: {
provider,
thirdPartyId: principalId
}
});
}
@ -96,8 +98,10 @@ export class AuthService {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId
data: {
provider,
thirdPartyId
}
});
}

View File

@ -1,33 +1,46 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import * as countriesAndTimezones from 'countries-and-timezones';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
public constructor(
readonly configurationService: ConfigurationService,
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly userService: UserService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
passReqToCallback: true,
secretOrKey: configurationService.get('JWT_SECRET_KEY')
});
}
public async validate({ id }: { id: string }) {
public async validate(request: Request, { id }: { id: string }) {
try {
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
const user = await this.userService.user({ id });
if (user) {
await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
where: { userId: user.id }
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({
create: { country, User: { connect: { id: user.id } } },
update: {
country,
activityCount: { increment: 1 },
updatedAt: new Date()
},
where: { userId: user.id }
});
}
return user;
} else {

View File

@ -1,6 +1,13 @@
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
HttpException,
Param,
UseGuards
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExchangeRateService } from './exchange-rate.service';
@ -18,9 +25,18 @@ export class ExchangeRateController {
): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString);
return this.exchangeRateService.getExchangeRate({
const exchangeRate = await this.exchangeRateService.getExchangeRate({
date,
symbol
});
if (exchangeRate) {
return { marketPrice: exchangeRate };
}
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
}

View File

@ -1,5 +1,4 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
@ -14,16 +13,14 @@ export class ExchangeRateService {
}: {
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
}): Promise<number> {
const [currency1, currency2] = symbol.split('-');
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
return this.exchangeRateDataService.toCurrencyAtDate(
1,
currency1,
currency2,
date
);
return { marketPrice };
}
}

View File

@ -14,6 +14,22 @@ export class ExportService {
activityIds?: string[];
userId: string;
}): Promise<Export> {
const accounts = await this.prismaService.account.findMany({
orderBy: {
name: 'asc'
},
select: {
accountType: true,
balance: true,
currency: true,
id: true,
isExcluded: true,
name: true,
platformId: true
},
where: { userId }
});
let activities = await this.prismaService.order.findMany({
orderBy: { date: 'desc' },
select: {
@ -38,6 +54,7 @@ export class ExportService {
return {
meta: { date: new Date().toISOString(), version: environment.version },
accounts,
activities: activities.map(
({
accountId,

View File

@ -1,11 +1,11 @@
import * as fs from 'fs';
import * as path from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
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';
@ -14,20 +14,14 @@ export class FrontendMiddleware implements NestMiddleware {
public indexHtmlDe = '';
public indexHtmlEn = '';
public indexHtmlEs = '';
public indexHtmlFr = '';
public indexHtmlIt = '';
public indexHtmlNl = '';
public isProduction: boolean;
public indexHtmlPt = '';
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'),
@ -41,6 +35,10 @@ export class FrontendMiddleware implements NestMiddleware {
this.getPathOfIndexHtmlFile('es'),
'utf8'
);
this.indexHtmlFr = fs.readFileSync(
this.getPathOfIndexHtmlFile('fr'),
'utf8'
);
this.indexHtmlIt = fs.readFileSync(
this.getPathOfIndexHtmlFile('it'),
'utf8'
@ -49,6 +47,10 @@ export class FrontendMiddleware implements NestMiddleware {
this.getPathOfIndexHtmlFile('nl'),
'utf8'
);
this.indexHtmlPt = fs.readFileSync(
this.getPathOfIndexHtmlFile('pt'),
'utf8'
);
} catch {}
}
@ -73,12 +75,31 @@ export class FrontendMiddleware implements NestMiddleware {
) {
featureGraphicPath = 'assets/images/blog/20221226.jpg';
title = `The importance of tracking your personal finances - ${title}`;
} else if (
request.path.startsWith(
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
)
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
title = `Ghostfolio meets Umbrel - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
)
) {
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
title = `Ghostfolio reaches 1000 Stars on GitHub - ${title}`;
}
if (
request.path.startsWith('/api/') ||
this.isFileRequest(request.url) ||
!this.isProduction
!environment.production
) {
// Skip
next();
@ -104,6 +125,15 @@ export class FrontendMiddleware implements NestMiddleware {
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, {
@ -126,6 +156,15 @@ export class FrontendMiddleware implements NestMiddleware {
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, {

View File

@ -1,8 +1,15 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
export class ImportDataDto {
@IsOptional()
@IsArray()
@Type(() => CreateAccountDto)
@ValidateNested({ each: true })
accounts: CreateAccountDto[];
@IsArray()
@Type(() => CreateOrderDto)
@ValidateNested({ each: true })

View File

@ -1,18 +1,25 @@
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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Logger,
Param,
Post,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ImportDataDto } from './import-data.dto';
@ -32,7 +39,13 @@ export class ImportController {
@Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
if (
!hasPermission(
this.request.user.permissions,
permissions.createAccount
) ||
!hasPermission(this.request.user.permissions, permissions.createOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -54,9 +67,10 @@ export class ImportController {
try {
const activities = await this.importService.import({
maxActivitiesToImport,
isDryRun,
maxActivitiesToImport,
userCurrency,
accountsDto: importData.accounts ?? [],
activitiesDto: importData.activities,
userId: this.request.user.id
});
@ -74,4 +88,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,16 +1,19 @@
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';
import { ImportService } from './import.service';
import { PlatformModule } from '@ghostfolio/api/services/platform/platform.module';
@Module({
controllers: [ImportController],
@ -22,8 +25,11 @@ import { ImportService } from './import.service';
DataProviderModule,
ExchangeRateDataModule,
OrderModule,
PlatformModule,
PortfolioModule,
PrismaModule,
RedisCacheModule
RedisCacheModule,
SymbolProfileModule
],
providers: [ImportService]
})

View File

@ -1,11 +1,21 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
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 { 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 { OrderWithAccount } from '@ghostfolio/common/types';
import { PlatformService } from '@ghostfolio/api/services/platform/platform.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 { Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
@ -16,22 +26,160 @@ export class ImportService {
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService
private readonly orderService: OrderService,
private readonly platformService: PlatformService,
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({
accountsDto,
activitiesDto,
isDryRun = false,
maxActivitiesToImport,
userCurrency,
userId
}: {
accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean;
maxActivitiesToImport: number;
userCurrency: string;
userId: string;
}): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
if (!isDryRun && accountsDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({
where: {
id: {
in: accountsDto.map(({ id }) => {
return id;
})
}
}
}),
this.platformService.get()
]);
for (const account of accountsDto) {
// Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find(
(existingAccount) => existingAccount.id === account.id
);
// If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== userId) {
let oldAccountId: string;
const platformId = account.platformId;
delete account.platformId;
if (accountWithSameId) {
oldAccountId = account.id;
delete account.id;
}
let accountObject: Prisma.AccountCreateInput = {
...account,
User: { connect: { id: userId } }
};
if (
existingPlatforms.some(({ id }) => {
return id === platformId;
})
) {
accountObject = {
...accountObject,
Platform: { connect: { id: platformId } }
};
}
const newAccount = await this.accountService.createAccount(
accountObject,
userId
);
// Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) {
accountIdMapping[oldAccountId] = newAccount.id;
}
}
}
}
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (activity.type === 'ITEM') {
@ -40,20 +188,33 @@ export class ImportService {
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
}
}
// If a new account is created, then update the accountId in all activities
if (!isDryRun) {
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
activity.accountId = accountIdMapping[activity.accountId];
}
}
}
await this.validateActivities({
const assetProfiles = await this.validateActivities({
activitiesDto,
maxActivitiesToImport,
userId
});
const accountIds = (await this.accountService.getAccounts(userId)).map(
const accounts = (await this.accountService.getAccounts(userId)).map(
(account) => {
return account.id;
return { id: account.id, name: account.name };
}
);
if (isDryRun) {
accountsDto.forEach(({ id, name }) => {
accounts.push({ id, name });
});
}
const activities: Activity[] = [];
for (const {
@ -69,11 +230,15 @@ export class ImportService {
unitPrice
} of activitiesDto) {
const date = parseISO(<string>(<unknown>dateString));
const validatedAccountId = accountIds.includes(accountId)
? accountId
: undefined;
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
let order: OrderWithAccount;
let order:
| OrderWithAccount
| (Omit<OrderWithAccount, 'Account'> & {
Account?: { id: string; name: string };
});
if (isDryRun) {
order = {
@ -84,7 +249,7 @@ export class ImportService {
type,
unitPrice,
userId,
accountId: validatedAccountId,
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
id: uuidv4(),
@ -99,13 +264,16 @@ export class ImportService {
countries: null,
createdAt: undefined,
id: undefined,
isin: null,
name: null,
scraperConfiguration: null,
sectors: null,
symbolMapping: null,
updatedAt: undefined,
url: null
url: null,
...assetProfiles[symbol]
},
Account: validatedAccount,
symbolProfileId: undefined,
updatedAt: new Date()
};
@ -118,7 +286,7 @@ export class ImportService {
type,
unitPrice,
userId,
accountId: validatedAccountId,
accountId: validatedAccount?.id,
SymbolProfile: {
connectOrCreate: {
create: {
@ -140,6 +308,7 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({
...order,
value,
@ -159,6 +328,16 @@ export class ImportService {
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({
activitiesDto,
maxActivitiesToImport,
@ -172,6 +351,9 @@ export class ImportService {
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' },
@ -200,22 +382,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

@ -6,9 +6,9 @@ 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_DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE,
@ -22,6 +22,7 @@ import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent';
@ -59,9 +60,7 @@ export class InfoService {
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource
);
@ -72,10 +71,6 @@ export class InfoService {
globalPermissions.push(permissions.enableFearAndGreedIndex);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
@ -93,6 +88,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');
}
@ -120,7 +119,7 @@ export class InfoService {
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
demoAuthToken: await this.getDemoAuthToken(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
@ -248,10 +247,18 @@ export class InfoService {
)) as string;
}
private getDemoAuthToken() {
return this.jwtService.sign({
id: DEMO_USER_ID
});
private async getDemoAuthToken() {
const demoUserId = (await this.propertyService.getByKey(
PROPERTY_DEMO_USER_ID
)) as string;
if (demoUserId) {
return this.jwtService.sign({
id: demoUserId
});
}
return undefined;
}
private async getStatistics() {
@ -298,19 +305,17 @@ export class InfoService {
return statistics;
}
private async getSubscriptions(): Promise<Subscription[]> {
private async getSubscriptions(): Promise<{
[offer in SubscriptionOffer]: Subscription;
}> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
const stripeConfig = await this.prismaService.property.findUnique({
const stripeConfig = (await this.prismaService.property.findUnique({
where: { key: PROPERTY_STRIPE_CONFIG }
});
})) ?? { value: '{}' };
if (stripeConfig) {
return [JSON.parse(stripeConfig.value)];
}
return [];
return JSON.parse(stripeConfig.value);
}
}

View File

@ -3,6 +3,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -66,7 +67,7 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string

View File

@ -76,23 +76,21 @@ export class OrderService {
userId: string;
}
): Promise<Order> {
const defaultAccount = (
await this.accountService.getAccounts(data.userId)
).find((account) => {
return account.isDefault === true;
});
let Account;
if (data.accountId) {
Account = {
connect: {
id_userId: {
userId: data.userId,
id: data.accountId
}
}
};
}
const tags = data.tags ?? [];
let Account = {
connect: {
id_userId: {
userId: data.userId,
id: data.accountId ?? defaultAccount?.id
}
}
};
if (data.type === 'ITEM') {
const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass;
@ -101,7 +99,6 @@ export class OrderService {
const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol;
Account = undefined;
data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
@ -113,19 +110,19 @@ export class OrderService {
dataSource,
symbol: id
};
} else {
data.SymbolProfile.connectOrCreate.create.symbol =
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
}
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
await this.dataGatheringService.addJobToQueue({
data: {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
}
});
const isDraft = isAfter(data.date as Date, endOfToday());

View File

@ -1,4 +1,5 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface';
@ -48,8 +49,11 @@ export const CurrentRateServiceMock = {
getValues: ({
dataGatheringItems,
dateQuery
}: GetValuesParams): Promise<GetValueObject[]> => {
const result: GetValueObject[] = [];
}: GetValuesParams): Promise<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}> => {
const values: GetValueObject[] = [];
if (dateQuery.lt) {
for (
let date = resetHours(dateQuery.gte);
@ -57,7 +61,7 @@ export const CurrentRateServiceMock = {
date = addDays(date, 1)
) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
values.push({
date,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
@ -70,7 +74,7 @@ export const CurrentRateServiceMock = {
} else {
for (const date of dateQuery.in) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
values.push({
date,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
@ -81,6 +85,6 @@ export const CurrentRateServiceMock = {
}
}
}
return Promise.resolve(result);
return Promise.resolve({ values, dataProviderInfos: [] });
}
};

View File

@ -1,10 +1,12 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
jest.mock('@ghostfolio/api/services/market-data.service', () => {
return {
@ -66,14 +68,32 @@ jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
};
});
jest.mock('@ghostfolio/api/services/property/property.service', () => {
return {
PropertyService: jest.fn().mockImplementation(() => {
return {
getByKey: (key: string) => Promise.resolve({})
};
})
};
});
describe('CurrentRateService', () => {
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let marketDataService: MarketDataService;
let propertyService: PropertyService;
beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null);
propertyService = new PropertyService(null);
dataProviderService = new DataProviderService(
null,
[],
null,
propertyService
);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
@ -103,17 +123,23 @@ describe('CurrentRateService', () => {
},
userCurrency: 'CHF'
})
).toMatchObject<GetValueObject[]>([
{
date: undefined,
marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
}
]);
).toMatchObject<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}>({
dataProviderInfos: [],
values: [
{
date: undefined,
marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
}
]
});
});
});

View File

@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
@ -22,7 +23,11 @@ export class CurrentRateService {
dataGatheringItems,
dateQuery,
userCurrency
}: GetValuesParams): Promise<GetValueObject[]> {
}: GetValuesParams): Promise<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}> {
const dataProviderInfos: DataProviderInfo[] = [];
const includeToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
@ -38,6 +43,14 @@ export class CurrentRateService {
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {
if (
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
) {
dataProviderInfos.push(
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
);
}
result.push({
date: today,
marketPriceInBaseCurrency:
@ -81,7 +94,10 @@ export class CurrentRateService {
})
);
return flatten(await Promise.all(promises));
return {
dataProviderInfos,
values: flatten(await Promise.all(promises))
};
}
private containsToday(dates: Date[]): boolean {

View File

@ -1,4 +1,5 @@
import {
DataProviderInfo,
EnhancedSymbolProfile,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
@ -7,6 +8,9 @@ import { Tag } from '@prisma/client';
export interface PortfolioPositionDetail {
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;
feeInBaseCurrency: number;
firstBuyDate: string;
grossPerformance: number;
grossPerformancePercent: number;

View File

@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
fee: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),

View File

@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
@ -70,6 +71,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('136.6'),
currency: 'CHF',
dataSource: 'YAHOO',
fee: new Big('1.55'),
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),

View File

@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('320.43'),
currency: 'CHF',
dataSource: 'YAHOO',
fee: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),

View File

@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();

View File

@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
fee: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),

View File

@ -68,7 +68,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();
@ -85,7 +86,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 13.100263852242744,
netPerformance: 19.86,
totalInvestment: 0,
value: 19.86
value: 0
});
expect(currentPositions).toEqual({
@ -101,6 +102,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
fee: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),

View File

@ -1,7 +1,12 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
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';
@ -44,6 +49,7 @@ export class PortfolioCalculator {
private currency: string;
private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[];
@ -176,10 +182,10 @@ export class PortfolioCalculator {
return isBefore(parseDate(transactionPoint.date), end);
}) ?? [];
const firstIndex = transactionPointsBeforeEndDate.length;
const currencies: { [symbol: string]: string } = {};
const dates: Date[] = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
const firstIndex = transactionPointsBeforeEndDate.length;
let day = start;
@ -201,14 +207,17 @@ export class PortfolioCalculator {
symbols[item.symbol] = true;
}
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
const { dataProviderInfos, values: marketSymbols } =
await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
@ -226,87 +235,100 @@ export class PortfolioCalculator {
}
}
const netPerformanceValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
const valuesByDate: {
[date: string]: {
maxTotalInvestmentValue: Big;
totalCurrentValue: Big;
totalInvestmentValue: Big;
totalNetPerformanceValue: Big;
};
} = {};
const investmentValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
const valuesBySymbol: {
[symbol: string]: {
currentValues: { [date: string]: Big };
investmentValues: { [date: string]: Big };
maxInvestmentValues: { [date: string]: Big };
netPerformanceValues: { [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
});
const {
currentValues,
investmentValues,
maxInvestmentValues,
netPerformanceValues
} = this.getSymbolMetrics({
end,
marketSymbolMap,
start,
step,
symbol,
isChartMode: true
});
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
investmentValuesBySymbol[symbol] = investmentValues;
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
valuesBySymbol[symbol] = {
currentValues,
investmentValues,
maxInvestmentValues,
netPerformanceValues
};
}
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);
for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol];
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
dateString
].add(netPerformanceValuesBySymbol[symbol][dateString]);
}
const currentValue =
symbolValues.currentValues?.[dateString] ?? new Big(0);
const investmentValue =
symbolValues.investmentValues?.[dateString] ?? new Big(0);
const maxInvestmentValue =
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
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]);
}
valuesByDate[dateString] = {
totalCurrentValue: (
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
totalInvestmentValue: (
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
).add(investmentValue),
maxTotalInvestmentValue: (
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
).add(maxInvestmentValue),
totalNetPerformanceValue: (
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
).add(netPerformanceValue)
};
}
}
return Object.keys(totalNetPerformanceValues).map((date) => {
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
return Object.entries(valuesByDate).map(([date, values]) => {
const {
maxTotalInvestmentValue,
totalCurrentValue,
totalInvestmentValue,
totalNetPerformanceValue
} = values;
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
? 0
: totalNetPerformanceValues[date]
.div(maxTotalInvestmentValues[date])
: totalNetPerformanceValue
.div(maxTotalInvestmentValue)
.mul(100)
.toNumber();
return {
date,
netPerformanceInPercentage,
netPerformance: totalNetPerformanceValues[date].toNumber(),
totalInvestment: totalInvestmentValues[date].toNumber(),
value: totalInvestmentValues[date]
.plus(totalNetPerformanceValues[date])
.toNumber()
netPerformance: totalNetPerformanceValue.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
value: totalCurrentValue.toNumber()
};
});
}
@ -367,14 +389,17 @@ export class PortfolioCalculator {
dates.push(resetHours(end));
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
const { dataProviderInfos, values: marketSymbols } =
await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
@ -430,6 +455,7 @@ export class PortfolioCalculator {
: item.investment.div(item.quantity),
currency: item.currency,
dataSource: item.dataSource,
fee: item.fee,
firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors
@ -446,7 +472,7 @@ export class PortfolioCalculator {
transactionCount: item.transactionCount
});
if (hasErrors) {
if (hasErrors && item.investment.gt(0)) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
}
}
@ -461,6 +487,10 @@ export class PortfolioCalculator {
};
}
public getDataProviderInfos() {
return this.dataProviderInfos;
}
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
@ -478,46 +508,60 @@ export class PortfolioCalculator {
});
}
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
public getInvestmentsByGroup(
groupBy: GroupBy
): { date: string; investment: Big }[] {
if (this.orders.length === 0) {
return [];
}
const investments = [];
let currentDate: Date;
let investmentByMonth = new Big(0);
let investmentByGroup = new Big(0);
for (const [index, order] of this.orders.entries()) {
if (
isSameMonth(parseDate(order.date), currentDate) &&
isSameYear(parseDate(order.date), currentDate)
isSameYear(parseDate(order.date), currentDate) &&
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
) {
// Same month: Add up investments
// Same group: Add up investments
investmentByMonth = investmentByMonth.plus(
investmentByGroup = investmentByGroup.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
);
} else {
// New month: Store previous month and reset
// New group: Store previous group and reset
if (currentDate) {
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup
});
}
currentDate = parseDate(order.date);
investmentByMonth = order.quantity
investmentByGroup = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
}
if (index === this.orders.length - 1) {
// Store current month (latest order)
// Store current group (latest order)
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup
});
}
}
@ -678,7 +722,7 @@ export class PortfolioCalculator {
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
`Missing historical market data for symbol ${currentPosition.symbol}`,
'PortfolioCalculator'
);
hasErrors = true;
@ -732,7 +776,7 @@ export class PortfolioCalculator {
let marketSymbols: GetValueObject[] = [];
if (dataGatheringItems.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
const { values } = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
@ -741,6 +785,7 @@ export class PortfolioCalculator {
},
userCurrency: this.currency
});
marketSymbols = values;
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
@ -874,12 +919,16 @@ export class PortfolioCalculator {
if (orders.length <= 0) {
return {
currentValues: {},
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
hasErrors: false,
initialValue: new Big(0),
investmentValues: {},
maxInvestmentValues: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
netPerformanceValues: {}
};
}
@ -914,6 +963,7 @@ export class PortfolioCalculator {
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let investmentAtStartDate: Big;
const currentValues: { [date: string]: Big } = {};
const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
@ -1132,6 +1182,7 @@ export class PortfolioCalculator {
}
if (isChartMode && i > indexOfStartOrder) {
currentValues[order.date] = valueOfInvestment;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
@ -1229,15 +1280,16 @@ export class PortfolioCalculator {
}
return {
initialValue,
currentValues,
grossPerformancePercentage,
initialValue,
investmentValues,
maxInvestmentValues,
netPerformancePercentage,
netPerformanceValues,
grossPerformance: totalGrossPerformance,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
grossPerformance: totalGrossPerformance
netPerformance: totalNetPerformance
};
}

View File

@ -10,6 +10,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
PortfolioDetails,
PortfolioDividends,
@ -18,7 +19,6 @@ import {
PortfolioPublicDetails,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
DateRange,
GroupBy,
@ -66,7 +66,7 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@ -132,7 +132,8 @@ export class PortfolioController {
portfolioPosition.investment / totalInvestment;
portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null;
portfolioPosition.value = portfolioPosition.value / totalValue;
portfolioPosition.valueInPercentage =
portfolioPosition.value / totalValue;
}
for (const [name, { current, original }] of Object.entries(accounts)) {
@ -189,24 +190,25 @@ export class PortfolioController {
@Get('dividends')
@UseGuards(AuthGuard('jwt'))
public async getDividends(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max',
@Query('groupBy') groupBy?: GroupBy
@Query('tags') filterByTags?: string
): Promise<PortfolioDividends> {
let dividends: InvestmentItem[];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
if (groupBy === 'month') {
dividends = await this.portfolioService.getDividends({
dateRange,
groupBy,
impersonationId
});
} else {
dividends = await this.portfolioService.getDividends({
dateRange,
impersonationId
});
}
let dividends = await this.portfolioService.getDividends({
dateRange,
filters,
groupBy,
impersonationId
});
if (
impersonationId ||
@ -238,24 +240,25 @@ export class PortfolioController {
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max',
@Query('groupBy') groupBy?: GroupBy
@Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> {
let investments: InvestmentItem[];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments({
dateRange,
groupBy,
impersonationId
});
} else {
investments = await this.portfolioService.getInvestments({
dateRange,
impersonationId
});
}
let investments = await this.portfolioService.getInvestments({
dateRange,
filters,
groupBy,
impersonationId
});
if (
impersonationId ||
@ -289,11 +292,21 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2')
public async getPerformanceV2(
@Headers('impersonation-id') impersonationId: string,
@Query('range') dateRange: DateRange = 'max'
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPerformanceResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const performanceInformation = await this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId,
userId: this.request.user.id
});
@ -311,7 +324,7 @@ export class PortfolioController {
totalInvestment: new Big(totalInvestment)
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
value: new Big(value)
valueInPercentage: new Big(value)
.div(performanceInformation.performance.currentValue)
.toNumber()
};
@ -345,31 +358,26 @@ export class PortfolioController {
@Get('positions')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers('impersonation-id') impersonationId: string,
@Query('range') dateRange: DateRange = 'max'
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@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,
dateRange
);
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
result.positions = result.positions.map((position) => {
return nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'quantity'
]);
});
}
return result;
return this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
}
@Get('public/:accessId')
@ -420,7 +428,7 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.value / totalValue,
allocationInPercentage: portfolioPosition.value / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
@ -431,7 +439,7 @@ export class PortfolioController {
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
value: portfolioPosition.value / totalValue
valueInPercentage: portfolioPosition.value / totalValue
};
}
@ -439,35 +447,22 @@ export class PortfolioController {
}
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'))
public async getPosition(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition(
const position = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (position) {
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
position = nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'orders',
'quantity',
'value'
]);
}
return position;
}
@ -480,7 +475,7 @@ export class PortfolioController {
@Get('report')
@UseGuards(AuthGuard('jwt'))
public async getReport(
@Headers('impersonation-id') impersonationId: string
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReport> {
const report = await this.portfolioService.getReport(impersonationId);

View File

@ -1,5 +1,6 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
@ -19,11 +20,11 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
ASSET_SUB_CLASS_EMERGENCY_FUND,
EMERGENCY_FUND_TAG_ID,
MAX_CHART_ITEMS,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
EnhancedSymbolProfile,
@ -36,8 +37,7 @@ import {
PortfolioSummary,
Position,
TimelinePosition,
UserSettings,
UserWithSettings
UserSettings
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
@ -46,7 +46,8 @@ import type {
GroupBy,
Market,
OrderWithAccount,
RequestWithUser
RequestWithUser,
UserWithSettings
} from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@ -210,16 +211,19 @@ export class PortfolioService {
public async getDividends({
dateRange,
impersonationId,
groupBy
filters,
groupBy,
impersonationId
}: {
dateRange: DateRange;
impersonationId: string;
filters?: Filter[];
groupBy?: GroupBy;
impersonationId: string;
}): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const activities = await this.orderService.getOrders({
filters,
userId,
types: ['DIVIDEND'],
userCurrency: this.request.user.Settings.settings.baseCurrency
@ -232,8 +236,8 @@ export class PortfolioService {
};
});
if (groupBy === 'month') {
dividends = this.getDividendsByMonth(dividends);
if (groupBy) {
dividends = this.getDividendsByGroup({ dividends, groupBy });
}
const startDate = this.getStartDate(
@ -248,17 +252,20 @@ export class PortfolioService {
public async getInvestments({
dateRange,
impersonationId,
groupBy
filters,
groupBy,
impersonationId
}: {
dateRange: DateRange;
impersonationId: string;
filters?: Filter[];
groupBy?: GroupBy;
impersonationId: string;
}): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
userId,
includeDrafts: true
});
@ -276,26 +283,31 @@ export class PortfolioService {
let investments: InvestmentItem[];
if (groupBy === 'month') {
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
});
if (groupBy) {
investments = portfolioCalculator
.getInvestmentsByGroup(groupBy)
.map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
});
// Add investment of current month
const dateOfCurrentMonth = format(
set(new Date(), { date: 1 }),
// Add investment of current group
const dateOfCurrentGroup = format(
set(new Date(), {
date: 1,
month: groupBy === 'year' ? 0 : new Date().getMonth()
}),
DATE_FORMAT
);
const investmentOfCurrentMonth = investments.filter(({ date }) => {
return date === dateOfCurrentMonth;
const investmentOfCurrentGroup = investments.filter(({ date }) => {
return date === dateOfCurrentGroup;
});
if (investmentOfCurrentMonth.length <= 0) {
if (investmentOfCurrentGroup.length <= 0) {
investments.push({
date: dateOfCurrentMonth,
date: dateOfCurrentGroup,
investment: 0
});
}
@ -343,11 +355,13 @@ export class PortfolioService {
public async getChart({
dateRange = 'max',
filters,
impersonationId,
userCurrency,
userId
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userCurrency: string;
userId: string;
@ -356,6 +370,7 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
userId
});
@ -397,15 +412,15 @@ export class PortfolioService {
}
public async getDetails({
impersonationId,
dateRange = 'max',
filters,
impersonationId,
userId,
withExcludedAccounts = false
}: {
impersonationId: string;
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> {
@ -522,12 +537,9 @@ export class PortfolioService {
holdings[item.symbol] = {
markets,
allocationCurrent: filteredValueInBaseCurrency.eq(0)
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
: value.div(filteredValueInBaseCurrency).toNumber(),
allocationInvestment: item.investment
.div(totalInvestmentInBaseCurrency)
.toNumber(),
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries,
@ -560,9 +572,7 @@ export class PortfolioService {
) {
const cashPositions = await this.getCashPositions({
cashDetails,
emergencyFund,
userCurrency,
investment: totalInvestmentInBaseCurrency,
value: filteredValueInBaseCurrency
});
@ -580,10 +590,51 @@ export class PortfolioService {
withExcludedAccounts
});
if (
filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID &&
filters[0].type === 'TAG'
) {
const cashPositions = await this.getCashPositions({
cashDetails,
userCurrency,
value: filteredValueInBaseCurrency
});
const emergencyFundInCash = emergencyFund
.minus(
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
})
)
.toNumber();
filteredValueInBaseCurrency = emergencyFund;
accounts[UNKNOWN_KEY] = {
balance: 0,
currency: userCurrency,
current: emergencyFundInCash,
name: UNKNOWN_KEY,
original: emergencyFundInCash
};
holdings[userCurrency] = {
...cashPositions[userCurrency],
investment: emergencyFundInCash,
value: emergencyFundInCash
};
}
const summary = await this.getSummary({
impersonationId,
userCurrency,
userId
userId,
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
})
});
return {
@ -627,6 +678,9 @@ export class PortfolioService {
return {
tags,
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
feeInBaseCurrency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
@ -646,8 +700,9 @@ export class PortfolioService {
}
const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource: aDataSource, symbol: aSymbol }
]);
const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => {
@ -692,12 +747,23 @@ export class PortfolioService {
averagePrice,
currency,
dataSource,
fee,
firstBuyDate,
marketPrice,
quantity,
transactionCount
} = position;
const dividendInBaseCurrency = getSum(
orders
.filter(({ type }) => {
return type === 'DIVIDEND';
})
.map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
);
// Convert investment, gross and net performance to currency of user
const investment = this.exchangeRateDataService.toCurrency(
position.investment?.toNumber(),
@ -731,7 +797,8 @@ export class PortfolioService {
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
date: firstBuyDate,
value: orders[0].unitPrice
marketPrice: orders[0].unitPrice,
quantity: orders[0].quantity
});
}
@ -747,6 +814,7 @@ export class PortfolioService {
j++;
}
let currentAveragePrice = 0;
let currentQuantity = 0;
const currentSymbol = transactionPoints[j].items.find(
(item) => item.symbol === aSymbol
);
@ -754,12 +822,14 @@ export class PortfolioService {
currentAveragePrice = currentSymbol.quantity.eq(0)
? 0
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
currentQuantity = currentSymbol.quantity.toNumber();
}
historicalDataArray.push({
date,
marketPrice,
averagePrice: currentAveragePrice,
value: marketPrice
quantity: currentQuantity
});
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
@ -780,6 +850,13 @@ export class PortfolioService {
tags,
transactionCount,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee.toNumber(),
SymbolProfile.currency,
userCurrency
),
grossPerformancePercent:
position.grossPerformancePercentage?.toNumber(),
historicalData: historicalDataArray,
@ -836,6 +913,9 @@ export class PortfolioService {
SymbolProfile,
tags,
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
feeInBaseCurrency: 0,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
@ -850,14 +930,20 @@ export class PortfolioService {
}
}
public async getPositions(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
public async getPositions({
dateRange = 'max',
filters,
impersonationId
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
userId
});
@ -877,7 +963,7 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
@ -885,12 +971,14 @@ export class PortfolioService {
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
);
const dataGatheringItem = positions.map((position) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
};
});
const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
@ -928,10 +1016,12 @@ export class PortfolioService {
public async getPerformance({
dateRange = 'max',
filters,
impersonationId,
userId
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userId: string;
}): Promise<PortfolioPerformanceResponse> {
@ -941,6 +1031,7 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
userId
});
@ -970,32 +1061,25 @@ export class PortfolioService {
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
const {
currentValue,
errors,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
} = await portfolioCalculator.getCurrentPositions(startDate);
const hasErrors = currentPositions.hasErrors;
const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance = currentPositions.grossPerformance;
const currentGrossPerformancePercent =
currentPositions.grossPerformancePercentage;
let currentNetPerformance = currentPositions.netPerformance;
let currentNetPerformancePercent =
currentPositions.netPerformancePercentage;
const totalInvestment = currentPositions.totalInvestment;
// if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
// // If algebraic sign is different, harmonize it
// currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
// }
// if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
// // If algebraic sign is different, harmonize it
// currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
// }
const currentGrossPerformance = grossPerformance;
const currentGrossPerformancePercent = grossPerformancePercentage;
let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage;
const historicalDataContainer = await this.getChart({
dateRange,
filters,
impersonationId,
userCurrency,
userId
@ -1013,28 +1097,28 @@ export class PortfolioService {
}
return {
errors,
hasErrors,
chart: historicalDataContainer.items.map(
({
date,
netPerformance,
netPerformance: netPerformanceOfItem,
netPerformanceInPercentage,
totalInvestment,
totalInvestment: totalInvestmentOfItem,
value
}) => {
return {
date,
netPerformance,
netPerformanceInPercentage,
totalInvestment,
value
value,
netPerformance: netPerformanceOfItem,
totalInvestment: totalInvestmentOfItem
};
}
),
errors: currentPositions.errors,
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
currentValue,
currentValue: currentValue.toNumber(),
currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent:
currentGrossPerformancePercent.toNumber(),
@ -1074,16 +1158,23 @@ export class PortfolioService {
portfolioStart
);
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
);
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
for (const position of currentPositions.positions) {
for (const position of positions) {
portfolioItemsNow[position.symbol] = position;
}
const accounts = await this.getValueOfAccounts({
orders,
portfolioItemsNow,
userId,
userCurrency
userCurrency,
userId
});
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
@ -1107,19 +1198,19 @@ export class PortfolioService {
[
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
this.exchangeRateDataService,
currentPositions
positions
),
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
currentPositions
positions
),
new CurrencyClusterRiskInitialInvestment(
this.exchangeRateDataService,
currentPositions
positions
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
currentPositions
positions
)
],
<UserSettings>this.request.user.Settings.settings
@ -1129,7 +1220,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(),
this.getFees({ orders, userCurrency }).toNumber()
this.getFees({ userCurrency, activities: orders }).toNumber()
)
],
<UserSettings>this.request.user.Settings.settings
@ -1140,16 +1231,12 @@ export class PortfolioService {
private async getCashPositions({
cashDetails,
emergencyFund,
investment,
userCurrency,
value
}: {
cashDetails: CashDetails;
emergencyFund: Big;
investment: Big;
value: Big;
userCurrency: string;
value: Big;
}) {
const cashPositions: PortfolioDetails['holdings'] = {
[userCurrency]: this.getInitialCashPosition({
@ -1180,62 +1267,38 @@ export class PortfolioService {
}
}
if (emergencyFund.gt(0)) {
cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = {
...cashPositions[userCurrency],
assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND,
investment: emergencyFund.toNumber(),
name: ASSET_SUB_CLASS_EMERGENCY_FUND,
symbol: ASSET_SUB_CLASS_EMERGENCY_FUND,
value: emergencyFund.toNumber()
};
cashPositions[userCurrency].investment = new Big(
cashPositions[userCurrency].investment
)
.minus(emergencyFund)
.toNumber();
cashPositions[userCurrency].value = new Big(
cashPositions[userCurrency].value
)
.minus(emergencyFund)
.toNumber();
}
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = value.gt(0)
cashPositions[symbol].allocationInPercentage = value.gt(0)
? new Big(cashPositions[symbol].value).div(value).toNumber()
: 0;
cashPositions[symbol].allocationInvestment = investment.gt(0)
? new Big(cashPositions[symbol].investment).div(investment).toNumber()
: 0;
}
return cashPositions;
}
private getDividend({
activities,
date = new Date(0),
orders,
userCurrency
}: {
activities: OrderWithAccount[];
date?: Date;
orders: OrderWithAccount[];
userCurrency: string;
}) {
return orders
.filter((order) => {
// Filter out all orders before given date and type dividend
return activities
.filter((activity) => {
// Filter out all activities before given date and type dividend
return (
isBefore(date, new Date(order.date)) &&
order.type === TypeOfOrder.DIVIDEND
isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.DIVIDEND
);
})
.map((order) => {
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.SymbolProfile.currency,
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
@ -1245,67 +1308,118 @@ export class PortfolioService {
);
}
private getDividendsByMonth(aDividends: InvestmentItem[]): InvestmentItem[] {
if (aDividends.length === 0) {
private getDividendsByGroup({
dividends,
groupBy
}: {
dividends: InvestmentItem[];
groupBy: GroupBy;
}): InvestmentItem[] {
if (dividends.length === 0) {
return [];
}
const dividends = [];
const dividendsByGroup: InvestmentItem[] = [];
let currentDate: Date;
let investmentByMonth = new Big(0);
let investmentByGroup = new Big(0);
for (const [index, dividend] of aDividends.entries()) {
for (const [index, dividend] of dividends.entries()) {
if (
isSameMonth(parseDate(dividend.date), currentDate) &&
isSameYear(parseDate(dividend.date), currentDate)
isSameYear(parseDate(dividend.date), currentDate) &&
(groupBy === 'year' ||
isSameMonth(parseDate(dividend.date), currentDate))
) {
// Same month: Add up divididends
// Same group: Add up dividends
investmentByMonth = investmentByMonth.plus(dividend.investment);
investmentByGroup = investmentByGroup.plus(dividend.investment);
} else {
// New month: Store previous month and reset
// New group: Store previous group and reset
if (currentDate) {
dividends.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
currentDate = parseDate(dividend.date);
investmentByMonth = new Big(dividend.investment);
investmentByGroup = new Big(dividend.investment);
}
if (index === aDividends.length - 1) {
if (index === dividends.length - 1) {
// Store current month (latest order)
dividends.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
}
return dividends;
return dividendsByGroup;
}
private getEmergencyFundPositionsValueInBaseCurrency({
activities
}: {
activities: Activity[];
}) {
const emergencyFundOrders = activities.filter((activity) => {
return (
activity.tags?.some(({ id }) => {
return id === EMERGENCY_FUND_TAG_ID;
}) ?? false
);
});
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
for (const order of emergencyFundOrders) {
if (order.type === 'BUY') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.plus(
order.valueInBaseCurrency
);
} else if (order.type === 'SELL') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.minus(
order.valueInBaseCurrency
);
}
}
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
}
private getFees({
activities,
date = new Date(0),
orders,
userCurrency
}: {
activities: OrderWithAccount[];
date?: Date;
orders: OrderWithAccount[];
userCurrency: string;
}) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
return activities
.filter((activity) => {
// Filter out all activities before given date
return isBefore(date, new Date(activity.date));
})
.map((order) => {
.map(({ fee, SymbolProfile }) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
fee,
SymbolProfile.currency,
userCurrency
);
})
@ -1324,8 +1438,7 @@ export class PortfolioService {
}): PortfolioPosition {
return {
currency,
allocationCurrent: 0,
allocationInvestment: 0,
allocationInPercentage: 0,
assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
countries: [],
@ -1372,26 +1485,42 @@ export class PortfolioService {
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) {
case '1d':
portfolioStart = max([portfolioStart, subDays(new Date(), 1)]);
portfolioStart = max([
portfolioStart,
subDays(new Date().setHours(0, 0, 0, 0), 1)
]);
break;
case 'ytd':
portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]);
portfolioStart = max([
portfolioStart,
setDayOfYear(new Date().setHours(0, 0, 0, 0), 1)
]);
break;
case '1y':
portfolioStart = max([portfolioStart, subYears(new Date(), 1)]);
portfolioStart = max([
portfolioStart,
subYears(new Date().setHours(0, 0, 0, 0), 1)
]);
break;
case '5y':
portfolioStart = max([portfolioStart, subYears(new Date(), 5)]);
portfolioStart = max([
portfolioStart,
subYears(new Date().setHours(0, 0, 0, 0), 5)
]);
break;
}
return portfolioStart;
}
private async getSummary({
balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency,
impersonationId,
userCurrency,
userId
}: {
balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number;
impersonationId: string;
userCurrency: string;
userId: string;
@ -1404,11 +1533,7 @@ export class PortfolioService {
userId
});
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
userId,
currency: userCurrency
});
const orders = await this.orderService.getOrders({
const activities = await this.orderService.getOrders({
userCurrency,
userId
});
@ -1423,18 +1548,27 @@ export class PortfolioService {
return account?.isExcluded ?? false;
});
const dividend = this.getDividend({ orders, userCurrency }).toNumber();
const dividend = this.getDividend({
activities,
userCurrency
}).toNumber();
const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
Math.max(
emergencyFundPositionsValueInBaseCurrency,
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
)
);
const fees = this.getFees({ orders, userCurrency }).toNumber();
const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber();
const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber();
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund)
.plus(emergencyFundPositionsValueInBaseCurrency)
.toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = new Big(
this.getTotalByType(excludedActivities, userCurrency, 'BUY')
@ -1490,8 +1624,8 @@ export class PortfolioService {
totalSell,
committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(),
ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL';
}).length
};
}
@ -1508,7 +1642,7 @@ export class PortfolioService {
withExcludedAccounts?: boolean;
}): Promise<{
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
orders: Activity[];
portfolioOrders: PortfolioOrder[];
}> {
const userCurrency =
@ -1581,6 +1715,14 @@ export class PortfolioService {
userId: string;
withExcludedAccounts?: boolean;
}) {
const ordersOfTypeItem = await this.orderService.getOrders({
filters,
userCurrency,
userId,
withExcludedAccounts,
types: ['ITEM']
});
const accounts: PortfolioDetails['accounts'] = {};
let currentAccounts: (Account & {
@ -1611,10 +1753,18 @@ export class PortfolioService {
});
for (const account of currentAccounts) {
const ordersByAccount = orders.filter(({ accountId }) => {
let ordersByAccount = orders.filter(({ accountId }) => {
return accountId === account.id;
});
const ordersOfTypeItemByAccount = ordersOfTypeItem.filter(
({ accountId }) => {
return accountId === account.id;
}
);
ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount);
accounts[account.id] = {
balance: account.balance,
currency: account.currency,
@ -1634,7 +1784,9 @@ export class PortfolioService {
for (const order of ordersByAccount) {
let currentValueOfSymbolInBaseCurrency =
order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
(portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ??
order.unitPrice ??
0);
let originalValueOfSymbolInBaseCurrency =
this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,

View File

@ -63,6 +63,7 @@ export class SubscriptionController {
await this.subscriptionService.createSubscription({
duration: coupon.duration,
price: 0,
userId: this.request.user.id
});
@ -116,7 +117,7 @@ export class SubscriptionController {
return await this.subscriptionService.createCheckoutSession({
couponId,
priceId,
userId: this.request.user.id
user: this.request.user
});
} catch (error) {
Logger.error(error, 'SubscriptionController');

View File

@ -1,7 +1,12 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
import { UserWithSettings } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns';
@ -19,7 +24,7 @@ export class SubscriptionService {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2020-08-27'
apiVersion: '2022-11-15'
}
);
}
@ -27,17 +32,17 @@ export class SubscriptionService {
public async createCheckoutSession({
couponId,
priceId,
userId
user
}: {
couponId?: string;
priceId: string;
userId: string;
user: UserWithSettings;
}) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`,
client_reference_id: userId,
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
}/account`,
client_reference_id: user.id,
line_items: [
{
price: priceId,
@ -70,13 +75,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: {
@ -93,10 +101,20 @@ export class SubscriptionService {
aCheckoutSessionId
);
await this.createSubscription({ userId: session.client_reference_id });
let subscriptions: SubscriptionInterface[] = [];
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
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
});
return session.client_reference_id;
@ -105,7 +123,9 @@ export class SubscriptionService {
}
}
public getSubscription(aSubscriptions: Subscription[]) {
public getSubscription(
aSubscriptions: Subscription[]
): UserWithSettings['subscription'] {
if (aSubscriptions.length > 0) {
const latestSubscription = aSubscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
@ -113,12 +133,14 @@ export class SubscriptionService {
return {
expiresAt: latestSubscription.expiresAt,
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
return {
offer: 'default',
type: SubscriptionType.Basic
};
}

View File

@ -1,6 +1,8 @@
import { DataSource } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface LookupItem {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
currency: string;
dataSource: DataSource;
name: string;

View File

@ -1,15 +1,18 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
HttpException,
Inject,
Param,
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';
@ -21,7 +24,10 @@ import { SymbolService } from './symbol.service';
@Controller('symbol')
export class SymbolController {
public constructor(private readonly symbolService: SymbolService) {}
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolService: SymbolService
) {}
/**
* Must be before /:symbol
@ -33,7 +39,10 @@ export class SymbolController {
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try {
return this.symbolService.lookup(query.toLowerCase());
return this.symbolService.lookup({
query: query.toLowerCase(),
user: this.request.user
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -6,6 +6,7 @@ import {
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns';
@ -79,15 +80,24 @@ export class SymbolService {
};
}
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
public async lookup({
query,
user
}: {
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] };
if (!aQuery) {
if (!query) {
return results;
}
try {
const { items } = await this.dataProviderService.search(aQuery);
const { items } = await this.dataProviderService.search({
query,
user
});
results.items = items;
return results;
} catch (error) {

View File

@ -5,6 +5,7 @@ import type {
} from '@ghostfolio/common/types';
import {
IsBoolean,
IsISO8601,
IsIn,
IsNumber,
IsOptional,
@ -12,6 +13,10 @@ import {
} from 'class-validator';
export class UpdateUserSettingDto {
@IsNumber()
@IsOptional()
annualInterestRate?: number;
@IsOptional()
@IsString()
baseCurrency?: string;
@ -48,6 +53,14 @@ export class UpdateUserSettingDto {
@IsOptional()
locale?: string;
@IsNumber()
@IsOptional()
projectedTotalAmount?: number;
@IsISO8601()
@IsOptional()
retirementDate?: string;
@IsNumber()
@IsOptional()
savingsRate?: number;

View File

@ -1,6 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
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';
@ -31,7 +29,6 @@ import { UserService } from './user.service';
@Controller('user')
export class UserController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
@ -82,7 +79,7 @@ export class UserController {
const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({
role: hasAdmin ? 'USER' : 'ADMIN'
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
});
return {

View File

@ -4,16 +4,13 @@ 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,
UserSettings,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client';
import { sortBy } from 'lodash';
@ -97,6 +94,7 @@ export class UserService {
const {
accessToken,
Account,
Analytics,
authChallenge,
createdAt,
id,
@ -107,7 +105,12 @@ export class UserService {
thirdPartyId,
updatedAt
} = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true },
include: {
Account: true,
Analytics: true,
Settings: true,
Subscription: true
},
where: userWhereUniqueInput
});
@ -121,7 +124,8 @@ export class UserService {
role,
Settings,
thirdPartyId,
updatedAt
updatedAt,
activityCount: Analytics?.activityCount
};
if (user?.Settings) {
@ -154,15 +158,22 @@ export class UserService {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
}
let currentPermissions = getPermissions(user.role);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
}
let currentPermissions = getPermissions(user.role);
if (
Analytics?.activityCount % 25 === 0 &&
user.subscription?.type === 'Basic'
) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
}
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
}
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
@ -217,7 +228,11 @@ export class UserService {
return hash.digest('hex');
}
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
public async createUser({
data
}: {
data: Prisma.UserCreateInput;
}): Promise<User> {
if (!data?.provider) {
data.provider = 'ANONYMOUS';
}
@ -242,6 +257,14 @@ export class UserService {
}
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await this.prismaService.analytics.create({
data: {
User: { connect: { id: user.id } }
}
});
}
if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(
user.id,

View File

@ -1,4 +1,5 @@
import { cloneDeep, isObject } from 'lodash';
import Big from 'big.js';
import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) {
@ -27,3 +28,51 @@ 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)) {
if (option.valueMap['*'] || option.valueMap['*'] === null) {
redactedObject[option.attribute] = option.valueMap['*'];
} else if (option.valueMap[redactedObject[option.attribute]]) {
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 (isArray(redactedObject[property])) {
redactedObject[property] = redactedObject[property].map(
(currentObject) => {
return redactAttributes({ options, object: currentObject });
}
);
} else if (
isObject(redactedObject[property]) &&
!(redactedObject[property] instanceof Big)
) {
// Recursively call the function on the nested object
redactedObject[property] = redactAttributes({
options,
object: redactedObject[property]
});
}
}
}
}
return redactedObject;
}

View File

@ -1,5 +1,6 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
CallHandler,
ExecutionContext,
@ -22,65 +23,43 @@ export class RedactValuesInResponseInterceptor<T>
return next.handle().pipe(
map((data: any) => {
const request = context.switchToHttp().getRequest();
const hasImpersonationId = !!request.headers?.['impersonation-id'];
const hasImpersonationId =
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
if (
hasImpersonationId ||
this.userService.isRestrictedView(request.user)
) {
if (data.accounts) {
for (const accountId of Object.keys(data.accounts)) {
if (data.accounts[accountId]?.balance !== undefined) {
data.accounts[accountId].balance = null;
}
}
}
if (data.activities) {
data.activities = data.activities.map((activity: Activity) => {
if (activity.Account?.balance !== undefined) {
activity.Account.balance = null;
}
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;
}
data = redactAttributes({
object: data,
options: [
'balance',
'balanceInBaseCurrency',
'comment',
'convertedBalance',
'dividendInBaseCurrency',
'fee',
'feeInBaseCurrency',
'filteredValueInBaseCurrency',
'grossPerformance',
'investment',
'netPerformance',
'quantity',
'symbolMapping',
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'value',
'valueInBaseCurrency'
].map((attribute) => {
return {
attribute,
valueMap: {
'*': null
}
};
})
});
}
return data;

View File

@ -24,7 +24,7 @@ export class TransformDataSourceInRequestInterceptor<T>
const http = context.switchToHttp();
const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body.dataSource) {
request.body.dataSource = decodeDataSource(request.body.dataSource);
}

View File

@ -1,3 +1,4 @@
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { encodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
@ -5,7 +6,7 @@ import {
Injectable,
NestInterceptor
} from '@nestjs/common';
import { isArray } from 'lodash';
import { DataSource } from '@prisma/client';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@ -25,66 +26,24 @@ export class TransformDataSourceInResponseInterceptor<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
if (data.activities) {
data.activities.map((activity) => {
activity.SymbolProfile.dataSource = encodeDataSource(
activity.SymbolProfile.dataSource
);
return activity;
});
}
if (isArray(data.benchmarks)) {
data.benchmarks.map((benchmark) => {
benchmark.dataSource = encodeDataSource(benchmark.dataSource);
return benchmark;
});
}
if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource);
}
if (data.errors) {
for (const error of data.errors) {
if (error.dataSource) {
error.dataSource = encodeDataSource(error.dataSource);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
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,6 +1,7 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import * as bodyParser from 'body-parser';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
@ -9,15 +10,10 @@ async function bootstrap() {
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']
logger: environment.production
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
});
app.enableCors();
app.enableVersioning({
@ -33,6 +29,9 @@ async function bootstrap() {
})
);
// Support 10mb csv/json files for importing activities
app.use(bodyParser.json({ limit: '10mb' }));
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;
await app.listen(PORT, HOST, () => {

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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
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

@ -19,12 +19,10 @@ export class ConfigurationService {
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({
default: [DataSource.GHOSTFOLIO, DataSource.MANUAL, DataSource.YAHOO]
default: [DataSource.COINGECKO, 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 }),
ENABLE_FEATURE_IMPORT: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
@ -42,7 +40,7 @@ export class ConfigurationService {
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }),
RAPID_API_API_KEY: str({ default: '' }),
REDIS_HOST: host({ default: 'localhost' }),
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }),

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,23 +30,28 @@ 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) {
await this.dataGatheringService.addJobToQueue(
GATHER_ASSET_PROFILE_PROCESS,
{
dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
}
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
}
};
})
);
}
}

View File

@ -2,8 +2,7 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.se
import {
DATA_GATHERING_QUEUE,
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
QUEUE_JOB_STATUS_LIST
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
@ -11,7 +10,8 @@ import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { JobOptions, Queue } from 'bull';
import { format, subDays } from 'date-fns';
import { format, min, subDays, subYears } from 'date-fns';
import { isEmpty } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
@ -34,17 +34,22 @@ export class DataGatheringService {
private readonly symbolProfileService: SymbolProfileService
) {}
public async addJobToQueue(name: string, data: any, options?: JobOptions) {
const hasJob = await this.hasJob(name, data);
public async addJobToQueue({
data,
name,
opts
}: {
data: any;
name: string;
opts?: JobOptions;
}) {
return this.dataGatheringQueue.add(name, data, opts);
}
if (hasJob) {
Logger.log(
`Job ${name} with data ${JSON.stringify(data)} already exists.`,
'DataGatheringService'
);
} else {
return this.dataGatheringQueue.add(name, data, options);
}
public async addJobsToQueue(
jobs: { data: any; name: string; opts?: JobOptions }[]
) {
return this.dataGatheringQueue.addBulk(jobs);
}
public async gather7Days() {
@ -152,10 +157,11 @@ export class DataGatheringService {
countries,
currency,
dataSource,
isin,
name,
sectors,
url
} = assetProfiles[symbol];
} = assetProfile;
try {
await this.prismaService.symbolProfile.upsert({
@ -165,6 +171,7 @@ export class DataGatheringService {
countries,
currency,
dataSource,
isin,
name,
sectors,
symbol,
@ -175,6 +182,7 @@ export class DataGatheringService {
assetSubClass,
countries,
currency,
isin,
name,
sectors,
url
@ -206,68 +214,22 @@ export class DataGatheringService {
}
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
if (dataSource === 'MANUAL') {
continue;
}
await this.addJobToQueue(
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
{
dataSource,
date,
symbol
},
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
);
}
}
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate =
(
await this.prismaService.order.findFirst({
orderBy: [{ date: 'asc' }]
})
)?.date ?? new Date();
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
await this.addJobsToQueue(
aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
data: {
dataSource,
date,
symbol
},
scraperConfiguration: true,
symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}`
}
}
};
})
).map((symbolProfile) => {
return {
...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate
};
});
return [...currencyPairsToGather, ...symbolProfilesToGather];
);
}
public async getUniqueAssets(): Promise<UniqueAsset[]> {
@ -278,7 +240,6 @@ export class DataGatheringService {
return symbolProfiles
.filter(({ dataSource }) => {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAPID_API
);
@ -300,17 +261,12 @@ export class DataGatheringService {
dataSource: true,
scraperConfiguration: true,
symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
}
});
// Only consider symbols with incomplete market data for the last
// 7 days
const symbolsNotToGather = (
const symbolsWithCompleteMarketData = (
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
@ -328,8 +284,14 @@ export class DataGatheringService {
});
const symbolProfilesToGather = symbolProfiles
.filter(({ symbol }) => {
return !symbolsNotToGather.includes(symbol);
.filter(({ dataSource, scraperConfiguration, symbol }) => {
const manualDataSourceWithScraperConfiguration =
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return (
!symbolsWithCompleteMarketData.includes(symbol) &&
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
);
})
.map((symbolProfile) => {
return {
@ -341,7 +303,7 @@ export class DataGatheringService {
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.filter(({ symbol }) => {
return !symbolsNotToGather.includes(symbol);
return !symbolsWithCompleteMarketData.includes(symbol);
})
.map(({ dataSource, symbol }) => {
return {
@ -354,17 +316,56 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather];
}
private async hasJob(name: string, data: any) {
const jobs = await this.dataGatheringQueue.getJobs(
QUEUE_JOB_STATUS_LIST.filter((status) => {
return status !== 'completed';
})
);
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate =
(
await this.prismaService.order.findFirst({
orderBy: [{ date: 'asc' }]
})
)?.date ?? new Date();
return jobs.some((job) => {
return (
job.name === name && JSON.stringify(job.data) === JSON.stringify(data)
);
});
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: min([startDate, subYears(new Date(), 10)])
};
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
})
)
.filter((symbolProfile) => {
const manualDataSourceWithScraperConfiguration =
symbolProfile.dataSource === 'MANUAL' &&
!isEmpty(symbolProfile.scraperConfiguration);
return (
symbolProfile.dataSource !== 'MANUAL' ||
manualDataSourceWithScraperConfiguration
);
})
.map((symbolProfile) => {
return {
...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate
};
});
return [...currencyPairsToGather, ...symbolProfilesToGather];
}
}

View File

@ -33,10 +33,25 @@ export class AlphaVantageService implements DataProviderInterface {
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
dataSource: this.getName(),
symbol: aSymbol
};
}
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',

View File

@ -0,0 +1,198 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import bent from 'bent';
import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable()
export class CoinGeckoService implements DataProviderInterface {
private baseCurrency: string;
private readonly URL = 'https://api.coingecko.com/api/v3';
public constructor(
private readonly configurationService: ConfigurationService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) {
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {
assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency,
dataSource: this.getName(),
symbol: aSymbol
};
try {
const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200);
const { name } = await get();
response.name = name;
} catch (error) {
Logger.error(error, 'CoinGeckoService');
}
return response;
}
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',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const get = bent(
`${
this.URL
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime(
from
)}&to=${getUnixTime(to)}`,
'GET',
'json',
200
);
const { prices } = await get();
const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[aSymbol]: {}
};
for (const [timestamp, marketPrice] of prices) {
result[aSymbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = {
marketPrice
};
}
return result;
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getMaxNumberOfSymbolsPerRequest() {
return 50;
}
public getName(): DataSource {
return DataSource.COINGECKO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const results: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return {};
}
try {
const get = bent(
`${this.URL}/simple/price?ids=${aSymbols.join(
','
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`,
'GET',
'json',
200
);
const response = await get();
for (const symbol in response) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
results[symbol] = {
currency: this.baseCurrency,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO,
marketPrice: response[symbol][this.baseCurrency.toLowerCase()],
marketState: 'open'
};
}
}
} catch (error) {
Logger.error(error, 'CoinGeckoService');
}
return results;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
try {
const get = bent(
`${this.URL}/search?query=${aQuery}`,
'GET',
'json',
200
);
const { coins } = await get();
items = coins.map(({ id: symbol, name }) => {
return {
name,
symbol,
assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency,
dataSource: this.getName()
};
});
} catch (error) {
Logger.error(error, 'CoinGeckoService');
}
return { items };
}
private getDataProviderInfo(): DataProviderInfo {
return {
name: 'CoinGecko',
url: 'https://coingecko.com'
};
}
}

View File

@ -1,15 +1,27 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { Module } from '@nestjs/common';
@Module({
exports: ['DataEnhancers', TrackinsightDataEnhancerService],
exports: [
'DataEnhancers',
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService
],
imports: [ConfigurationModule, CryptocurrencyModule],
providers: [
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService,
{
inject: [TrackinsightDataEnhancerService],
inject: [
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService
],
provide: 'DataEnhancers',
useFactory: (trackinsight) => [trackinsight]
},
TrackinsightDataEnhancerService
useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance]
}
]
})
export class DataEnhancerModule {}

View File

@ -1,13 +1,15 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import bent from 'bent';
const getJSON = bent('json');
@Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com/holdings';
private static baseUrl = 'https://data.trackinsight.com';
private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = {
'Russian Federation': 'Russia'
@ -32,17 +34,29 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response;
}
const result = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
const profile = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json`
).catch(() => {
return {};
});
const isin = profile.isin?.split(';')?.[0];
if (isin) {
response.isin = isin;
}
const holdings = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
).catch(() => {
return getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${
symbol.split('.')[0]
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
symbol.split('.')?.[0]
}.json`
);
});
if (result.weight < 0.95) {
if (holdings?.weight < 0.95) {
// Skip if data is inaccurate
return response;
}
@ -52,7 +66,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.countries as unknown as Country[]).length === 0
) {
response.countries = [];
for (const [name, value] of Object.entries<any>(result.countries)) {
for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {}
)) {
let countryCode: string;
for (const [key, country] of Object.entries<any>(
@ -80,7 +96,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.sectors as unknown as Sector[]).length === 0
) {
response.sectors = [];
for (const [name, value] of Object.entries<any>(result.sectors)) {
for (const [name, value] of Object.entries<any>(
holdings?.sectors ?? {}
)) {
response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight

View File

@ -1,7 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceService } from './yahoo-finance.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
jest.mock(
'@ghostfolio/api/services/cryptocurrency/cryptocurrency.service',
@ -25,16 +25,16 @@ jest.mock(
}
);
describe('YahooFinanceService', () => {
describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceService: YahooFinanceService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService();
yahooFinanceService = new YahooFinanceService(
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService
);
@ -42,25 +42,37 @@ describe('YahooFinanceService', () => {
it('convertFromYahooFinanceSymbol', async () => {
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('BRK-B')
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
'BRK-B'
)
).toEqual('BRK-B');
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('BTC-USD')
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
'BTC-USD'
)
).toEqual('BTCUSD');
expect(
await yahooFinanceService.convertFromYahooFinanceSymbol('EURUSD=X')
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
'EURUSD=X'
)
).toEqual('EURUSD');
});
it('convertToYahooFinanceSymbol', async () => {
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('BTCUSD')
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
'BTCUSD'
)
).toEqual('BTC-USD');
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD')
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
'DOGEUSD'
)
).toEqual('DOGE-USD');
expect(
await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF')
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
'USDCHF'
)
).toEqual('USDCHF=X');
});
});

View File

@ -0,0 +1,325 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import { countries } from 'countries-list';
import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
);
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
symbol = `${this.baseCurrency}${symbol}`;
}
return symbol.replace('=X', '');
}
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF -> USDCHF=X
* Cryptocurrency: BTCUSD -> BTC-USD
* DOGEUSD -> DOGE-USD
*/
public convertToYahooFinanceSymbol(aSymbol: string) {
if (
aSymbol.includes(this.baseCurrency) &&
aSymbol.length > this.baseCurrency.length
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
)
) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
)
) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace(
new RegExp(`-?${this.baseCurrency}$`),
`-${this.baseCurrency}`
);
}
}
return aSymbol;
}
public async enhance({
response,
symbol
}: {
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
if (response.dataSource !== 'YAHOO' && !response.isin) {
return response;
}
try {
let yahooSymbol: string;
if (response.dataSource === 'YAHOO') {
yahooSymbol = symbol;
} else {
const { quotes } = await yahooFinance.search(response.isin);
yahooSymbol = quotes[0].symbol;
}
const { countries, sectors, url } = await this.getAssetProfile(
yahooSymbol
);
if (countries) {
response.countries = countries;
}
if (sectors) {
response.sectors = sectors;
}
if (url) {
response.url = url;
}
} catch (error) {
Logger.error(error, 'YahooFinanceDataEnhancerService');
}
return response;
}
public formatName({
longName,
quoteType,
shortName,
symbol
}: {
longName: Price['longName'];
quoteType: Price['quoteType'];
shortName: Price['shortName'];
symbol: Price['symbol'];
}) {
let name = longName;
if (name) {
name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares V PLC - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');
name = name.replace('VanEck ETFs N.V. - ', '');
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
name = name.replace('Vanguard Funds Public Limited Company - ', '');
name = name.replace('Vanguard Index Funds - ', '');
name = name.replace('Xtrackers (IE) Plc - ', '');
}
if (quoteType === 'FUTURE') {
// "Gold Jun 22" -> "Gold"
name = shortName?.slice(0, -7);
}
return name || shortName || symbol;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {};
try {
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
const assetProfile = await yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile', 'topHoldings']
});
const { assetClass, assetSubClass } = this.parseAssetClass({
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName
});
response.assetClass = assetClass;
response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency;
response.dataSource = this.getName();
response.name = this.formatName({
longName: assetProfile.price.longName,
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol
});
response.symbol = aSymbol;
if (assetSubClass === AssetSubClass.MUTUALFUND) {
response.sectors = [];
for (const sectorWeighting of assetProfile.topHoldings
?.sectorWeightings ?? []) {
for (const [sector, weight] of Object.entries(sectorWeighting)) {
response.sectors.push({ weight, name: this.parseSector(sector) });
}
}
} else if (
assetSubClass === AssetSubClass.STOCK &&
assetProfile.summaryProfile?.country
) {
// Add country if asset is stock and country available
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === assetProfile.summaryProfile?.country;
});
if (code) {
response.countries = [{ code, weight: 1 }];
}
} catch {}
if (assetProfile.summaryProfile?.sector) {
response.sectors = [
{ name: assetProfile.summaryProfile?.sector, weight: 1 }
];
}
}
const url = assetProfile.summaryProfile?.website;
if (url) {
response.url = url;
}
} catch (error) {
Logger.error(error, 'YahooFinanceService');
}
return response;
}
public getName() {
return DataSource.YAHOO;
}
public parseAssetClass({
quoteType,
shortName
}: {
quoteType: string;
shortName: string;
}): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (quoteType?.toLowerCase()) {
case 'cryptocurrency':
assetClass = AssetClass.CASH;
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break;
case 'equity':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'etf':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
case 'future':
assetClass = AssetClass.COMMODITY;
assetSubClass = AssetSubClass.COMMODITY;
if (
shortName?.toLowerCase()?.startsWith('gold') ||
shortName?.toLowerCase()?.startsWith('palladium') ||
shortName?.toLowerCase()?.startsWith('platinum') ||
shortName?.toLowerCase()?.startsWith('silver')
) {
assetSubClass = AssetSubClass.PRECIOUS_METAL;
}
break;
case 'mutualfund':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.MUTUALFUND;
break;
}
return { assetClass, assetSubClass };
}
private parseSector(aString: string): string {
let sector = UNKNOWN_KEY;
switch (aString) {
case 'basic_materials':
sector = 'Basic Materials';
break;
case 'communication_services':
sector = 'Communication Services';
break;
case 'consumer_cyclical':
sector = 'Consumer Cyclical';
break;
case 'consumer_defensive':
sector = 'Consumer Staples';
break;
case 'energy':
sector = 'Energy';
break;
case 'financial_services':
sector = 'Financial Services';
break;
case 'healthcare':
sector = 'Healthcare';
break;
case 'industrials':
sector = 'Industrials';
break;
case 'realestate':
sector = 'Real Estate';
break;
case 'technology':
sector = 'Technology';
break;
case 'utilities':
sector = 'Utilities';
break;
}
return sector;
}
}

View File

@ -1,8 +1,8 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
@ -11,20 +11,25 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { DataEnhancerModule } from './data-enhancer/data-enhancer.module';
import { YahooFinanceDataEnhancerService } from './data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderService } from './data-provider.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@Module({
imports: [
ConfigurationModule,
CryptocurrencyModule,
DataEnhancerModule,
PrismaModule,
PropertyModule,
SymbolProfileModule
],
providers: [
AlphaVantageService,
CoinGeckoService,
DataProviderService,
EodHistoricalDataService,
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -32,8 +37,8 @@ import { DataProviderService } from './data-provider.service';
{
inject: [
AlphaVantageService,
CoinGeckoService,
EodHistoricalDataService,
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -42,23 +47,24 @@ import { DataProviderService } from './data-provider.service';
provide: 'DataProviderInterfaces',
useFactory: (
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rapidApiService,
yahooFinanceService
) => [
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rapidApiService,
yahooFinanceService
]
}
},
YahooFinanceDataEnhancerService
],
exports: [DataProviderService, GhostfolioScraperApiService]
exports: [DataProviderService, YahooFinanceService]
})
export class DataProviderModule {}

View File

@ -8,20 +8,56 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns';
import { groupBy, isEmpty } from 'lodash';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
@Injectable()
export class DataProviderService {
private dataProviderMapping: { [dataProviderName: string]: string };
public constructor(
private readonly configurationService: ConfigurationService,
@Inject('DataProviderInterfaces')
private readonly dataProviderInterfaces: DataProviderInterface[],
private readonly prismaService: PrismaService
) {}
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) {
this.initialize();
}
public async initialize() {
this.dataProviderMapping =
((await this.propertyService.getByKey(PROPERTY_DATA_SOURCE_MAPPING)) as {
[dataProviderName: string]: string;
}) ?? {};
}
public async getDividends({
dataSource,
from,
granularity = 'day',
symbol,
to
}: {
dataSource: DataSource;
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return this.getDataProvider(DataSource[dataSource]).getDividends({
from,
granularity,
symbol,
to
});
}
public async getHistorical(
aItems: IDataGatheringItem[],
@ -239,26 +275,51 @@ export class DataProviderService {
return response;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
query,
user
}: {
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = [];
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push(
this.getDataProvider(DataSource[dataSource]).search(aQuery)
);
if (query?.length < 2) {
return { items: lookupItems };
}
let dataSources = this.configurationService.get('DATA_SOURCES');
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === 'Basic'
) {
dataSources = dataSources.filter((dataSource) => {
return !this.isPremiumDataSource(DataSource[dataSource]);
});
}
for (const dataSource of dataSources) {
promises.push(this.getDataProvider(DataSource[dataSource]).search(query));
}
const searchResults = await Promise.all(promises);
searchResults.forEach((searchResult) => {
lookupItems = lookupItems.concat(searchResult.items);
searchResults.forEach(({ items }) => {
if (items?.length > 0) {
lookupItems = lookupItems.concat(items);
}
});
const filteredItems = lookupItems.filter((lookupItem) => {
// Only allow symbols with supported currency
return lookupItem.currency ? true : false;
});
const filteredItems = lookupItems
.filter((lookupItem) => {
// Only allow symbols with supported currency
return lookupItem.currency ? true : false;
})
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
});
return {
items: filteredItems
@ -267,6 +328,21 @@ export class DataProviderService {
private getDataProvider(providerName: DataSource) {
for (const dataProviderInterface of this.dataProviderInterfaces) {
if (this.dataProviderMapping[dataProviderInterface.getName()]) {
const mappedDataProviderInterface = this.dataProviderInterfaces.find(
(currentDataProviderInterface) => {
return (
currentDataProviderInterface.getName() ===
this.dataProviderMapping[dataProviderInterface.getName()]
);
}
);
if (mappedDataProviderInterface) {
return mappedDataProviderInterface;
}
}
if (dataProviderInterface.getName() === providerName) {
return dataProviderInterface;
}
@ -274,4 +350,9 @@ export class DataProviderService {
throw new Error('No data provider has been found.');
}
private isPremiumDataSource(aDataSource: DataSource) {
const premiumDataSources: DataSource[] = [DataSource.EOD_HISTORICAL_DATA];
return premiumDataSources.includes(aDataSource);
}
}

View File

@ -5,13 +5,17 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import bent from 'bent';
import { format } from 'date-fns';
import { format, isToday } from 'date-fns';
@Injectable()
export class EodHistoricalDataService implements DataProviderInterface {
@ -19,8 +23,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService
private readonly configurationService: ConfigurationService
) {
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
}
@ -32,11 +35,33 @@ export class EodHistoricalDataService implements DataProviderInterface {
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(aSymbol);
return {
dataSource: this.getName()
assetClass: searchResult?.assetClass,
assetSubClass: searchResult?.assetSubClass,
currency: this.convertCurrency(searchResult?.currency),
dataSource: this.getName(),
isin: searchResult?.isin,
name: searchResult?.name,
symbol: aSymbol
};
}
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',
@ -108,32 +133,38 @@ export class EodHistoricalDataService implements DataProviderInterface {
200
);
const [response, symbolProfiles] = await Promise.all([
const [realTimeResponse, searchResponse] = await Promise.all([
get(),
this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => {
return {
symbol,
dataSource: DataSource.EOD_HISTORICAL_DATA
};
})
)
this.search(aSymbols[0])
]);
const quotes = aSymbols.length === 1 ? [response] : response;
const quotes =
aSymbols.length === 1 ? [realTimeResponse] : realTimeResponse;
return quotes.reduce((result, item, index, array) => {
result[item.code] = {
currency: symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === item.code;
})?.currency,
dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: item.close,
marketState: 'delayed'
};
return quotes.reduce(
(
result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp }
) => {
const currency = this.convertCurrency(
searchResponse?.items[0]?.currency
);
return result;
}, {});
if (currency) {
result[code] = {
currency,
dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000))
? 'open'
: 'closed'
};
}
return result;
},
{}
);
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
}
@ -142,6 +173,120 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
const searchResult = await this.getSearchResult(aQuery);
return {
items: searchResult
.filter(({ symbol }) => {
return !symbol.toLowerCase().endsWith('forex');
})
.map(
({
assetClass,
assetSubClass,
currency,
dataSource,
name,
symbol
}) => {
return {
assetClass,
assetSubClass,
dataSource,
name,
symbol,
currency: this.convertCurrency(currency)
};
}
)
};
}
private convertCurrency(aCurrency: string) {
let currency = aCurrency;
if (currency === 'GBX') {
currency = 'GBp';
}
return currency;
}
private async getSearchResult(aQuery: string): Promise<
(LookupItem & {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
isin: string;
})[]
> {
let searchResult = [];
try {
const get = bent(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
'GET',
'json',
200
);
const response = await get();
searchResult = response.map(
({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => {
const { assetClass, assetSubClass } = this.parseAssetClass({
Exchange,
Type
});
return {
assetClass,
assetSubClass,
isin,
name,
currency: this.convertCurrency(Currency),
dataSource: this.getName(),
symbol: `${Code}.${Exchange}`
};
}
);
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
}
return searchResult;
}
private parseAssetClass({
Exchange,
Type
}: {
Exchange: string;
Type: string;
}): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (Type?.toLowerCase()) {
case 'common stock':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'currency':
assetClass = AssetClass.CASH;
if (Exchange?.toLowerCase() === 'cc') {
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
}
break;
case 'etf':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
}
return { assetClass, assetSubClass };
}
}

View File

@ -1,180 +0,0 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
public constructor(
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) {
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
};
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const symbol = aSymbol;
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration;
if (defaultMarketPrice) {
const historical: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let date = from;
while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice
};
date = addDays(date, 1);
}
return historical;
} else if (selector === undefined || url === undefined) {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
const value = extractNumberFromString($(selector).text());
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value
}
}
};
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getName(): DataSource {
return DataSource.GHOSTFOLIO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return response;
}
try {
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],
orderBy: {
date: 'desc'
},
take: aSymbols.length,
where: {
symbol: {
in: aSymbols
}
}
});
for (const symbolProfile of symbolProfiles) {
response[symbolProfile.symbol] = {
currency: symbolProfile.currency,
dataSource: this.getName(),
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
}).marketPrice,
marketState: 'delayed'
};
}
return response;
} catch (error) {
Logger.error(error, 'GhostfolioScraperApiService');
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
}

View File

@ -30,10 +30,25 @@ export class GoogleSheetsService implements DataProviderInterface {
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
dataSource: this.getName(),
symbol: aSymbol
};
}
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',
@ -131,6 +146,8 @@ export class GoogleSheetsService implements DataProviderInterface {
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
assetClass: true,
assetSubClass: true,
currency: true,
dataSource: true,
name: true,

View File

@ -11,6 +11,18 @@ export interface DataProviderInterface {
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
getDividends({
from,
granularity,
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}): Promise<{ [date: string]: IDataProviderHistoricalResponse }>;
getHistorical(
aSymbol: string,
aGranularity: Granularity,

View File

@ -6,9 +6,18 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio';
import { isUUID } from 'class-validator';
import { addDays, format, isBefore } from 'date-fns';
@Injectable()
export class ManualService implements DataProviderInterface {
@ -18,17 +27,32 @@ export class ManualService implements DataProviderInterface {
) {}
public canHandle(symbol: string) {
return false;
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
dataSource: this.getName(),
symbol: aSymbol
};
}
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',
@ -37,7 +61,57 @@ export class ManualService implements DataProviderInterface {
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
return {};
try {
const symbol = aSymbol;
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration ?? {};
if (defaultMarketPrice) {
const historical: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let date = from;
while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice
};
date = addDays(date, 1);
}
return historical;
} else if (selector === undefined || url === undefined) {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
const value = extractNumberFromString($(selector).text());
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value
}
}
};
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getName(): DataSource {
@ -74,10 +148,9 @@ export class ManualService implements DataProviderInterface {
response[symbolProfile.symbol] = {
currency: symbolProfile.currency,
dataSource: this.getName(),
marketPrice:
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
})?.marketPrice ?? 0,
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
})?.marketPrice,
marketState: 'delayed'
};
}
@ -91,8 +164,10 @@ export class ManualService implements DataProviderInterface {
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
let items = await this.prismaService.symbolProfile.findMany({
select: {
assetClass: true,
assetSubClass: true,
currency: true,
dataSource: true,
name: true,
@ -118,6 +193,11 @@ export class ManualService implements DataProviderInterface {
}
});
items = items.filter(({ symbol }) => {
// Remove UUID symbols (activities of type ITEM)
return !isUUID(symbol);
});
return { items };
}
}

View File

@ -27,10 +27,25 @@ export class RapidApiService implements DataProviderInterface {
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
dataSource: this.getName(),
symbol: aSymbol
};
}
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',

View File

@ -1,26 +1,19 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns';
import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
@ -28,7 +21,8 @@ export class YahooFinanceService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@ -37,131 +31,75 @@ export class YahooFinanceService implements DataProviderInterface {
return true;
}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
);
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
symbol = `${this.baseCurrency}${symbol}`;
}
return symbol.replace('=X', '');
}
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF -> USDCHF=X
* Cryptocurrency: BTCUSD -> BTC-USD
* DOGEUSD -> DOGE-USD
*/
public convertToYahooFinanceSymbol(aSymbol: string) {
if (
aSymbol.includes(this.baseCurrency) &&
aSymbol.length > this.baseCurrency.length
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
)
) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
)
) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace(
new RegExp(`-?${this.baseCurrency}$`),
`-${this.baseCurrency}`
);
}
}
return aSymbol;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {};
const { assetClass, assetSubClass, currency, name } =
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
try {
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
const assetProfile = await yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile', 'topHoldings']
});
return {
assetClass,
assetSubClass,
currency,
name,
dataSource: this.getName(),
symbol: aSymbol
};
}
const { assetClass, assetSubClass } = this.parseAssetClass(
assetProfile.price
);
response.assetClass = assetClass;
response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency;
response.dataSource = this.getName();
response.name = this.formatName({
longName: assetProfile.price.longName,
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol
});
response.symbol = aSymbol;
if (assetSubClass === AssetSubClass.MUTUALFUND) {
response.sectors = [];
for (const sectorWeighting of assetProfile.topHoldings
?.sectorWeightings ?? []) {
for (const [sector, weight] of Object.entries(sectorWeighting)) {
response.sectors.push({ weight, name: this.parseSector(sector) });
}
}
} else if (
assetSubClass === AssetSubClass.STOCK &&
assetProfile.summaryProfile?.country
) {
// Add country if asset is stock and country available
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === assetProfile.summaryProfile?.country;
});
if (code) {
response.countries = [{ code, weight: 1 }];
}
} catch {}
if (assetProfile.summaryProfile?.sector) {
response.sectors = [
{ name: assetProfile.summaryProfile?.sector, weight: 1 }
];
}
}
const url = assetProfile.summaryProfile?.website;
if (url) {
response.url = url;
}
} catch (error) {
throw new Error(
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
error.name
}] ${error.message}`
);
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
return response;
try {
const historicalResult = await yahooFinance.historical(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
{
events: 'dividends',
interval: granularity === 'month' ? '1mo' : '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
);
const response: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
for (const historicalItem of historicalResult) {
response[format(historicalItem.date, DATE_FORMAT)] = {
marketPrice: this.getConvertedValue({
symbol,
value: historicalItem.dividends
})
};
}
return response;
} catch (error) {
Logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`,
'YahooFinanceService'
);
return {};
}
}
public async getHistorical(
@ -176,11 +114,11 @@ export class YahooFinanceService implements DataProviderInterface {
to = addDays(to, 1);
}
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
try {
const historicalResult = await yahooFinance.historical(
yahooFinanceSymbol,
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
aSymbol
),
{
interval: '1d',
period1: format(from, DATE_FORMAT),
@ -192,27 +130,14 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {};
// Convert symbol back
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {};
response[aSymbol] = {};
for (const historicalItem of historicalResult) {
let marketPrice = historicalItem.close;
if (symbol === `${this.baseCurrency}GBp`) {
// Convert GPB to GBp (pence)
marketPrice = new Big(marketPrice).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) {
// Convert ILS to ILA
marketPrice = new Big(marketPrice).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ZAc`) {
// Convert ZAR to ZAc (cents)
marketPrice = new Big(marketPrice).mul(100).toNumber();
}
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
marketPrice,
response[aSymbol][format(historicalItem.date, DATE_FORMAT)] = {
marketPrice: this.getConvertedValue({
symbol: aSymbol,
value: historicalItem.close
}),
performance: historicalItem.open - historicalItem.close
};
}
@ -243,7 +168,7 @@ export class YahooFinanceService implements DataProviderInterface {
return {};
}
const yahooFinanceSymbols = aSymbols.map((symbol) =>
this.convertToYahooFinanceSymbol(symbol)
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
);
try {
@ -253,7 +178,10 @@ export class YahooFinanceService implements DataProviderInterface {
for (const quote of quotes) {
// Convert symbols back
const symbol = this.convertFromYahooFinanceSymbol(quote.symbol);
const symbol =
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
quote.symbol
);
response[symbol] = {
currency: quote.currency,
@ -370,15 +298,24 @@ export class YahooFinanceService implements DataProviderInterface {
return currentQuote.symbol === marketDataItem.symbol;
});
const symbol = this.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
const symbol =
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
const { assetClass, assetSubClass } =
this.yahooFinanceDataEnhancerService.parseAssetClass({
quoteType: quote.quoteType,
shortName: quote.shortname
});
items.push({
assetClass,
assetSubClass,
symbol,
currency: marketDataItem.currency,
dataSource: this.getName(),
name: this.formatName({
name: this.yahooFinanceDataEnhancerService.formatName({
longName: quote.longname,
quoteType: quote.quoteType,
shortName: quote.shortname,
@ -393,122 +330,24 @@ export class YahooFinanceService implements DataProviderInterface {
return { items };
}
private formatName({
longName,
quoteType,
shortName,
symbol
private getConvertedValue({
symbol,
value
}: {
longName: Price['longName'];
quoteType: Price['quoteType'];
shortName: Price['shortName'];
symbol: Price['symbol'];
symbol: string;
value: number;
}) {
let name = longName;
if (name) {
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');
name = name.replace('VanEck ETFs N.V. - ', '');
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
name = name.replace('Vanguard Funds Public Limited Company - ', '');
name = name.replace('Vanguard Index Funds - ', '');
name = name.replace('Xtrackers (IE) Plc - ', '');
if (symbol === `${this.baseCurrency}GBp`) {
// Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) {
// Convert ILS to ILA
return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ZAc`) {
// Convert ZAR to ZAc (cents)
return new Big(value).mul(100).toNumber();
}
if (quoteType === 'FUTURE') {
// "Gold Jun 22" -> "Gold"
name = shortName?.slice(0, -6);
}
return name || shortName || symbol;
}
private parseAssetClass(aPrice: Price): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (aPrice?.quoteType?.toLowerCase()) {
case 'cryptocurrency':
assetClass = AssetClass.CASH;
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break;
case 'equity':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'etf':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
case 'future':
assetClass = AssetClass.COMMODITY;
assetSubClass = AssetSubClass.COMMODITY;
if (
aPrice?.shortName?.toLowerCase()?.startsWith('gold') ||
aPrice?.shortName?.toLowerCase()?.startsWith('palladium') ||
aPrice?.shortName?.toLowerCase()?.startsWith('platinum') ||
aPrice?.shortName?.toLowerCase()?.startsWith('silver')
) {
assetSubClass = AssetSubClass.PRECIOUS_METAL;
}
break;
case 'mutualfund':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.MUTUALFUND;
break;
}
return { assetClass, assetSubClass };
}
private parseSector(aString: string): string {
let sector = UNKNOWN_KEY;
switch (aString) {
case 'basic_materials':
sector = 'Basic Materials';
break;
case 'communication_services':
sector = 'Communication Services';
break;
case 'consumer_cyclical':
sector = 'Consumer Cyclical';
break;
case 'consumer_defensive':
sector = 'Consumer Staples';
break;
case 'energy':
sector = 'Energy';
break;
case 'financial_services':
sector = 'Financial Services';
break;
case 'healthcare':
sector = 'Healthcare';
break;
case 'industrials':
sector = 'Industrials';
break;
case 'realestate':
sector = 'Real Estate';
break;
case 'technology':
sector = 'Technology';
break;
case 'utilities':
sector = 'Utilities';
break;
}
return sector;
return value;
}
}

View File

@ -62,7 +62,8 @@ export class ExchangeRateDataService {
getYesterday()
);
if (Object.keys(result).length !== this.currencyPairs.length) {
// TODO: add fallback
/*if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback
// if historical data is not fully available
const historicalData = await this.dataProviderService.getQuotes(
@ -72,13 +73,15 @@ export class ExchangeRateDataService {
);
Object.keys(historicalData).forEach((key) => {
result[key] = {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: historicalData[key].marketPrice
}
};
if (isNumber(historicalData[key].marketPrice)) {
result[key] = {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: historicalData[key].marketPrice
}
};
}
});
}
}*/
const resultExtended = result;
@ -168,7 +171,7 @@ export class ExchangeRateDataService {
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
}
let factor = 1;
let factor: number;
if (aFromCurrency !== aToCurrency) {
const dataSource = this.dataProviderService.getPrimaryDataSource();
@ -183,9 +186,29 @@ export class ExchangeRateDataService {
if (marketData?.marketPrice) {
factor = marketData?.marketPrice;
} else {
// TODO: Get from data provider service or calculate indirectly via base currency
// and market data
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
// Calculate indirectly via base currency
try {
const [
{ marketPrice: marketPriceBaseCurrencyFromCurrency },
{ marketPrice: marketPriceBaseCurrencyToCurrency }
] = await Promise.all([
this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aFromCurrency}`
}),
this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aToCurrency}`
})
]);
// Calculate the opposite direction
factor =
(1 / marketPriceBaseCurrencyFromCurrency) *
marketPriceBaseCurrencyToCurrency;
} catch {}
}
}
@ -193,12 +216,15 @@ export class ExchangeRateDataService {
return factor * aValue;
}
// Fallback with error, if currencies are not available
Logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`,
`No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format(
aDate,
DATE_FORMAT
)}`,
'ExchangeRateDataService'
);
return aValue;
return undefined;
}
private async prepareCurrencies(): Promise<string[]> {

View File

@ -8,9 +8,7 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string[];
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;

View File

@ -1,4 +1,4 @@
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import {
Account,
@ -28,6 +28,7 @@ export interface IDataProviderHistoricalResponse {
export interface IDataProviderResponse {
currency: string;
dataProviderInfo?: DataProviderInfo;
dataSource: DataSource;
marketPrice: number;
marketState: MarketState;

View File

@ -0,0 +1,11 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { PlatformService } from './platform.service';
@Module({
exports: [PlatformService],
imports: [PrismaModule],
providers: [PlatformService]
})
export class PlatformModule {}

View File

@ -0,0 +1,11 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class PlatformService {
public constructor(private readonly prismaService: PrismaService) {}
public async get() {
return this.prismaService.platform.findMany();
}
}

View File

@ -1,18 +0,0 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line.
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

View File

@ -3,12 +3,7 @@ export default {
displayName: 'client',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$'
}
},
globals: {},
coverageDirectory: '../../coverage/apps/client',
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
@ -16,7 +11,13 @@ export default {
'jest-preset-angular/build/serializers/html-comment'
],
transform: {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
'^.+.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$'
}
]
},
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
preset: '../../jest.preset.js'

View File

@ -65,7 +65,10 @@
"output": "./../assets/"
}
],
"styles": ["apps/client/src/styles.scss"],
"styles": [
"apps/client/src/styles/theme.scss",
"apps/client/src/styles.scss"
],
"scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true,
"extractLicenses": false,
@ -89,6 +92,10 @@
"baseHref": "/es/",
"localize": ["es"]
},
"development-fr": {
"baseHref": "/fr/",
"localize": ["fr"]
},
"development-it": {
"baseHref": "/it/",
"localize": ["it"]
@ -97,6 +104,10 @@
"baseHref": "/nl/",
"localize": ["nl"]
},
"development-pt": {
"baseHref": "/pt/",
"localize": ["pt"]
},
"production": {
"fileReplacements": [
{
@ -144,12 +155,18 @@
"development-es": {
"browserTarget": "client:build:development-es"
},
"development-fr": {
"browserTarget": "client:build:development-fr"
},
"development-it": {
"browserTarget": "client:build:development-it"
},
"development-nl": {
"browserTarget": "client:build:development-nl"
},
"development-pt": {
"browserTarget": "client:build:development-pt"
},
"production": {
"browserTarget": "client:build:production"
}
@ -164,8 +181,10 @@
"targetFiles": [
"messages.de.xlf",
"messages.es.xlf",
"messages.fr.xlf",
"messages.it.xlf",
"messages.nl.xlf"
"messages.nl.xlf",
"messages.pt.xlf"
]
}
},
@ -194,6 +213,10 @@
"baseHref": "/es/",
"translation": "apps/client/src/locales/messages.es.xlf"
},
"fr": {
"baseHref": "/fr/",
"translation": "apps/client/src/locales/messages.fr.xlf"
},
"it": {
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
@ -201,6 +224,10 @@
"nl": {
"baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf"
},
"pt": {
"baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf"
}
},
"sourceLocale": "en"

View File

@ -116,6 +116,27 @@ const routes: Routes = [
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
},
{
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
loadChildren: () =>
import(
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
},
{
path: 'blog/2023/02/ghostfolio-meets-umbrel',
loadChildren: () =>
import(
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
},
{
path: 'blog/2023/03/ghostfolio-reaches-1000-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.module'
).then((m) => m.ThousandStarsOnGitHubPageModule)
},
{
path: 'demo',
loadChildren: () =>
@ -215,9 +236,8 @@ const routes: Routes = [
// Preload all lazy loaded modules with the attribute preload === true
{
anchorScrolling: 'enabled',
preloadingStrategy: ModulePreloadService,
preloadingStrategy: ModulePreloadService
// enableTracing: true // <-- debugging purposes only
relativeLinkResolution: 'legacy'
}
)
],

View File

@ -3,6 +3,7 @@
class="position-fixed w-100"
[currentRoute]="currentRoute"
[info]="info"
[pageTitle]="pageTitle"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
@ -30,7 +31,8 @@
>
<div
*ngIf="!canCreateAccount && info?.systemMessage && user"
class="d-inline-block info-message px-3 py-2"
class="cursor-pointer d-inline-block info-message px-3 py-2 text-truncate"
(click)="onShowSystemMessage()"
>
{{ info.systemMessage }}
</div>

View File

@ -1,4 +1,4 @@
@import '~apps/client/src/styles/ghostfolio-style';
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
@ -13,9 +13,10 @@
margin-top: -0.5rem;
.info-message {
background-color: rgba(0, 0, 0, $alpha-hover);
background-color: rgba(var(--palette-foreground-text), 0.05);
border-radius: 2rem;
font-size: 80%;
max-width: 100%;
.a {
color: rgba(var(--palette-primary-500), 1);
@ -30,3 +31,13 @@
line-height: 1;
}
}
:host-context(.is-dark-theme) {
main {
.info-message-container {
.info-message {
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
}
}
}
}

View File

@ -1,20 +1,17 @@
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { Title } from '@angular/platform-browser';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
import {
primaryColorHex,
secondaryColorHex,
warnColorHex
} from '@ghostfolio/common/config';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ColorScheme } from '@ghostfolio/common/types';
import { MaterialCssVarsService } from 'angular-material-css-vars';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
@ -36,6 +33,7 @@ export class AppComponent implements OnDestroy, OnInit {
public currentYear = new Date().getFullYear();
public deviceType: string;
public info: InfoItem;
public pageTitle: string;
public user: User;
public version = environment.version;
@ -45,8 +43,9 @@ export class AppComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private materialCssVarsService: MaterialCssVarsService,
@Inject(DOCUMENT) private document: Document,
private router: Router,
private title: Title,
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
@ -66,6 +65,19 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentRoute = urlSegments[0].path;
this.info = this.dataService.fetchInfo();
if (this.deviceType === 'mobile') {
setTimeout(() => {
const index = this.title.getTitle().indexOf('');
const title =
index === -1
? ''
: this.title.getTitle().substring(0, index).trim();
this.pageTitle = title.length <= 15 ? title : 'Ghostfolio';
this.changeDetectorRef.markForCheck();
});
}
});
this.userService.stateChanged
@ -88,11 +100,15 @@ export class AppComponent implements OnDestroy, OnInit {
this.tokenStorageService.signOut();
}
public onShowSystemMessage() {
alert(this.info.systemMessage);
}
public onSignOut() {
this.tokenStorageService.signOut();
this.userService.remove();
document.location.href = '/';
document.location.href = `/${document.documentElement.lang}`;
}
public ngOnDestroy() {
@ -105,16 +121,20 @@ export class AppComponent implements OnDestroy, OnInit {
? userPreferredColorScheme === 'DARK'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
this.materialCssVarsService.setDarkTheme(isDarkTheme);
this.toggleThemeStyleClass(isDarkTheme);
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
if (!this.user?.settings.colorScheme) {
this.materialCssVarsService.setDarkTheme(event.matches);
this.toggleThemeStyleClass(event.matches);
}
});
}
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
this.materialCssVarsService.setAccentColor(secondaryColorHex);
this.materialCssVarsService.setWarnColor(warnColorHex);
private toggleThemeStyleClass(isDarkTheme: boolean) {
if (isDarkTheme) {
this.document.body.classList.add('is-dark-theme');
} else {
this.document.body.classList.remove('is-dark-theme');
}
}
}

View File

@ -1,20 +1,19 @@
import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
MatNativeDateModule
} from '@angular/material/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker';
import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
@ -25,6 +24,7 @@ import { DateFormats } from './adapter/date-formats';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { GfHeaderModule } from './components/header/header.module';
import { GfSubscriptionInterstitialDialogModule } from './components/subscription-interstitial-dialog/subscription-interstitial-dialog.module';
import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageService } from './core/language.service';
@ -40,15 +40,11 @@ export function NgxStripeFactory(): string {
BrowserAnimationsModule,
BrowserModule,
GfHeaderModule,
GfSubscriptionInterstitialDialogModule,
HttpClientModule,
MarkdownModule.forRoot(),
MatAutocompleteModule,
MatChipsModule,
MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme',
isAutoContrast: true,
lightThemeClass: 'is-light-theme'
}),
MatNativeDateModule,
MatSnackBarModule,
MatTooltipModule,

View File

@ -1,4 +1,4 @@
@import '~apps/client/src/styles/ghostfolio-style';
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;

View File

@ -1,7 +1,7 @@
:host {
display: block;
.mat-dialog-content {
.mat-mdc-dialog-content {
max-height: unset;
}
}

View File

@ -12,7 +12,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { AccountType } from '@prisma/client';
import { translate } from '@ghostfolio/ui/i18n';
import { format, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -27,7 +27,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss']
})
export class AccountDetailDialog implements OnDestroy, OnInit {
public accountType: AccountType;
public accountType: string;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
@ -59,7 +59,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
this.accountType = accountType;
this.accountType = translate(accountType);
this.name = name;
this.platformName = Platform?.name ?? '-';
this.valueInBaseCurrency = valueInBaseCurrency;

View File

@ -40,7 +40,6 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"

View File

@ -1,19 +1,9 @@
@import '~apps/client/src/styles/ghostfolio-style';
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
.mat-table {
td {
&.mat-footer-cell {
border-top: 1px solid
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
}
}
.mat-mdc-table {
th {
::ng-deep {
.mat-sort-header-container {
@ -23,16 +13,3 @@
}
}
}
:host-context(.is-dark-theme) {
.mat-table {
td {
&.mat-footer-cell {
border-top-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-dark-alpha)
);
}
}
}
}

View File

@ -9,8 +9,8 @@ import {
Output,
ViewChild
} from '@angular/core';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatSort } from '@angular/material/sort';
import { Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client';
import { get } from 'lodash';

View File

@ -1,10 +1,9 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { MatSortModule } from '@angular/material/sort';
import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -20,7 +19,6 @@ import { AccountsTableComponent } from './accounts-table.component';
GfSymbolIconModule,
GfValueModule,
MatButtonModule,
MatInputModule,
MatMenuModule,
MatSortModule,
MatTableModule,

View File

@ -2,10 +2,7 @@
<div class="row">
<div class="col">
<form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field
appearance="outline"
class="compact-with-outline without-hint w-100"
>
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select formControlName="status">
<mat-option></mat-option>
<mat-option

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