Compare commits

...

126 Commits

Author SHA1 Message Date
f79d60014b Release 2.6.0 (#2395) 2023-09-26 18:58:22 +02:00
5b7409d08e Feature/add tag management in admin control panel (#2389)
* Add tag management

* Update locales

* Update changelog
2023-09-26 18:56:09 +02:00
6230aa87e2 Feature/add hacktoberfest 2023 blog post (#2359)
* Add blog post: Hacktoberfest 2023

* Update changelog
2023-09-26 16:08:17 +02:00
8b615d2f56 Feature/upgrade prettier to version 3.0.3 (#2393)
* Upgrade prettier to version 3.0.3

* Update changelog
2023-09-26 15:07:20 +02:00
4100446cac Update OSS Friends (#2385) 2023-09-26 15:05:14 +02:00
ad3e6d637c Improve file name (#2391) 2023-09-26 15:04:32 +02:00
aa87262954 Feature/upgrade yahoo finance2 to version 2.7.0 (#2392)
* Upgrade yahoo-finance2 to version 2.7.0

* Update changelog
2023-09-26 07:51:27 +02:00
01b6bb5b99 Clean up (#2390) 2023-09-25 23:37:52 +02:00
884b7f4de7 Clean up (#2342) 2023-09-24 08:25:25 +02:00
3f8a2b47f9 Release 2.5.0 (#2380) 2023-09-23 20:11:46 +02:00
e2e4c9be3c Feature/skip data gathering for manual data source (#2379)
* Skip data gathering

* Update changelog
2023-09-23 20:10:08 +02:00
0f7c6ff0fe Bugfix/fix asset class of cash position for empty account (#2378)
* Fix assetClass and assetSubClass

* Update changelog
2023-09-23 19:52:28 +02:00
703a96f4db Add guard (#2377) 2023-09-23 19:45:15 +02:00
42c0560422 Feature/translate activity type (#2376)
* Introduce ActivityTypeComponent with localized label

* Update changelog
2023-09-23 16:44:03 +02:00
eb63802d01 Feature/extend supported date formats in activities import (#2362)
* Extend supported date formats in activities import

* Update changelog
2023-09-23 16:14:54 +02:00
6d9191a46f Feature/setup turkish (#2300)
* Setup Turkish

* Add Turkish translations

* Update changelog

---------

Co-authored-by: sadmimye <134071831+sadmimye@users.noreply.github.com>
2023-09-22 20:26:45 +02:00
6744245d8b Feature/extend personal finance tools pages 20230922 (#2369)
* Extend pages

* Refactoring
2023-09-22 20:04:40 +02:00
8f64a77a9d Clean up (#2329) 2023-09-21 19:56:31 +02:00
0d5fc7655b Improve wording (#2358) 2023-09-21 19:55:36 +02:00
c511ec7e33 Release 2.4.0 (#2356) 2023-09-19 20:38:50 +02:00
b12349a148 Feature/add support for interest on account level (#2354)
* Add support for interest

* Update changelog
2023-09-19 20:37:04 +02:00
f7e3a4c727 Update OSS Friends (#2352) 2023-09-19 20:27:14 +02:00
5f276469b7 Feature/upgrade prisma to version 5.3.1 (#2355)
* Upgrade prisma to version 5.3.1

* Update changelog
2023-09-19 19:37:33 +02:00
69e1d92ed3 Feature/unlock experimental features setting for all users (#2351)
* Unlock experimental features setting for all users

* Update changelog
2023-09-19 18:41:12 +02:00
ef2849aa6c Remove this (#2341) 2023-09-19 10:28:07 +02:00
c668d7b456 Feature/improve preselected currency in create or update activity dialog (#2349)
* Preselect currency based on account's currency

* Update changelog
2023-09-18 19:45:02 +02:00
e23bf62859 Fix Memory Leak on Data Gathering when server TZ is behind UTC (#2332)
* Fix for timezones behind UTC (the previous code converted the date to one day before (in local time) then added a day, which resulted in the same day after converting back to UTC and thus generating an infinite loop)

* Update changelog

---------

Co-authored-by: Rafael Claudio <rafacla@github.com>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-17 22:19:06 +02:00
54c5746d21 Release 2.3.0 (#2348) 2023-09-17 18:23:28 +02:00
7130ac7565 Feature/add support for fees on account level (#1954)
* Add migration

* Add business logic for fees

* Fix export for liabilities

* Update changelog
2023-09-17 18:20:54 +02:00
1851ae137f Release 2.2.0 (#2346) 2023-09-17 07:17:20 +02:00
6f6ff94979 Improve sidebar (#2343)
* Improve sidebar

* Improve style of system message

* Update changelog
2023-09-17 07:08:26 +02:00
7f25066f0f Remove ALPHA_VANTAGE_API_KEY (#2345) 2023-09-17 06:54:38 +02:00
fc795aaa8c Update postgres to version 15 in docker-compose files (#1596)
* Update postgres to version 15 in docker-compose files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-17 06:52:41 +02:00
d0112968e8 Feature/introduce sidebar navigation on desktop (#2340)
* Introduce sidebar navigation on desktop

* Update changelog
2023-09-16 14:40:05 +02:00
522025ffa0 Fix entry (#2339) 2023-09-16 14:37:08 +02:00
27bf662281 Release 2.1.0 (#2338) 2023-09-15 19:48:09 +02:00
93c27277c6 Extend sitemap with Italian pages (#2337) 2023-09-15 19:46:32 +02:00
5e6adfcef5 Feature/improve language localization for german 20230915 (#2336)
* Improve language localization

* Update changelog
2023-09-15 19:38:15 +02:00
ab691bb27a Feature/remove account type from user interface (#2335)
* Remove account type from user interface and set it optional

* Update changelog
2023-09-15 19:11:20 +02:00
8fc5676443 Feature/improve timeout of data source requests (#2330)
* Improve timeout

* Update changelog
2023-09-15 16:25:01 +02:00
1fe1e2fe0c Feature/improve read only mode (#2322)
* Improve read-only mode

* Update changelog
2023-09-15 16:22:39 +02:00
921d38a706 Feature/harmonize style of granted access user interface (#2326)
* Harmonize style

* Update changelog
2023-09-15 16:21:14 +02:00
6161d5e77c Feature/improve logger output of info service (#2331)
* Improve context of logger output

* Update changelog
2023-09-15 08:26:08 +02:00
369386f976 Add drop file functionality on import (#2323)
* Add drop file functionality on import

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-14 21:12:48 +02:00
41437636b1 Update messages.it.xlf (#2325)
* Update messages.it.xlf

* Update changelog
2023-09-14 19:48:47 +02:00
b21884eb66 Feature/harmonize logger output (#2321)
* Harmonize logger output

* Update changelog
2023-09-13 08:39:37 +02:00
1c5437e1fd Extend sitemap.xml with dutch pages (#2318) 2023-09-13 08:39:09 +02:00
58278ba5e6 Bugfix/fix dutch localization of portfolio summary (#2315)
* Revert reserved keyword (plural)

* Update changelog
2023-09-11 12:04:09 +02:00
921f3e9807 Update dutch translation (#2314)
* Update dutch translation

* Update changelog
2023-09-11 11:42:36 +02:00
75ca125a70 Add home server systems (#2311) 2023-09-10 08:01:01 +02:00
a1fd4e7a38 Improve wording (#2312) 2023-09-10 08:00:07 +02:00
0d5a8eb33e Add Ghostfolio 2.0 (#2309) 2023-09-09 09:10:36 +02:00
b088df2fa3 Release 2.0.0 (#2310) 2023-09-09 08:29:18 +02:00
f45d8f616a Bugfix/fix blog post ghostfolio 2 (#2307)
* Fix month

* Update sitemap.xml

* Update locales
2023-09-08 21:36:01 +02:00
d8300502ce Feature/upgrade yahoo finance2 to version 2.5.0 (#2306)
* Upgrade yahoo-finance2 to version 2.5.0

* Update changelog
2023-09-08 21:22:57 +02:00
502d51ad29 Feature/add blog post ghostfolio 2 (#2269)
* Add blog post: Ghostfolio 2.0

* Update changelog
2023-09-08 20:45:31 +02:00
bc33e5f147 Feature/remove deprecated environment variable base currency (#2255)
* Remove the deprecated environment variable BASE_CURRENCY

* Update changelog
2023-09-08 20:43:23 +02:00
48ba8f936b Feature/deactivate internet identity for account registration (#2293)
* Deactivate Internet Identity

* Update changelog
2023-09-08 20:23:22 +02:00
05ec4cce05 Add Portuguese landing page (#2301) 2023-09-08 17:06:58 +02:00
d74f283707 Eliminate prisma service (#2286)
* Eliminate prisma service
2023-09-08 17:05:42 +02:00
0f8bc7db32 Bugfix/do not remove countries and sectors in yahoo finance data enhancer (#2297)
* Do not remove countries and sectors

* Update changelog
2023-09-08 15:07:17 +02:00
431500f28a Update docker compose files to version 3.9 (#2299)
* Update docker compose files to version 3.9

* Format yml files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-07 20:44:52 +02:00
9672de174e Feature/improve language localization for german 20230903 (#2294)
* Update locales

* Update changelog
2023-09-07 19:20:30 +02:00
c6aa06b933 Feature/improve import validation (#2305)
* Improve import validation

* Update changelog

Co-authored-by: httpiga <36515569+httpiga@users.noreply.github.com>
2023-09-07 18:28:47 +02:00
1f46a6b6f3 Clean up (#2291) 2023-09-05 19:44:45 +02:00
1bed940bc0 Feature/refresh cryptocurrencies list 20230903 (#2290)
* Refresh cryptocurrencies list

* Add CyberConnect

* Update changelog
2023-09-04 09:07:06 +02:00
f9eb3cc3c5 Release 1.305.0 (#2289) 2023-09-03 08:16:02 +02:00
2519c3ffb0 Bugfix/fix alignment in menu of impersonation mode (#2284)
* Fix alignment

* Update changelog
2023-09-02 17:57:50 +02:00
91013d1d10 Bugfix/fix alignment in header navigation (#2285)
* Fix alignment

* Update changelog
2023-09-02 17:13:13 +02:00
6deefb9c43 Update OSS Friends (#2282) 2023-09-02 11:52:29 +02:00
d0744e07df Feature/upgrade replace in file to version 7.0.1 (#2277)
* Upgrade replace-in-file to version 7.0.1

* Update changelog
2023-09-02 08:58:25 +02:00
93e1ee3ba7 Feature/improve localization of personal finance tools (#2274)
* Improve localization

* Update changelog
2023-09-02 08:58:10 +02:00
dceaa55a6c Feature/add hacker news logo to landing page (#2281)
* Add Hacker News

* Update changelog
2023-09-02 08:39:32 +02:00
8b4d55925d Feature/upgrade yahoo finance2 to version 2.4.4 (#2276)
* Upgrade yahoo-finance2 to version 2.4.4

* Update changelog
2023-09-02 08:36:46 +02:00
754b49e50f Feature/shorten page titles (#2273)
* Shorten page titles

* Update changelog
2023-08-31 18:22:22 +02:00
6ccbda8169 Feature/upgrade prisma to version 5.2.0 (#2217)
* Upgrade prisma to version 5.2.0

* Update changelog
2023-08-29 13:44:53 +02:00
b0fb986208 Release 1.304.0 (#2272) 2023-08-27 11:19:14 +02:00
0b59fc639d Feature/upgrade prettier to version 3 (#2163)
* Upgrade prettier to version 3.0.2

* Prettify code

* Update changelog
2023-08-27 11:13:11 +02:00
7ddd6f27b5 Feature/upgrade nx to version 16.7.4 (#2271)
* Upgrade Nx to version 16.7.4

* Update changelog
2023-08-27 10:44:06 +02:00
c5d56f4b47 Fix border (#2268) 2023-08-27 10:20:36 +02:00
2f2b712999 Fix breadcrumb (#2267) 2023-08-27 10:20:21 +02:00
c2fd31f5e5 Feature/add health check endpoints for data enhancers (#2265)
* Add health check for data enhancers

* Update changelog
2023-08-27 10:19:53 +02:00
f2d70f9070 Sort imports (#2266) 2023-08-26 11:22:19 +02:00
f41dd9cd8e Fix lint script (#2264) 2023-08-25 15:13:04 +02:00
7d238b4935 Release 1.303.0 (#2261) 2023-08-23 18:52:59 +02:00
da6591fca0 Bugfix/fix base url in trackinsight data enhancer (#2258)
* Fix base url

* Update changelog
2023-08-23 18:51:02 +02:00
1f9b9e9998 Feature/blog post ghostfolio joins oss friends (#2260)
* Add blog post: Ghostfolio joins OSS Friends

* Update changelog
2023-08-23 18:49:53 +02:00
49c4ea306d Feature/improve oss friends page (#2257)
* Improve OSS Friends page

* Update changelog
2023-08-22 09:02:39 +02:00
ccb5c664ef Feature/refresh cryptocurrencies list 20230821 (#2256)
* Update cryptocurrencies.json

* Update changelog
2023-08-21 20:20:59 +02:00
97e165ff69 Improve localization (#2254) 2023-08-21 18:05:08 +02:00
45aefb6a45 Reorder charts (#2253) 2023-08-21 18:04:49 +02:00
2435535975 Release 1.302.0 (#2252) 2023-08-20 10:35:13 +02:00
bd3d43bf05 Feature/upgrade nx to version 16.7.2 (#2251)
* Upgrade Nx and Angular dependencies

* Update changelog
2023-08-20 10:32:52 +02:00
02dc7c52b1 Localize routes (#2250)
* Localize about path

* Localize faq path

* Localize features path

* Localize markets path

* Localize pricing path

* Localize register path

* Localize resources path

* Extend sitemap
2023-08-20 10:01:40 +02:00
ff59fd4196 Feature/improve language localization for german 20230819 (#2249)
* Improve language localization

* Update changelog
2023-08-19 19:49:40 +02:00
4955555ddd Release 1.301.1 (#2247) 2023-08-19 08:49:31 +02:00
a98c788a26 Release 1.301.0 (#2246) 2023-08-18 20:46:58 +02:00
9c16af81c7 Feature/setup oss friends page (#2245)
* Setup OSS Friends page

* Update changelog
2023-08-18 20:45:10 +02:00
2df27100f0 Add middleware (#2239)
* Add middleware

* Update changelog
2023-08-18 20:27:19 +02:00
6cf6538719 Feature/add currencies preset to historical market data table (#2243)
* Add currencies preset

* Update locales

* Update changelog
2023-08-18 19:33:00 +02:00
0fd3db3228 Bugfix/fix cash position rows in holdings table (#2237)
* Fix cash position rows

* Update changelog
2023-08-17 20:23:23 +02:00
18835149e2 Add repository (#2189) 2023-08-16 20:32:54 +02:00
6c9779fb0d Bugfix/change date creation from string using parse iso (#2236)
* Change date creation using parseISO

parseISO provides consistent date parsing across different time zones

* Update changelog
2023-08-15 19:24:31 +02:00
3e98f097ef Refactor account page to user account page (#2235)
* Refactor account page to user account page
2023-08-13 09:24:54 +02:00
183ac8fa2b Feature/add data export to user account page (#2234)
* Add data export

* Update changelog
2023-08-12 21:51:35 +02:00
9036f53e7d Reset benchmark in user settings (#2233) 2023-08-12 21:50:01 +02:00
f7c04e469a Release 1.300.0 (#2232) 2023-08-11 20:24:23 +02:00
b5f01c0d15 Feature/migrate requests from bent to got (#2231)
* Migrate requests from bent to got

* Update changelog
2023-08-11 20:20:35 +02:00
5a23cd34ad Replace variables (#2229) 2023-08-11 18:29:39 +02:00
6e87f34c6f Feature/add more durations in coupon system (#2228)
* Add 90 and 180 days

* Update changelog
2023-08-10 20:49:06 +02:00
6618aa2e9b Release 1.299.1 (#2227) 2023-08-10 07:58:34 +02:00
0d25a96f7e Release 1.299.0 (#2225) 2023-08-09 20:59:52 +02:00
4f6d9d3a76 Feature/add timeout to eod historical data requests (#2222)
* Add timeout to requests using got

* Update changelog
2023-08-09 20:58:00 +02:00
928f6f0c45 Bugfix/fix historical data gathering interval for benchmarks with activity (#2221)
* Fix historical data gathering interval for asset profiles used as benchmarks having activities

* Update changelog
2023-08-09 20:43:03 +02:00
09e95ddcee Bugfix/fix editing of emergency fund (#2220)
* Fix editing of emergency fund

* Update changelog
2023-08-09 19:41:42 +02:00
2d003225bc Allow custom currency in activity import (#2215)
* Allow custom currency in activity import

* Extend import test files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-08-08 20:00:55 +02:00
de93cabd69 Release 1.298.0 (#2214) 2023-08-06 09:13:04 +02:00
51489cca81 Feature/upgrade ng extract i18n merge to version 2.7.0 (#2155)
* Upgrade ng-extract-i18n-merge to version 2.7.0

* Update changelog
2023-08-06 09:10:14 +02:00
f7f4c3afb1 Feature/localize open startup page (#2213)
* Localize Open Startup page

* Update changelog
2023-08-06 09:09:21 +02:00
0821086e41 Bugfix/fix various styles after angular material 16 upgrade (#2212)
* Fix styles

* Update changelog
2023-08-06 08:52:45 +02:00
7a905fde63 Clean up (#2210) 2023-08-06 08:32:06 +02:00
d2882b1119 Clean up (#2206) 2023-08-06 08:31:48 +02:00
3a500598c5 Feature/upgrade nx to version 16.6.0 (#2211)
* Upgrade Nx to version 16.6.0

* Update changelog
2023-08-06 08:30:28 +02:00
42274917e0 Release 1.297.4 (#2209) 2023-08-05 19:57:39 +02:00
8ba50f2729 Release 1.297.3 (#2208) 2023-08-05 16:08:06 +02:00
f22071f061 Release 1.297.2 (#2207) 2023-08-05 14:51:10 +02:00
298 changed files with 49730 additions and 16390 deletions

View File

@ -11,6 +11,5 @@ POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD> POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING> ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING> JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

View File

@ -9,6 +9,7 @@
], ],
"attributeSort": "ASC", "attributeSort": "ASC",
"endOfLine": "auto", "endOfLine": "auto",
"plugins": ["prettier-plugin-organize-attributes"],
"printWidth": 80, "printWidth": 80,
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,

View File

@ -5,7 +5,221 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.297.1 - 2023-08-05 ## 2.6.0 - 2023-09-26
### Added
- Added the management of tags in the admin control panel
- Added a blog post: _Hacktoberfest 2023_
### Changed
- Upgraded `prettier` from version `3.0.2` to `3.0.3`
- Upgraded `yahoo-finance2` from version `2.5.0` to `2.7.0`
## 2.5.0 - 2023-09-23
### Added
- Added support for translated activity types in the activities table
- Added support for dates in `DD.MM.YYYY` format in the activities import
- Set up the language localization for Türkçe (`tr`)
### Changed
- Skipped creating queue jobs for asset profiles with `MANUAL` data source on creating a new activity
### Fixed
- Fixed an issue with the cash position in the holdings table
## 2.4.0 - 2023-09-19
### Added
- Added support for interest on account level (experimental)
### Changed
- Improved the preselected currency based on the account's currency in the create or edit activity dialog
- Unlocked the experimental features setting for all users
- Upgraded `prisma` from version `5.2.0` to `5.3.1`
### Fixed
- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering
## 2.3.0 - 2023-09-17
### Added
- Added support for fees on account level (experimental)
### Fixed
- Fixed the export functionality for liabilities
## 2.2.0 - 2023-09-17
### Added
- Introduced a sidebar navigation on desktop
### Changed
- Improved the style of the system message
- Upgraded _Postgres_ from version `12` to `15` in the `docker-compose` files
## 2.1.0 - 2023-09-15
### Added
- Added support to drop a file in the import activities dialog
- Added a timeout to all data source requests
### Changed
- Harmonized the style of the user interface for granting and revoking public access to share the portfolio
- Removed the account type from the user interface as a preparation to remove it from the `Account` database schema
- Improved the logger output of the info service
- Harmonized the logger output: `<symbol> (<dataSource>)`
- Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`)
- Improved the language localization for Dutch (`nl`)
- Improved the read-only mode
### Fixed
- Fixed the timeout in _EOD Historical Data_ requests
- Fixed an issue with the portfolio summary caused by the language localization for Dutch (`nl`)
## 2.0.0 - 2023-09-09
### Added
- Added support for the cryptocurrency _CyberConnect_
- Added a blog post: _Announcing Ghostfolio 2.0_
### Changed
- **Breaking Change**: Removed the deprecated environment variable `BASE_CURRENCY`
- Improved the validation in the activities import
- Deactivated _Internet Identity_ as a social login provider for the account registration
- Improved the language localization for German (`de`)
- Refreshed the cryptocurrencies list
- Changed the version in the `docker-compose` files from `3.7` to `3.9`
- Upgraded `yahoo-finance2` from version `2.4.4` to `2.5.0`
### Fixed
- Fixed an issue in the _Yahoo Finance_ data enhancer where countries and sectors have been removed
## 1.305.0 - 2023-09-03
### Added
- Added _Hacker News_ to the _As seen in_ section on the landing page
### Changed
- Shortened the page titles
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `4.16.2` to `5.2.0`
- Upgraded `replace-in-file` from version `6.3.5` to `7.0.1`
- Upgraded `yahoo-finance2` from version `2.4.3` to `2.4.4`
### Fixed
- Fixed the alignment in the header navigation
- Fixed the alignment in the menu of the impersonation mode
## 1.304.0 - 2023-08-27
### Added
- Added health check endpoints for data enhancers
### Changed
- Upgraded `Nx` from version `16.7.2` to `16.7.4`
- Upgraded `prettier` from version `2.8.4` to `3.0.2`
## 1.303.0 - 2023-08-23
### Added
- Added a blog post: _Ghostfolio joins OSS Friends_
### Changed
- Refreshed the cryptocurrencies list
- Improved the _OSS Friends_ page
### Fixed
- Fixed an issue with the _Trackinsight_ data enhancer for asset profile data
## 1.302.0 - 2023-08-20
### Changed
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `16.1.8` to `16.2.1`
- Upgraded `Nx` from version `16.6.0` to `16.7.2`
## 1.301.1 - 2023-08-19
### Added
- Added the data export feature to the user account page
- Added a currencies preset to the historical market data table of the admin control panel
- Added the _OSS Friends_ page
### Changed
- Improved the localized meta data in `html` files
### Fixed
- Fixed the rows with cash positions in the holdings table
- Fixed an issue with the date parsing in the historical market data editor of the admin control panel
## 1.300.0 - 2023-08-11
### Added
- Added more durations in the coupon system
### Changed
- Migrated the remaining requests from `bent` to `got`
## 1.299.1 - 2023-08-10
### Changed
- Optimized the activities import by allowing a different currency than the asset's official one
- Added a timeout to the _EOD Historical Data_ requests
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
### Fixed
- Fixed the editing of the emergency fund
- Fixed the historical data gathering interval for asset profiles used as benchmarks having activities
## 1.298.0 - 2023-08-06
### Changed
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.6.0` to `2.7.0`
- Upgraded `Nx` from version `16.5.5` to `16.6.0`
### Fixed
- Fixed the styles of various components (card, progress, tab) after the upgrade to `@angular/material` `16`
## 1.297.4 - 2023-08-05
### Added ### Added
@ -576,7 +790,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Changed the slide toggles to checkboxes on the account page - Changed the slide toggles to checkboxes on the user account page
- Changed the slide toggles to checkboxes in the admin control panel - Changed the slide toggles to checkboxes in the admin control panel
- Increased the density of the theme - Increased the density of the theme
- Migrated the style of various components to `@angular/material` `15` (mdc) - Migrated the style of various components to `@angular/material` `15` (mdc)
@ -1138,7 +1352,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the language selector on the account page - Improved the language selector on the user account page
- Improved the wording in the _X-ray_ section (net worth instead of investment) - Improved the wording in the _X-ray_ section (net worth instead of investment)
- Extended the asset profile details dialog in the admin control panel - Extended the asset profile details dialog in the admin control panel
- Updated the browserslist database - Updated the browserslist database
@ -1383,7 +1597,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Set up the language localization for Italiano (`it`) - Set up the language localization for Italian (`it`)
- Extended the landing page - Extended the landing page
## 1.195.0 - 20.09.2022 ## 1.195.0 - 20.09.2022
@ -1556,7 +1770,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a language selector to the account page - Added a language selector to the user account page
- Added support for translated labels in the value component - Added support for translated labels in the value component
### Changed ### Changed
@ -1885,7 +2099,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the user id to the account page - Added the user id to the user account page
- Added a new view with jobs of the queue to the admin control panel - Added a new view with jobs of the queue to the admin control panel
### Changed ### Changed
@ -2806,7 +3020,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Supported the management of additional currencies in the admin control panel - Supported the management of additional currencies in the admin control panel
- Introduced the system message - Introduced the system message
- Introduced the read only mode - Introduced the read-only mode
### Changed ### Changed
@ -3540,7 +3754,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Respected the cash balance on the analysis page - Respected the cash balance on the analysis page
- Improved the settings selectors on the account page - Improved the settings selectors on the user account page
- Harmonized the slogan to "Open Source Wealth Management Software" - Harmonized the slogan to "Open Source Wealth Management Software"
### Fixed ### Fixed
@ -4006,7 +4220,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a gradient to the line charts - Added a gradient to the line charts
- Added a selector to set the base currency on the account page - Added a selector to set the base currency on the user account page
## 0.81.0 - 06.04.2021 ## 0.81.0 - 06.04.2021
@ -4320,7 +4534,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Added the membership status to the account page - Added the membership status to the user account page
### Fixed ### Fixed

View File

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

View File

@ -13,6 +13,8 @@
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing) [![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) [![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
</div> </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. **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.
@ -25,7 +27,7 @@
## Ghostfolio Premium ## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
@ -136,9 +138,9 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d` 1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
At each start, the container will automatically apply the database schema migrations if needed. At each start, the container will automatically apply the database schema migrations if needed.
### Run with _Unraid_ (Community) ### Home Server Systems (Community)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio). Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development

View File

@ -10,6 +10,7 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class CreateAccountDto { export class CreateAccountDto {
@IsOptional()
@IsString() @IsString()
accountType: AccountType; accountType: AccountType;

View File

@ -10,6 +10,7 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class UpdateAccountDto { export class UpdateAccountDto {
@IsOptional()
@IsString() @IsString()
accountType: AccountType; accountType: AccountType;

View File

@ -38,7 +38,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { isDate } from 'date-fns'; import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
@ -233,7 +233,7 @@ export class AdminController {
); );
} }
const date = new Date(dateString); const date = parseISO(dateString);
if (!isDate(date)) { if (!isDate(date)) {
throw new HttpException( throw new HttpException(
@ -333,7 +333,7 @@ export class AdminController {
); );
} }
const date = new Date(dateString); const date = parseISO(dateString);
return this.marketDataService.updateMarketData({ return this.marketDataService.updateMarketData({
data: { marketPrice: data.marketPrice, state: 'CLOSE' }, data: { marketPrice: data.marketPrice, state: 'CLOSE' },

View File

@ -7,13 +7,16 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_PAGE_SIZE, DEFAULT_CURRENCY,
PROPERTY_CURRENCIES PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem,
Filter, Filter,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -25,8 +28,6 @@ import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -36,9 +37,7 @@ export class AdminService {
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async addAssetProfile({ public async addAssetProfile({
dataSource, dataSource,
@ -82,15 +81,15 @@ export class AdminService {
exchangeRates: this.exchangeRateDataService exchangeRates: this.exchangeRateDataService
.getCurrencies() .getCurrencies()
.filter((currency) => { .filter((currency) => {
return currency !== this.baseCurrency; return currency !== DEFAULT_CURRENCY;
}) })
.map((currency) => { .map((currency) => {
return { return {
label1: this.baseCurrency, label1: DEFAULT_CURRENCY,
label2: currency, label2: currency,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
1, 1,
this.baseCurrency, DEFAULT_CURRENCY,
currency currency
) )
}; };
@ -121,7 +120,9 @@ export class AdminService {
[{ symbol: 'asc' }]; [{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {}; const where: Prisma.SymbolProfileWhereInput = {};
if ( if (presetId === 'CURRENCIES') {
return this.getMarketDataForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' || presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS' presetId === 'ETF_WITHOUT_SECTORS'
) { ) {
@ -306,13 +307,45 @@ export class AdminService {
response = await this.propertyService.delete({ key }); response = await this.propertyService.delete({ key });
} }
if (key === PROPERTY_CURRENCIES) { if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
} else if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize(); await this.exchangeRateDataService.initialize();
} }
return response; return response;
} }
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol,
assetClass: 'CASH',
countriesCount: 0,
sectorsCount: 0
};
});
return { marketData, count: marketData.length };
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> { private async getUsersWithAnalytics(): Promise<AdminData['users']> {
let orderBy: any = { let orderBy: any = {
createdAt: 'desc' createdAt: 'desc'

View File

@ -7,11 +7,16 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import {
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
@ -23,7 +28,6 @@ import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
@ -32,14 +36,11 @@ import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
import {
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { StatusCodes } from 'http-status-codes';
@Module({ @Module({
imports: [ imports: [
@ -75,6 +76,8 @@ import { StatusCodes } from 'http-status-codes';
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
exclude: ['/api*', '/sitemap.xml'],
rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: { serveStaticOptions: {
setHeaders: (res) => { setHeaders: (res) => {
if (res.req?.path === '/') { if (res.req?.path === '/') {
@ -94,22 +97,16 @@ import { StatusCodes } from 'http-status-codes';
res.statusCode = StatusCodes.MOVED_PERMANENTLY; res.statusCode = StatusCodes.MOVED_PERMANENTLY;
} }
} }
}, }
rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*']
}), }),
SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagModule,
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],
controllers: [AppController], controllers: [AppController],
providers: [CronService] providers: [CronService]
}) })
export class AppModule { export class AppModule {}
configure(consumer: MiddlewareConsumer) {
consumer
.apply(FrontendMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

@ -41,9 +41,8 @@ export class AuthController {
@Param('accessToken') accessToken: string @Param('accessToken') accessToken: string
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
try { try {
const authToken = await this.authService.validateAnonymousLogin( const authToken =
accessToken await this.authService.validateAnonymousLogin(accessToken);
);
return { authToken }; return { authToken };
} catch { } catch {
throw new HttpException( throw new HttpException(

View File

@ -55,7 +55,7 @@ export class AuthService {
const isUserSignupEnabled = const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled(); await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) { if (!isUserSignupEnabled || true) {
throw new Error('Sign up forbidden'); throw new Error('Sign up forbidden');
} }

View File

@ -7,6 +7,7 @@ import {
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExchangeRateService } from './exchange-rate.service'; import { ExchangeRateService } from './exchange-rate.service';
@ -23,7 +24,7 @@ export class ExchangeRateController {
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> { ): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString); const date = parseISO(dateString);
const exchangeRate = await this.exchangeRateService.getExchangeRate({ const exchangeRate = await this.exchangeRateService.getExchangeRate({
date, date,

View File

@ -26,18 +26,8 @@ export class ExportService {
where: { userId } where: { userId }
}) })
).map( ).map(
({ ({ balance, comment, currency, id, isExcluded, name, platformId }) => {
accountType,
balance,
comment,
currency,
id,
isExcluded,
name,
platformId
}) => {
return { return {
accountType,
balance, balance,
comment, comment,
currency, currency,
@ -87,7 +77,13 @@ export class ExportService {
currency: SymbolProfile.currency, currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(), date: date.toISOString(),
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol symbol:
type === 'FEE' ||
type === 'INTEREST' ||
type === 'ITEM' ||
type === 'LIABILITY'
? SymbolProfile.name
: SymbolProfile.symbol
}; };
} }
) )

View File

@ -1,255 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class FrontendMiddleware implements NestMiddleware {
public indexHtmlDe = '';
public indexHtmlEn = '';
public indexHtmlEs = '';
public indexHtmlFr = '';
public indexHtmlIt = '';
public indexHtmlNl = '';
public indexHtmlPt = '';
public sitemapXml = '';
private static readonly DEFAULT_DESCRIPTION =
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
public constructor(
private readonly configurationService: ConfigurationService
) {
try {
this.indexHtmlDe = fs.readFileSync(
this.getPathOfIndexHtmlFile('de'),
'utf8'
);
this.indexHtmlEn = fs.readFileSync(
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
'utf8'
);
this.indexHtmlEs = fs.readFileSync(
this.getPathOfIndexHtmlFile('es'),
'utf8'
);
this.indexHtmlFr = fs.readFileSync(
this.getPathOfIndexHtmlFile('fr'),
'utf8'
);
this.indexHtmlIt = fs.readFileSync(
this.getPathOfIndexHtmlFile('it'),
'utf8'
);
this.indexHtmlNl = fs.readFileSync(
this.getPathOfIndexHtmlFile('nl'),
'utf8'
);
this.indexHtmlPt = fs.readFileSync(
this.getPathOfIndexHtmlFile('pt'),
'utf8'
);
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
public use(request: Request, response: Response, next: NextFunction) {
const currentDate = format(new Date(), DATE_FORMAT);
let featureGraphicPath = 'assets/cover.png';
let title = 'Ghostfolio Open Source Wealth Management Software';
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
title = `500 Stars - ${title}`;
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
title = `Hacktoberfest 2022 - ${title}`;
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
title = `Black Friday 2022 - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
)
) {
featureGraphicPath = 'assets/images/blog/20221226.jpg';
title = `The importance of tracking your personal finances - ${title}`;
} 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}`;
} else if (
request.path.startsWith(
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
)
) {
featureGraphicPath = 'assets/images/blog/20230520.jpg';
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
) {
featureGraphicPath = 'assets/images/blog/20230701.jpg';
title = `Exploring the Path to FIRE - ${title}`;
}
if (
request.path.startsWith('/api/') ||
this.isFileRequest(request.url) ||
!environment.production
) {
// Skip
next();
} else if (request.path === '/sitemap.xml') {
response.setHeader('content-type', 'application/xml');
response.send(
this.interpolate(this.sitemapXml, {
currentDate: format(getYesterday(), DATE_FORMAT)
})
);
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
response.send(
this.interpolate(this.indexHtmlDe, {
currentDate,
featureGraphicPath,
title,
description:
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
languageCode: 'de',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
response.send(
this.interpolate(this.indexHtmlEs, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
languageCode: 'es',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
response.send(
this.interpolate(this.indexHtmlFr, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
languageCode: 'fr',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
response.send(
this.interpolate(this.indexHtmlIt, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
languageCode: 'it',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
response.send(
this.interpolate(this.indexHtmlNl, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.',
languageCode: 'nl',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
response.send(
this.interpolate(this.indexHtmlPt, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
languageCode: 'pt',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else {
response.send(
this.interpolate(this.indexHtmlEn, {
currentDate,
featureGraphicPath,
title,
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
languageCode: DEFAULT_LANGUAGE_CODE,
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
}
}
private getPathOfIndexHtmlFile(aLocale: string) {
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
}
private interpolate(template: string, context: any) {
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
const properties = objectPath.split('.');
return properties.reduce(
(previous, current) => previous?.[current],
context
);
});
}
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (
filename === '/sitemap.xml' ||
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}
return filename.split('.').pop() !== filename;
}
}

View File

@ -18,6 +18,19 @@ export class HealthController {
@Get() @Get()
public async getHealth() {} public async getHealth() {}
@Get('data-enhancer/:name')
public async getHealthOfDataEnhancer(@Param('name') name: string) {
const hasResponse =
await this.healthService.hasResponseFromDataEnhancer(name);
if (hasResponse !== true) {
throw new HttpException(
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
StatusCodes.SERVICE_UNAVAILABLE
);
}
}
@Get('data-provider/:dataSource') @Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider( public async getHealthOfDataProvider(
@ -30,9 +43,8 @@ export class HealthController {
); );
} }
const hasResponse = await this.healthService.hasResponseFromDataProvider( const hasResponse =
dataSource await this.healthService.hasResponseFromDataProvider(dataSource);
);
if (hasResponse !== true) { if (hasResponse !== true) {
throw new HttpException( throw new HttpException(

View File

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,7 @@ import { HealthService } from './health.service';
@Module({ @Module({
controllers: [HealthController], controllers: [HealthController],
imports: [ConfigurationModule, DataProviderModule], imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
providers: [HealthService] providers: [HealthService]
}) })
export class HealthModule {} export class HealthModule {}

View File

@ -1,3 +1,4 @@
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -5,9 +6,14 @@ import { DataSource } from '@prisma/client';
@Injectable() @Injectable()
export class HealthService { export class HealthService {
public constructor( public constructor(
private readonly dataEnhancerService: DataEnhancerService,
private readonly dataProviderService: DataProviderService private readonly dataProviderService: DataProviderService
) {} ) {}
public async hasResponseFromDataEnhancer(aName: string) {
return this.dataEnhancerService.enhance(aName);
}
public async hasResponseFromDataProvider(aDataSource: DataSource) { public async hasResponseFromDataProvider(aDataSource: DataSource) {
return this.dataProviderService.checkQuote(aDataSource); return this.dataProviderService.checkQuote(aDataSource);
} }

View File

@ -13,6 +13,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -24,7 +25,7 @@ import {
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns'; import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -248,17 +249,20 @@ export class ImportService {
const activities: Activity[] = []; const activities: Activity[] = [];
for (const { for (let [
accountId, index,
comment, {
date, accountId,
error, comment,
fee, date,
quantity, error,
SymbolProfile, fee,
type, quantity,
unitPrice SymbolProfile,
} of activitiesExtendedWithErrors) { type,
unitPrice
}
] of activitiesExtendedWithErrors.entries()) {
const assetProfile = assetProfiles[ const assetProfile = assetProfiles[
getAssetProfileIdentifier({ getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
@ -296,6 +300,35 @@ export class ImportService {
Account?: { id: string; name: string }; Account?: { id: string; name: string };
}); });
if (SymbolProfile.currency !== assetProfile.currency) {
// Convert the unit price and fee to the asset currency if the imported
// activity is in a different currency
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice,
SymbolProfile.currency,
assetProfile.currency,
date
);
if (!unitPrice) {
throw new Error(
`activities.${index} historical exchange rate at ${format(
date,
DATE_FORMAT
)} is not available from "${SymbolProfile.currency}" to "${
assetProfile.currency
}"`
);
}
fee = await this.exchangeRateDataService.toCurrencyAtDate(
fee,
SymbolProfile.currency,
assetProfile.currency,
date
);
}
if (isDryRun) { if (isDryRun) {
order = { order = {
comment, comment,
@ -377,7 +410,7 @@ export class ImportService {
currency, currency,
userCurrency userCurrency
), ),
//@ts-ignore // @ts-ignore
SymbolProfile: assetProfile, SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
@ -533,15 +566,21 @@ export class ImportService {
]) ])
)?.[symbol]; )?.[symbol];
if (assetProfile === undefined) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (assetProfile.currency !== currency) { if (
assetProfile.currency !== currency &&
!this.exchangeRateDataService.hasCurrencyPair(
currency,
assetProfile.currency
)
) {
throw new Error( throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"` `activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
); );
} }

View File

@ -1,6 +1,7 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -28,11 +29,11 @@ import { InfoService } from './info.service';
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
PlatformModule, PlatformModule,
PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TagModule TagModule,
UserModule
], ],
providers: [InfoService] providers: [InfoService]
}) })

View File

@ -1,12 +1,14 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
@ -30,9 +32,9 @@ import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types'; import { SubscriptionOffer } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
@ -44,10 +46,10 @@ export class InfoService {
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly tagService: TagService private readonly tagService: TagService,
private readonly userService: UserService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
@ -139,18 +141,13 @@ export class InfoService {
subscriptions, subscriptions,
systemMessage, systemMessage,
tags, tags,
baseCurrency: this.configurationService.get('BASE_CURRENCY'), baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies()
}; };
} }
private async countActiveUsers(aDays: number) { private async countActiveUsers(aDays: number) {
return await this.prismaService.user.count({ return this.userService.count({
orderBy: {
Analytics: {
updatedAt: 'desc'
}
},
where: { where: {
AND: [ AND: [
{ {
@ -172,20 +169,24 @@ export class InfoService {
private async countDockerHubPulls(): Promise<number> { private async countDockerHubPulls(): Promise<number> {
try { try {
const get = bent( const abortController = new AbortController();
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
'GET', setTimeout(() => {
'json', abortController.abort();
200, }, DEFAULT_REQUEST_TIMEOUT);
{
'User-Agent': 'request' const { pull_count } = await got(
} `https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
); {
headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: abortController.signal
}
).json<any>();
const { pull_count } = await get();
return pull_count; return pull_count;
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService - DockerHub');
return undefined; return undefined;
} }
@ -193,16 +194,18 @@ export class InfoService {
private async countGitHubContributors(): Promise<number> { private async countGitHubContributors(): Promise<number> {
try { try {
const get = bent( const abortController = new AbortController();
'https://github.com/ghostfolio/ghostfolio',
'GET',
'string',
200,
{}
);
const html = await get(); setTimeout(() => {
const $ = cheerio.load(html); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
// @ts-ignore
signal: abortController.signal
});
const $ = cheerio.load(body);
return extractNumberFromString( return extractNumberFromString(
$( $(
@ -210,7 +213,7 @@ export class InfoService {
).text() ).text()
); );
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService - GitHub');
return undefined; return undefined;
} }
@ -218,30 +221,31 @@ export class InfoService {
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const get = bent( const abortController = new AbortController();
`https://api.github.com/repos/ghostfolio/ghostfolio`,
'GET', setTimeout(() => {
'json', abortController.abort();
200, }, DEFAULT_REQUEST_TIMEOUT);
{
'User-Agent': 'request' const { stargazers_count } = await got(
} `https://api.github.com/repos/ghostfolio/ghostfolio`,
); {
headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: abortController.signal
}
).json<any>();
const { stargazers_count } = await get();
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService - GitHub');
return undefined; return undefined;
} }
} }
private async countNewUsers(aDays: number) { private async countNewUsers(aDays: number) {
return await this.prismaService.user.count({ return this.userService.count({
orderBy: {
createdAt: 'desc'
},
where: { where: {
AND: [ AND: [
{ {
@ -332,11 +336,10 @@ export class InfoService {
return undefined; return undefined;
} }
const stripeConfig = (await this.prismaService.property.findUnique({ return (
where: { key: PROPERTY_STRIPE_CONFIG } ((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
})) ?? { value: '{}' }; {}
);
return JSON.parse(stripeConfig.value);
} }
private async getUptime(): Promise<number> { private async getUptime(): Promise<number> {
@ -346,25 +349,31 @@ export class InfoService {
PROPERTY_BETTER_UPTIME_MONITOR_ID PROPERTY_BETTER_UPTIME_MONITOR_ID
)) as string; )) as string;
const get = bent( const abortController = new AbortController();
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { data } = await got(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90), subDays(new Date(), 90),
DATE_FORMAT DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`, )}&to${format(new Date(), DATE_FORMAT)}`,
'GET',
'json',
200,
{ {
Authorization: `Bearer ${this.configurationService.get( headers: {
'BETTER_UPTIME_API_KEY' Authorization: `Bearer ${this.configurationService.get(
)}` 'BETTER_UPTIME_API_KEY'
)}`
},
// @ts-ignore
signal: abortController.signal
} }
); ).json<any>();
const { data } = await get();
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService - Better Stack');
return undefined; return undefined;
} }

View File

@ -1,8 +1,9 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import got from 'got';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable() @Injectable()
@ -41,15 +42,19 @@ export class LogoService {
} }
private getBuffer(aUrl: string) { private getBuffer(aUrl: string) {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
return got(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
'GET',
'buffer',
200,
{ {
'User-Agent': 'request' headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: abortController.signal
} }
); ).buffer();
return get();
} }
} }

View File

@ -147,8 +147,9 @@ export class OrderController {
userId: this.request.user.id userId: this.request.user.id
}); });
if (!order.isDraft) { if (data.dataSource && !order.isDraft) {
// Gather symbol data in the background, if not draft // Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
dataSource: data.dataSource, dataSource: data.dataSource,

View File

@ -97,7 +97,12 @@ export class OrderService {
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
if (data.type === 'ITEM' || data.type === 'LIABILITY') { if (
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
) {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
currency = data.SymbolProfile.connectOrCreate.create.currency; currency = data.SymbolProfile.connectOrCreate.create.currency;
@ -118,20 +123,22 @@ export class OrderService {
}; };
} }
this.dataGatheringService.addJobToQueue({ if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') {
data: { this.dataGatheringService.addJobToQueue({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, data: {
symbol: data.SymbolProfile.connectOrCreate.create.symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
}) },
} name: GATHER_ASSET_PROFILE_PROCESS,
}); opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
})
}
});
}
delete data.accountId; delete data.accountId;
delete data.assetClass; delete data.assetClass;
@ -151,6 +158,9 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = const isDraft =
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY' data.type === 'LIABILITY'
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
@ -197,7 +207,12 @@ export class OrderService {
where where
}); });
if (order.type === 'ITEM' || order.type === 'LIABILITY') { if (
order.type === 'FEE' ||
order.type === 'INTEREST' ||
order.type === 'ITEM' ||
order.type === 'LIABILITY'
) {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
@ -368,7 +383,12 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (data.type === 'ITEM' || data.type === 'LIABILITY') { if (
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
) {
delete data.SymbolProfile.connect; delete data.SymbolProfile.connect;
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;

View File

@ -47,6 +47,7 @@ export class PlatformController {
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
return this.platformService.createPlatform(data); return this.platformService.createPlatform(data);
} }

View File

@ -6,6 +6,18 @@ import { Platform, Prisma } from '@prisma/client';
export class PlatformService { export class PlatformService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async createPlatform(data: Prisma.PlatformCreateInput) {
return this.prismaService.platform.create({
data
});
}
public async deletePlatform(
where: Prisma.PlatformWhereUniqueInput
): Promise<Platform> {
return this.prismaService.platform.delete({ where });
}
public async getPlatform( public async getPlatform(
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
): Promise<Platform> { ): Promise<Platform> {
@ -56,12 +68,6 @@ export class PlatformService {
}); });
} }
public async createPlatform(data: Prisma.PlatformCreateInput) {
return this.prismaService.platform.create({
data
});
}
public async updatePlatform({ public async updatePlatform({
data, data,
where where
@ -74,10 +80,4 @@ export class PlatformService {
where where
}); });
} }
public async deletePlatform(
where: Prisma.PlatformWhereUniqueInput
): Promise<Platform> {
return this.prismaService.platform.delete({ where });
}
} }

View File

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

View File

@ -784,7 +784,7 @@ export class PortfolioCalculator {
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( Logger.warn(
`Missing historical market data for symbol ${currentPosition.symbol}`, `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator' 'PortfolioCalculator'
); );
hasErrors = true; hasErrors = true;

View File

@ -10,7 +10,10 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
@ -47,8 +50,6 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
private baseCurrency: string;
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
@ -57,9 +58,7 @@ export class PortfolioController {
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@ -174,8 +173,14 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = { holdings[symbol] = {
...portfolioPosition, ...portfolioPosition,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined, assetClass:
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined, hasDetails || portfolioPosition.assetClass === 'CASH'
? portfolioPosition.assetClass
: undefined,
assetSubClass:
hasDetails || portfolioPosition.assetSubClass === 'CASH'
? portfolioPosition.assetSubClass
: undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
@ -442,8 +447,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency, portfolioPosition.currency,
this.request.user?.Settings?.settings.baseCurrency ?? this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
this.baseCurrency
); );
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);

View File

@ -11,12 +11,12 @@ import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/ac
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID, EMERGENCY_FUND_TAG_ID,
MAX_CHART_ITEMS, MAX_CHART_ITEMS,
UNKNOWN_KEY UNKNOWN_KEY
@ -56,12 +56,11 @@ import {
Platform, Platform,
Prisma, Prisma,
Tag, Tag,
Type as TypeOfOrder Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
differenceInDays, differenceInDays,
endOfToday,
format, format,
isAfter, isAfter,
isBefore, isBefore,
@ -90,11 +89,8 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private baseCurrency: string;
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService, private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -104,9 +100,7 @@ export class PortfolioService {
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService private readonly userService: UserService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getAccounts({ public async getAccounts({
filters, filters,
@ -470,9 +464,8 @@ export class PortfolioService {
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
); );
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
startDate await portfolioCalculator.getCurrentPositions(startDate);
);
const cashDetails = await this.accountService.getCashDetails({ const cashDetails = await this.accountService.getCashDetails({
filters, filters,
@ -810,9 +803,8 @@ export class PortfolioService {
const transactionPoints = portfolioCalculator.getTransactionPoints(); const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
portfolioStart await portfolioCalculator.getCurrentPositions(portfolioStart);
);
const position = currentPositions.positions.find( const position = currentPositions.positions.find(
(item) => item.symbol === aSymbol (item) => item.symbol === aSymbol
@ -1046,9 +1038,8 @@ export class PortfolioService {
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
startDate await portfolioCalculator.getCurrentPositions(startDate);
);
const positions = currentPositions.positions.filter( const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0) (item) => !item.quantity.eq(0)
@ -1238,9 +1229,8 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
portfolioStart await portfolioCalculator.getCurrentPositions(portfolioStart);
);
const positions = currentPositions.positions.filter( const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0) (item) => !item.quantity.eq(0)
@ -1351,36 +1341,6 @@ export class PortfolioService {
return cashPositions; return cashPositions;
} }
private getDividend({
activities,
date = new Date(0),
userCurrency
}: {
activities: OrderWithAccount[];
date?: Date;
userCurrency: string;
}) {
return activities
.filter((activity) => {
// Filter out all activities before given date (drafts) and type dividend
return (
isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.DIVIDEND
);
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getDividendsByGroup({ private getDividendsByGroup({
dividends, dividends,
groupBy groupBy
@ -1525,52 +1485,6 @@ export class PortfolioService {
}; };
} }
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
return activities
.filter((activity) => {
// Filter out all activities before given date (drafts) and type item
return (
isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.ITEM
);
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getLiabilities({
activities,
userCurrency
}: {
activities: OrderWithAccount[];
userCurrency: string;
}) {
return activities
.filter(({ type }) => {
return type === TypeOfOrder.LIABILITY;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) { switch (aDateRange) {
case '1d': case '1d':
@ -1659,9 +1573,10 @@ export class PortfolioService {
return account?.isExcluded ?? false; return account?.isExcluded ?? false;
}); });
const dividend = this.getDividend({ const dividend = this.getSumOfActivityType({
activities, activities,
userCurrency userCurrency,
activityType: 'DIVIDEND'
}).toNumber(); }).toNumber();
const emergencyFund = new Big( const emergencyFund = new Big(
Math.max( Math.max(
@ -1671,23 +1586,49 @@ export class PortfolioService {
); );
const fees = this.getFees({ activities, userCurrency }).toNumber(); const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date; const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber(); const interest = this.getSumOfActivityType({
const liabilities = this.getLiabilities({
activities, activities,
userCurrency userCurrency,
activityType: 'INTEREST'
}).toNumber();
const items = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'ITEM'
}).toNumber();
const liabilities = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'LIABILITY'
}).toNumber(); }).toNumber();
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY'); const totalBuy = this.getSumOfActivityType({
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL'); activities,
userCurrency,
activityType: 'BUY'
}).toNumber();
const totalSell = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'SELL'
}).toNumber();
const cash = new Big(balanceInBaseCurrency) const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund) .minus(emergencyFund)
.plus(emergencyFundPositionsValueInBaseCurrency) .plus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(); .toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell); const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = new Big( const totalOfExcludedActivities = this.getSumOfActivityType({
this.getTotalByType(excludedActivities, userCurrency, 'BUY') userCurrency,
).minus(this.getTotalByType(excludedActivities, userCurrency, 'SELL')); activities: excludedActivities,
activityType: 'BUY'
}).minus(
this.getSumOfActivityType({
userCurrency,
activities: excludedActivities,
activityType: 'SELL'
})
);
const cashDetailsWithExcludedAccounts = const cashDetailsWithExcludedAccounts =
await this.accountService.getCashDetails({ await this.accountService.getCashDetails({
@ -1734,6 +1675,7 @@ export class PortfolioService {
excludedAccountsAndActivities, excludedAccountsAndActivities,
fees, fees,
firstOrderDate, firstOrderDate,
interest,
items, items,
liabilities, liabilities,
netWorth, netWorth,
@ -1756,6 +1698,39 @@ export class PortfolioService {
}; };
} }
private getSumOfActivityType({
activities,
activityType,
date = new Date(0),
userCurrency
}: {
activities: OrderWithAccount[];
activityType: ActivityType;
date?: Date;
userCurrency: string;
}) {
return activities
.filter((activity) => {
// Filter out all activities before given date (drafts) and
// activity type
return (
isBefore(date, new Date(activity.date)) &&
activity.type === activityType
);
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private async getTransactionPoints({ private async getTransactionPoints({
filters, filters,
includeDrafts = false, includeDrafts = false,
@ -1772,7 +1747,7 @@ export class PortfolioService {
portfolioOrders: PortfolioOrder[]; portfolioOrders: PortfolioOrder[];
}> { }> {
const userCurrency = const userCurrency =
this.request.user?.Settings?.settings.baseCurrency ?? this.baseCurrency; this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
filters, filters,
@ -1827,6 +1802,21 @@ export class PortfolioService {
}; };
} }
private getUserCurrency(aUser: UserWithSettings) {
return (
aUser.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private async getValueOfAccountsAndPlatforms({ private async getValueOfAccountsAndPlatforms({
filters = [], filters = [],
orders, orders,
@ -1970,38 +1960,4 @@ export class PortfolioService {
return { accounts, platforms }; return { accounts, platforms };
} }
private getTotalByType(
orders: OrderWithAccount[],
currency: string,
type: TypeOfOrder
) {
return orders
.filter(
(order) => !isAfter(order.date, endOfToday()) && order.type === type
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.SymbolProfile.currency,
currency
);
})
.reduce((previous, current) => previous + current, 0);
}
private getUserCurrency(aUser: UserWithSettings) {
return (
aUser.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
} }

View File

@ -0,0 +1,36 @@
import * as fs from 'fs';
import * as path from 'path';
import {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
@Controller('sitemap.xml')
export class SitemapController {
public sitemapXml = '';
public constructor() {
try {
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@Get()
@Version(VERSION_NEUTRAL)
public async flushCache(@Res() response: Response): Promise<void> {
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
currentDate: format(getYesterday(), DATE_FORMAT)
})
);
}
}

View File

@ -0,0 +1,24 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller';
@Module({
controllers: [SitemapController],
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
]
})
export class SitemapModule {}

View File

@ -93,9 +93,8 @@ export class SubscriptionService {
public async createSubscriptionViaStripe(aCheckoutSessionId: string) { public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try { try {
const session = await this.stripe.checkout.sessions.retrieve( const session =
aCheckoutSessionId await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId);
);
await this.createSubscription({ await this.createSubscription({
price: session.amount_total / 100, price: session.amount_total / 100,

View File

@ -15,6 +15,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
@ -93,7 +94,7 @@ export class SymbolController {
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> { ): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString); const date = parseISO(dateString);
if (!isDate(date)) { if (!isDate(date)) {
throw new HttpException( throw new HttpException(

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
}

View File

@ -0,0 +1,104 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tag')
export class TagController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.createTag(data);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteTag(@Param('id') id: string) {
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.deleteTag({ id });
}
}

View File

@ -0,0 +1,13 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

View File

@ -0,0 +1,79 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name }) => {
return {
id,
name,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

View File

@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
}

View File

@ -4,7 +4,11 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
PROPERTY_IS_READ_ONLY_MODE,
locale
} from '@ghostfolio/common/config';
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
import { import {
getPermissions, getPermissions,
@ -15,24 +19,22 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash'; import { sortBy, without } from 'lodash';
const crypto = require('crypto'); const crypto = require('crypto');
@Injectable() @Injectable()
export class UserService { export class UserService {
public static DEFAULT_CURRENCY = 'USD';
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService private readonly tagService: TagService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
public async count(args?: Prisma.UserCountArgs) {
return this.prismaService.user.count(args);
} }
public async getUser( public async getUser(
@ -145,8 +147,7 @@ export class UserService {
// Set default value for base currency // Set default value for base currency
if (!(user.Settings.settings as UserSettings)?.baseCurrency) { if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
(user.Settings.settings as UserSettings).baseCurrency = (user.Settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY;
UserService.DEFAULT_CURRENCY;
} }
// Set default value for date range // Set default value for date range
@ -186,6 +187,14 @@ export class UserService {
if (Analytics?.activityCount % frequency === 1) { if (Analytics?.activityCount % frequency === 1) {
currentPermissions.push(permissions.enableSubscriptionInterstitial); currentPermissions.push(permissions.enableSubscriptionInterstitial);
} }
currentPermissions = without(
currentPermissions,
permissions.createAccess
);
// Reset benchmark
user.Settings.settings.benchmark = undefined;
} }
if (user.subscription?.type === 'Premium') { if (user.subscription?.type === 'Premium') {
@ -263,7 +272,7 @@ export class UserService {
...data, ...data,
Account: { Account: {
create: { create: {
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
isDefault: true, isDefault: true,
name: 'Default Account' name: 'Default Account'
} }
@ -271,7 +280,7 @@ export class UserService {
Settings: { Settings: {
create: { create: {
settings: { settings: {
currency: this.baseCurrency currency: DEFAULT_CURRENCY
} }
} }
} }

View File

@ -51,7 +51,9 @@
"3FT": "ThreeFold Token", "3FT": "ThreeFold Token",
"3ULL": "3ULL Coin", "3ULL": "3ULL Coin",
"3XD": "3DChain", "3XD": "3DChain",
"420CHAN": "420chan",
"4ART": "4ART Coin", "4ART": "4ART Coin",
"4CHAN": "4Chan",
"4JNET": "4JNET", "4JNET": "4JNET",
"77G": "GraphenTech", "77G": "GraphenTech",
"7E": "7ELEVEN", "7E": "7ELEVEN",
@ -60,6 +62,7 @@
"8BT": "8 Circuit Studios", "8BT": "8 Circuit Studios",
"8PAY": "8Pay", "8PAY": "8Pay",
"8X8": "8X8 Protocol", "8X8": "8X8 Protocol",
"9GAG": "9GAG",
"A5T": "Alpha5", "A5T": "Alpha5",
"AAA": "Moon Rabbit", "AAA": "Moon Rabbit",
"AAB": "AAX Token", "AAB": "AAX Token",
@ -101,6 +104,7 @@
"ACN": "AvonCoin", "ACN": "AvonCoin",
"ACOIN": "ACoin", "ACOIN": "ACoin",
"ACP": "Anarchists Prime", "ACP": "Anarchists Prime",
"ACQ": "Acquire.Fi",
"ACS": "Access Protocol", "ACS": "Access Protocol",
"ACT": "Achain", "ACT": "Achain",
"ACTIN": "Actinium", "ACTIN": "Actinium",
@ -180,7 +184,7 @@
"AGX": "Agricoin", "AGX": "Agricoin",
"AHOO": "Ahoolee", "AHOO": "Ahoolee",
"AHT": "AhaToken", "AHT": "AhaToken",
"AI": "Multiverse", "AI": "AiDoge",
"AIB": "AdvancedInternetBlock", "AIB": "AdvancedInternetBlock",
"AIBB": "AiBB", "AIBB": "AiBB",
"AIBK": "AIB Utility Token", "AIBK": "AIB Utility Token",
@ -213,6 +217,7 @@
"AKA": "Akroma", "AKA": "Akroma",
"AKITA": "Akita Inu", "AKITA": "Akita Inu",
"AKN": "Akoin", "AKN": "Akoin",
"AKNC": "Aave KNC v1",
"AKRO": "Akropolis", "AKRO": "Akropolis",
"AKT": "Akash Network", "AKT": "Akash Network",
"AKTIO": "AKTIO Coin", "AKTIO": "AKTIO Coin",
@ -237,12 +242,14 @@
"ALIC": "AliCoin", "ALIC": "AliCoin",
"ALICE": "My Neighbor Alice", "ALICE": "My Neighbor Alice",
"ALIEN": "AlienCoin", "ALIEN": "AlienCoin",
"ALINK": "Aave LINK v1",
"ALIS": "ALISmedia", "ALIS": "ALISmedia",
"ALITA": "Alita Network", "ALITA": "Alita Network",
"ALIX": "AlinX", "ALIX": "AlinX",
"ALKI": "Alkimi", "ALKI": "Alkimi",
"ALLBI": "ALL BEST ICO", "ALLBI": "ALL BEST ICO",
"ALLEY": "NFT Alley", "ALLEY": "NFT Alley",
"ALLIN": "All in",
"ALN": "Aluna", "ALN": "Aluna",
"ALOHA": "Aloha", "ALOHA": "Aloha",
"ALP": "Alphacon", "ALP": "Alphacon",
@ -410,12 +417,14 @@
"ARIX": "Arix", "ARIX": "Arix",
"ARK": "ARK", "ARK": "ARK",
"ARKER": "Arker", "ARKER": "Arker",
"ARKM": "Arkham",
"ARKN": "Ark Rivals", "ARKN": "Ark Rivals",
"ARM": "Armory Coin", "ARM": "Armory Coin",
"ARMOR": "ARMOR", "ARMOR": "ARMOR",
"ARMR": "ARMR", "ARMR": "ARMR",
"ARMS": "2Acoin", "ARMS": "2Acoin",
"ARNA": "ARNA Panacea", "ARNA": "ARNA Panacea",
"ARNM": "Arenum",
"ARNO": "ARNO", "ARNO": "ARNO",
"ARNX": "Aeron", "ARNX": "Aeron",
"ARNXM": "Armor NXM", "ARNXM": "Armor NXM",
@ -472,6 +481,7 @@
"ASTO": "Altered State Token", "ASTO": "Altered State Token",
"ASTON": "Aston", "ASTON": "Aston",
"ASTR": "Astar", "ASTR": "Astar",
"ASTRAFER": "Astrafer",
"ASTRAL": "Astral", "ASTRAL": "Astral",
"ASTRO": "AstroSwap", "ASTRO": "AstroSwap",
"ASTROC": "Astroport Classic", "ASTROC": "Astroport Classic",
@ -531,6 +541,7 @@
"AURY": "Aurory", "AURY": "Aurory",
"AUSCM": "Auric Network", "AUSCM": "Auric Network",
"AUSD": "Appeal dollar", "AUSD": "Appeal dollar",
"AUSDC": "Aave USDC v1",
"AUT": "Autoria", "AUT": "Autoria",
"AUTHORSHIP": "Authorship", "AUTHORSHIP": "Authorship",
"AUTO": "Auto", "AUTO": "Auto",
@ -612,6 +623,7 @@
"BACK": "DollarBack", "BACK": "DollarBack",
"BACOIN": "BACoin", "BACOIN": "BACoin",
"BACON": "BaconDAO (BACON)", "BACON": "BaconDAO (BACON)",
"BAD": "Bad Idea AI",
"BADGER": "Badger DAO", "BADGER": "Badger DAO",
"BAG": "BondAppetit", "BAG": "BondAppetit",
"BAGS": "Basis Gold Share", "BAGS": "Basis Gold Share",
@ -662,6 +674,7 @@
"BBCT": "TraDove B2BCoin", "BBCT": "TraDove B2BCoin",
"BBDT": "BBD Token", "BBDT": "BBD Token",
"BBF": "Bubblefong", "BBF": "Bubblefong",
"BBFT": "Block Busters Tech Token",
"BBG": "BigBang", "BBG": "BigBang",
"BBGC": "BigBang Game", "BBGC": "BigBang Game",
"BBI": "BelugaPay", "BBI": "BelugaPay",
@ -725,6 +738,7 @@
"BDX": "Beldex", "BDX": "Beldex",
"BDY": "Buddy DAO", "BDY": "Buddy DAO",
"BEACH": "BeachCoin", "BEACH": "BeachCoin",
"BEAI": "BeNFT Solutions",
"BEAM": "Beam", "BEAM": "Beam",
"BEAN": "BeanCash", "BEAN": "BeanCash",
"BEAST": "CryptoBeast", "BEAST": "CryptoBeast",
@ -806,6 +820,7 @@
"BIDR": "Binance IDR Stable Coin", "BIDR": "Binance IDR Stable Coin",
"BIFI": "Beefy.Finance", "BIFI": "Beefy.Finance",
"BIFIF": "BiFi", "BIFIF": "BiFi",
"BIG": "Big Eyes",
"BIGHAN": "BighanCoin", "BIGHAN": "BighanCoin",
"BIGSB": "BigShortBets", "BIGSB": "BigShortBets",
"BIGUP": "BigUp", "BIGUP": "BigUp",
@ -1090,6 +1105,7 @@
"BRNK": "Brank", "BRNK": "Brank",
"BRNX": "Bronix", "BRNX": "Bronix",
"BRO": "Bitradio", "BRO": "Bitradio",
"BROCK": "Bitrock",
"BRONZ": "BitBronze", "BRONZ": "BitBronze",
"BRT": "Bikerush", "BRT": "Bikerush",
"BRTR": "Barter", "BRTR": "Barter",
@ -1226,7 +1242,7 @@
"BULL": "Bullieverse", "BULL": "Bullieverse",
"BULLC": "BuySell", "BULLC": "BuySell",
"BULLION": "BullionFX", "BULLION": "BullionFX",
"BULLS": "BullshitCoin", "BULLS": "Bull Coin",
"BULLSH": "Bullshit Inu", "BULLSH": "Bullshit Inu",
"BUMN": "BUMooN", "BUMN": "BUMooN",
"BUMP": "Bumper", "BUMP": "Bumper",
@ -1277,6 +1293,7 @@
"BZKY": "Bizkey", "BZKY": "Bizkey",
"BZL": "BZLCoin", "BZL": "BZLCoin",
"BZNT": "Bezant", "BZNT": "Bezant",
"BZR": "Bazaars",
"BZRX": "bZx Protocol", "BZRX": "bZx Protocol",
"BZX": "Bitcoin Zero", "BZX": "Bitcoin Zero",
"BZZ": "Swarmv", "BZZ": "Swarmv",
@ -1319,8 +1336,10 @@
"CAP": "BottleCaps", "CAP": "BottleCaps",
"CAPD": "Capdax", "CAPD": "Capdax",
"CAPP": "Cappasity", "CAPP": "Cappasity",
"CAPRICOIN": "CapriCoin",
"CAPS": "Ternoa", "CAPS": "Ternoa",
"CAPT": "Bitcoin Captain", "CAPT": "Bitcoin Captain",
"CAPTAINPLANET": "Captain Planet",
"CAR": "CarBlock", "CAR": "CarBlock",
"CARAT": "Carats Token", "CARAT": "Carats Token",
"CARBON": "Carboncoin", "CARBON": "Carboncoin",
@ -1478,6 +1497,7 @@
"CHECKR": "CheckerChain", "CHECKR": "CheckerChain",
"CHECOIN": "CheCoin", "CHECOIN": "CheCoin",
"CHEDDA": "Chedda", "CHEDDA": "Chedda",
"CHEEL": "Cheelee",
"CHEESE": "CHEESE", "CHEESE": "CHEESE",
"CHEESUS": "Cheesus", "CHEESUS": "Cheesus",
"CHEQ": "CHEQD Network", "CHEQ": "CHEQD Network",
@ -1520,7 +1540,8 @@
"CHX": "Own", "CHX": "Own",
"CHY": "Concern Poverty Chain", "CHY": "Concern Poverty Chain",
"CHZ": "Chiliz", "CHZ": "Chiliz",
"CIC": "CIChain", "CIC": "Crazy Internet Coin",
"CICHAIN": "CIChain",
"CIF": "Crypto Improvement Fund", "CIF": "Crypto Improvement Fund",
"CIM": "COINCOME", "CIM": "COINCOME",
"CIN": "CinderCoin", "CIN": "CinderCoin",
@ -1630,7 +1651,6 @@
"COB": "Cobinhood", "COB": "Cobinhood",
"COC": "Coin of the champions", "COC": "Coin of the champions",
"COCK": "Shibacock", "COCK": "Shibacock",
"COCOS": "COCOS BCX",
"CODEO": "Codeo Token", "CODEO": "Codeo Token",
"CODEX": "CODEX Finance", "CODEX": "CODEX Finance",
"CODI": "Codi Finance", "CODI": "Codi Finance",
@ -1659,7 +1679,7 @@
"COLX": "ColossusCoinXT", "COLX": "ColossusCoinXT",
"COM": "Coliseum", "COM": "Coliseum",
"COMB": "Combo", "COMB": "Combo",
"COMBO": "Furucombo", "COMBO": "COMBO",
"COMFI": "CompliFi", "COMFI": "CompliFi",
"COMM": "Community Coin", "COMM": "Community Coin",
"COMMUNITYCOIN": "Community Coin", "COMMUNITYCOIN": "Community Coin",
@ -1672,7 +1692,6 @@
"CONI": "CoinBene", "CONI": "CoinBene",
"CONS": "ConSpiracy Coin", "CONS": "ConSpiracy Coin",
"CONSENTIUM": "Consentium", "CONSENTIUM": "Consentium",
"CONT": "Contentos",
"CONUN": "CONUN", "CONUN": "CONUN",
"CONV": "Convergence", "CONV": "Convergence",
"COOK": "Cook", "COOK": "Cook",
@ -1683,17 +1702,19 @@
"COPS": "Cops Finance", "COPS": "Cops Finance",
"COR": "Corion", "COR": "Corion",
"CORAL": "CoralPay", "CORAL": "CoralPay",
"CORE": "Coreum", "CORE": "Core",
"COREDAO": "coreDAO", "COREDAO": "coreDAO",
"COREG": "Core Group Asset", "COREG": "Core Group Asset",
"COREUM": "Coreum",
"CORGI": "Corgi Inu", "CORGI": "Corgi Inu",
"CORN": "CORN", "CORN": "CORN",
"CORX": "CorionX", "CORX": "CorionX",
"COS": "COS", "COS": "Contentos",
"COSHI": "CoShi Inu", "COSHI": "CoShi Inu",
"COSM": "CosmoChain", "COSM": "CosmoChain",
"COSMIC": "CosmicSwap", "COSMIC": "CosmicSwap",
"COSP": "Cosplay Token", "COSP": "Cosplay Token",
"COSS": "COS",
"COSX": "Cosmecoin", "COSX": "Cosmecoin",
"COT": "CoTrader", "COT": "CoTrader",
"COTI": "COTI", "COTI": "COTI",
@ -1729,7 +1750,7 @@
"CPOOL": "Clearpool", "CPOOL": "Clearpool",
"CPROP": "CPROP", "CPROP": "CPROP",
"CPRX": "Crypto Perx", "CPRX": "Crypto Perx",
"CPS": "CapriCoin", "CPS": "Cryptostone",
"CPT": "Cryptaur", "CPT": "Cryptaur",
"CPU": "CPUcoin", "CPU": "CPUcoin",
"CPX": "Apex Token", "CPX": "Apex Token",
@ -1796,6 +1817,7 @@
"CRTS": "Cratos", "CRTS": "Cratos",
"CRU": "Crust Network", "CRU": "Crust Network",
"CRV": "Curve DAO Token", "CRV": "Curve DAO Token",
"CRVUSD": "crvUSD",
"CRW": "Crown Coin", "CRW": "Crown Coin",
"CRWD": "CRWD Network", "CRWD": "CRWD Network",
"CRWNY": "Crowny Token", "CRWNY": "Crowny Token",
@ -1843,7 +1865,7 @@
"CTLX": "Cash Telex", "CTLX": "Cash Telex",
"CTN": "Continuum Finance", "CTN": "Continuum Finance",
"CTO": "Crypto", "CTO": "Crypto",
"CTP": "Captain Planet", "CTP": "Ctomorrow Platform",
"CTPL": "Cultiplan", "CTPL": "Cultiplan",
"CTPT": "Contents Protocol", "CTPT": "Contents Protocol",
"CTR": "Creator Platform", "CTR": "Creator Platform",
@ -2007,6 +2029,7 @@
"DBC": "DeepBrain Chain", "DBC": "DeepBrain Chain",
"DBCCOIN": "Datablockchain", "DBCCOIN": "Datablockchain",
"DBD": "Day By Day", "DBD": "Day By Day",
"DBEAR": "DBear Coin",
"DBET": "Decent.bet", "DBET": "Decent.bet",
"DBIC": "DubaiCoin", "DBIC": "DubaiCoin",
"DBIX": "DubaiCoin", "DBIX": "DubaiCoin",
@ -2058,6 +2081,7 @@
"DEEP": "DeepCloud AI", "DEEP": "DeepCloud AI",
"DEEPG": "Deep Gold", "DEEPG": "Deep Gold",
"DEEX": "DEEX", "DEEX": "DEEX",
"DEEZ": "DEEZ NUTS",
"DEFI": "Defi", "DEFI": "Defi",
"DEFI5": "DEFI Top 5 Tokens Index", "DEFI5": "DEFI Top 5 Tokens Index",
"DEFIL": "DeFIL", "DEFIL": "DeFIL",
@ -2162,11 +2186,12 @@
"DIEM": "Facebook Diem", "DIEM": "Facebook Diem",
"DIESEL": "Diesel", "DIESEL": "Diesel",
"DIFX": "Digital Financial Exchange", "DIFX": "Digital Financial Exchange",
"DIG": "Dignity", "DIG": "DIEGO",
"DIGG": "DIGG", "DIGG": "DIGG",
"DIGIC": "DigiCube", "DIGIC": "DigiCube",
"DIGIF": "DigiFel", "DIGIF": "DigiFel",
"DIGITAL": "Digital Reserve Currency", "DIGITAL": "Digital Reserve Currency",
"DIGNITY": "Dignity",
"DIGS": "Diggits", "DIGS": "Diggits",
"DIKO": "Arkadiko", "DIKO": "Arkadiko",
"DILI": "D Community", "DILI": "D Community",
@ -2246,6 +2271,7 @@
"DOGBOSS": "Dog Boss", "DOGBOSS": "Dog Boss",
"DOGDEFI": "DogDeFiCoin", "DOGDEFI": "DogDeFiCoin",
"DOGE": "Dogecoin", "DOGE": "Dogecoin",
"DOGE20": "Doge 2.0",
"DOGEBNB": "DogeBNB", "DOGEBNB": "DogeBNB",
"DOGEC": "DogeCash", "DOGEC": "DogeCash",
"DOGECEO": "Doge CEO", "DOGECEO": "Doge CEO",
@ -2539,7 +2565,7 @@
"ELONGT": "Elon GOAT", "ELONGT": "Elon GOAT",
"ELONONE": "AstroElon", "ELONONE": "AstroElon",
"ELP": "Ellerium", "ELP": "Ellerium",
"ELS": "Elysium", "ELS": "Ethlas",
"ELT": "Element Black", "ELT": "Element Black",
"ELTC2": "eLTC", "ELTC2": "eLTC",
"ELTCOIN": "ELTCOIN", "ELTCOIN": "ELTCOIN",
@ -2548,6 +2574,7 @@
"ELVN": "11Minutes", "ELVN": "11Minutes",
"ELX": "Energy Ledger", "ELX": "Energy Ledger",
"ELY": "Elysian", "ELY": "Elysian",
"ELYSIUM": "Elysium",
"EM": "Eminer", "EM": "Eminer",
"EMANATE": "EMANATE", "EMANATE": "EMANATE",
"EMAR": "EmaratCoin", "EMAR": "EmaratCoin",
@ -2559,6 +2586,7 @@
"EMC2": "Einsteinium", "EMC2": "Einsteinium",
"EMD": "Emerald", "EMD": "Emerald",
"EMIGR": "EmiratesGoldCoin", "EMIGR": "EmiratesGoldCoin",
"EML": "EML Protocol",
"EMN.CUR": "Eastman Chemical", "EMN.CUR": "Eastman Chemical",
"EMON": "Ethermon", "EMON": "Ethermon",
"EMOT": "Sentigraph.io", "EMOT": "Sentigraph.io",
@ -2692,6 +2720,7 @@
"ETHD": "Ethereum Dark", "ETHD": "Ethereum Dark",
"ETHER": "Etherparty", "ETHER": "Etherparty",
"ETHERDELTA": "EtherDelta", "ETHERDELTA": "EtherDelta",
"ETHERKING": "Ether Kingdoms Token",
"ETHERNITY": "Ethernity Chain", "ETHERNITY": "Ethernity Chain",
"ETHF": "EthereumFair", "ETHF": "EthereumFair",
"ETHIX": "EthicHub", "ETHIX": "EthicHub",
@ -2709,6 +2738,7 @@
"ETHSHIB": "Eth Shiba", "ETHSHIB": "Eth Shiba",
"ETHV": "Ethverse", "ETHV": "Ethverse",
"ETHW": "Ethereum PoW", "ETHW": "Ethereum PoW",
"ETHX": "Stader ETHx",
"ETHY": "Ethereum Yield", "ETHY": "Ethereum Yield",
"ETI": "EtherInc", "ETI": "EtherInc",
"ETK": "Energi Token", "ETK": "Energi Token",
@ -2722,7 +2752,7 @@
"ETR": "Electric Token", "ETR": "Electric Token",
"ETRNT": "Eternal Trusts", "ETRNT": "Eternal Trusts",
"ETS": "ETH Share", "ETS": "ETH Share",
"ETSC": "Ether star blockchain", "ETSC": "Ether star blockchain",
"ETT": "EncryptoTel", "ETT": "EncryptoTel",
"ETY": "Ethereum Cloud", "ETY": "Ethereum Cloud",
"ETZ": "EtherZero", "ETZ": "EtherZero",
@ -2773,6 +2803,7 @@
"EXB": "ExaByte (EXB)", "EXB": "ExaByte (EXB)",
"EXC": "Eximchain", "EXC": "Eximchain",
"EXCC": "ExchangeCoin", "EXCC": "ExchangeCoin",
"EXCHANGEN": "ExchangeN",
"EXCL": "Exclusive Coin", "EXCL": "Exclusive Coin",
"EXE": "ExeCoin", "EXE": "ExeCoin",
"EXFI": "Flare Finance", "EXFI": "Flare Finance",
@ -2781,7 +2812,7 @@
"EXLT": "ExtraLovers", "EXLT": "ExtraLovers",
"EXM": "EXMO Coin", "EXM": "EXMO Coin",
"EXMR": "EXMR FDN", "EXMR": "EXMR FDN",
"EXN": "ExchangeN", "EXN": "Exeno",
"EXO": "Exosis", "EXO": "Exosis",
"EXP": "Expanse", "EXP": "Expanse",
"EXRD": "Radix", "EXRD": "Radix",
@ -2814,6 +2845,7 @@
"FAIR": "FairCoin", "FAIR": "FairCoin",
"FAIRC": "Faireum Token", "FAIRC": "Faireum Token",
"FAIRG": "FairGame", "FAIRG": "FairGame",
"FAKE": "FAKE COIN",
"FAKT": "Medifakt", "FAKT": "Medifakt",
"FALCONS": "Falcon Swaps", "FALCONS": "Falcon Swaps",
"FAME": "Fame MMA", "FAME": "Fame MMA",
@ -2860,6 +2892,7 @@
"FDO": "Firdaos", "FDO": "Firdaos",
"FDR": "French Digital Reserve", "FDR": "French Digital Reserve",
"FDT": "Frutti Dino", "FDT": "Frutti Dino",
"FDUSD": "First Digital USD",
"FDX": "fidentiaX", "FDX": "fidentiaX",
"FDZ": "Friendz", "FDZ": "Friendz",
"FEAR": "Fear", "FEAR": "Fear",
@ -2870,6 +2903,7 @@
"FEN": "First Ever NFT", "FEN": "First Ever NFT",
"FENOMY": "Fenomy", "FENOMY": "Fenomy",
"FER": "Ferro", "FER": "Ferro",
"FERC": "FairERC20",
"FERMA": "Ferma", "FERMA": "Ferma",
"FESS": "Fesschain", "FESS": "Fesschain",
"FET": "Fetch.AI", "FET": "Fetch.AI",
@ -2931,7 +2965,7 @@
"FLASH": "Flashstake", "FLASH": "Flashstake",
"FLASHC": "FLASH coin", "FLASHC": "FLASH coin",
"FLC": "FlowChainCoin", "FLC": "FlowChainCoin",
"FLD": "FLUID", "FLD": "FluidAI",
"FLDC": "Folding Coin", "FLDC": "Folding Coin",
"FLDT": "FairyLand", "FLDT": "FairyLand",
"FLETA": "FLETA", "FLETA": "FLETA",
@ -3091,6 +3125,7 @@
"FUEL": "Jetfuel Finance", "FUEL": "Jetfuel Finance",
"FUJIN": "Fujinto", "FUJIN": "Fujinto",
"FUKU": "Furukuru", "FUKU": "Furukuru",
"FUMO": "Alien Milady Fumo",
"FUN": "FUN Token", "FUN": "FUN Token",
"FUNC": "FunCoin", "FUNC": "FunCoin",
"FUND": "Unification", "FUND": "Unification",
@ -3101,6 +3136,7 @@
"FUNDZ": "FundFantasy", "FUNDZ": "FundFantasy",
"FUNK": "Cypherfunks Coin", "FUNK": "Cypherfunks Coin",
"FUR": "Furio", "FUR": "Furio",
"FURU": "Furucombo",
"FURY": "Engines of Fury", "FURY": "Engines of Fury",
"FUS": "Fus", "FUS": "Fus",
"FUSE": "Fuse Network Token", "FUSE": "Fuse Network Token",
@ -3118,6 +3154,7 @@
"FXP": "FXPay", "FXP": "FXPay",
"FXS": "Frax Share", "FXS": "Frax Share",
"FXT": "FuzeX", "FXT": "FuzeX",
"FXY": "Floxypay",
"FYN": "Affyn", "FYN": "Affyn",
"FYP": "FlypMe", "FYP": "FlypMe",
"FYZ": "Fyooz", "FYZ": "Fyooz",
@ -3172,6 +3209,7 @@
"GAT": "GATCOIN", "GAT": "GATCOIN",
"GATE": "GATENet", "GATE": "GATENet",
"GATEWAY": "Gateway Protocol", "GATEWAY": "Gateway Protocol",
"GAYPEPE": "Gay Pepe",
"GAZE": "GazeTV", "GAZE": "GazeTV",
"GB": "GoldBlocks", "GB": "GoldBlocks",
"GBA": "Geeba", "GBA": "Geeba",
@ -3222,6 +3260,7 @@
"GEMZ": "Gemz Social", "GEMZ": "Gemz Social",
"GEN": "DAOstack", "GEN": "DAOstack",
"GENE": "Genopets", "GENE": "Genopets",
"GENIE": "The Genie",
"GENIX": "Genix", "GENIX": "Genix",
"GENS": "Genshiro", "GENS": "Genshiro",
"GENSTAKE": "Genstake", "GENSTAKE": "Genstake",
@ -3261,6 +3300,7 @@
"GHCOLD": "Galaxy Heroes Coin", "GHCOLD": "Galaxy Heroes Coin",
"GHD": "Giftedhands", "GHD": "Giftedhands",
"GHNY": "Grizzly Honey", "GHNY": "Grizzly Honey",
"GHO": "GHO",
"GHOST": "GhostbyMcAfee", "GHOST": "GhostbyMcAfee",
"GHOSTCOIN": "GhostCoin", "GHOSTCOIN": "GhostCoin",
"GHOSTM": "GhostMarket", "GHOSTM": "GhostMarket",
@ -3274,6 +3314,7 @@
"GIFT": "GiftNet", "GIFT": "GiftNet",
"GIG": "GigaCoin", "GIG": "GigaCoin",
"GIGA": "GigaSwap", "GIGA": "GigaSwap",
"GIGX": "GigXCoin",
"GIM": "Gimli", "GIM": "Gimli",
"GIMMER": "Gimmer", "GIMMER": "Gimmer",
"GIN": "GINcoin", "GIN": "GINcoin",
@ -3385,6 +3426,7 @@
"GOVT": "The Government Network", "GOVT": "The Government Network",
"GOZ": "Göztepe S.K. Fan Token", "GOZ": "Göztepe S.K. Fan Token",
"GP": "Wizards And Dragons", "GP": "Wizards And Dragons",
"GPBP": "Genius Playboy Billionaire Philanthropist",
"GPKR": "Gold Poker", "GPKR": "Gold Poker",
"GPL": "Gold Pressed Latinum", "GPL": "Gold Pressed Latinum",
"GPPT": "Pluto Project Coin", "GPPT": "Pluto Project Coin",
@ -3501,7 +3543,8 @@
"HALF": "0.5X Long Bitcoin Token", "HALF": "0.5X Long Bitcoin Token",
"HALFSHIT": "0.5X Long Shitcoin Index Token", "HALFSHIT": "0.5X Long Shitcoin Index Token",
"HALLO": "Halloween Coin", "HALLO": "Halloween Coin",
"HALO": "Halo Platform", "HALO": "Halo Coin",
"HALOPLATFORM": "Halo Platform",
"HAM": "Hamster", "HAM": "Hamster",
"HAMS": "HamsterCoin", "HAMS": "HamsterCoin",
"HANA": "Hanacoin", "HANA": "Hanacoin",
@ -3598,6 +3641,7 @@
"HILL": "President Clinton", "HILL": "President Clinton",
"HINA": "Hina Inu", "HINA": "Hina Inu",
"HINT": "Hintchain", "HINT": "Hintchain",
"HIPPO": "HIPPO",
"HIRE": "HireMatch", "HIRE": "HireMatch",
"HIT": "HitChain", "HIT": "HitChain",
"HITBTC": "HitBTC Token", "HITBTC": "HitBTC Token",
@ -3634,6 +3678,7 @@
"HNTR": "Hunter", "HNTR": "Hunter",
"HNY": "Honey", "HNY": "Honey",
"HNZO": "Hanzo Inu", "HNZO": "Hanzo Inu",
"HOBO": "HOBO THE BEAR",
"HOD": "HoDooi.com", "HOD": "HoDooi.com",
"HODL": "HOdlcoin", "HODL": "HOdlcoin",
"HOGE": "Hoge Finance", "HOGE": "Hoge Finance",
@ -3839,7 +3884,7 @@
"IMPCN": "Brain Space", "IMPCN": "Brain Space",
"IMPER": "Impermax", "IMPER": "Impermax",
"IMPS": "Impulse Coin", "IMPS": "Impulse Coin",
"IMPT": "Ether Kingdoms Token", "IMPT": "IMPT",
"IMPULSE": "IMPULSE by FDR", "IMPULSE": "IMPULSE by FDR",
"IMS": "Independent Money System", "IMS": "Independent Money System",
"IMST": "Imsmart", "IMST": "Imsmart",
@ -4001,6 +4046,7 @@
"JAM": "Tune.Fm", "JAM": "Tune.Fm",
"JANE": "JaneCoin", "JANE": "JaneCoin",
"JAR": "Jarvis+", "JAR": "Jarvis+",
"JARED": "Jared From Subway",
"JASMY": "JasmyCoin", "JASMY": "JasmyCoin",
"JBS": "JumBucks Coin", "JBS": "JumBucks Coin",
"JBX": "Juicebox", "JBX": "Juicebox",
@ -4163,9 +4209,10 @@
"KIN": "Kin", "KIN": "Kin",
"KIND": "Kind Ads", "KIND": "Kind Ads",
"KINE": "Kine Protocol", "KINE": "Kine Protocol",
"KING": "King Finance", "KING": "KING",
"KING93": "King93", "KING93": "King93",
"KINGDOMQUEST": "Kingdom Quest", "KINGDOMQUEST": "Kingdom Quest",
"KINGF": "King Finance",
"KINGSHIB": "King Shiba", "KINGSHIB": "King Shiba",
"KINGSWAP": "KingSwap", "KINGSWAP": "KingSwap",
"KINT": "Kintsugi", "KINT": "Kintsugi",
@ -4175,6 +4222,7 @@
"KISC": "Kaiser", "KISC": "Kaiser",
"KISHIMOTO": "Kishimoto Inu", "KISHIMOTO": "Kishimoto Inu",
"KISHU": "Kishu Inu", "KISHU": "Kishu Inu",
"KITA": "KITA INU",
"KITSU": "Kitsune Inu", "KITSU": "Kitsune Inu",
"KITTY": "Kitty Inu", "KITTY": "Kitty Inu",
"KKO": "Kineko", "KKO": "Kineko",
@ -4267,10 +4315,12 @@
"KUBO": "KUBO", "KUBO": "KUBO",
"KUBOS": "KubosCoin", "KUBOS": "KubosCoin",
"KUE": "Kuende", "KUE": "Kuende",
"KUJI": "Kujira",
"KUMA": "Kuma Inu", "KUMA": "Kuma Inu",
"KUNCI": "Kunci Coin", "KUNCI": "Kunci Coin",
"KUR": "Kuro", "KUR": "Kuro",
"KURT": "Kurrent", "KURT": "Kurrent",
"KUSA": "Kusa Inu",
"KUSD": "Kowala", "KUSD": "Kowala",
"KUSH": "KushCoin", "KUSH": "KushCoin",
"KUV": "Kuverit", "KUV": "Kuverit",
@ -4280,6 +4330,7 @@
"KVT": "Kinesis Velocity Token", "KVT": "Kinesis Velocity Token",
"KWATT": "4New", "KWATT": "4New",
"KWD": "KIWI DEFI", "KWD": "KIWI DEFI",
"KWENTA": "Kwenta",
"KWH": "KWHCoin", "KWH": "KWHCoin",
"KWIK": "KwikSwap", "KWIK": "KwikSwap",
"KWS": "Knight War Spirits", "KWS": "Knight War Spirits",
@ -4299,7 +4350,9 @@
"LABX": "Stakinglab", "LABX": "Stakinglab",
"LACCOIN": "LocalAgro", "LACCOIN": "LocalAgro",
"LACE": "Lovelace World", "LACE": "Lovelace World",
"LADYS": "Milady Meme Coin",
"LAEEB": "LaEeb", "LAEEB": "LaEeb",
"LAELAPS": "Laelaps",
"LAIKA": "Laika Protocol", "LAIKA": "Laika Protocol",
"LALA": "LaLa World", "LALA": "LaLa World",
"LAMB": "Lambda", "LAMB": "Lambda",
@ -4455,13 +4508,14 @@
"LLAND": "Lyfe Land", "LLAND": "Lyfe Land",
"LLG": "Loligo", "LLG": "Loligo",
"LLION": "Lydian Lion", "LLION": "Lydian Lion",
"LM": "LM Token", "LM": "LeisureMeta",
"LMAO": "LMAO Finance", "LMAO": "LMAO Finance",
"LMC": "LomoCoin", "LMC": "LomoCoin",
"LMCH": "Latamcash", "LMCH": "Latamcash",
"LMCSWAP": "LimoCoin SWAP", "LMCSWAP": "LimoCoin SWAP",
"LMR": "Lumerin", "LMR": "Lumerin",
"LMT": "Lympo Market Token", "LMT": "Lympo Market Token",
"LMTOKEN": "LM Token",
"LMXC": "LimonX", "LMXC": "LimonX",
"LMY": "Lunch Money", "LMY": "Lunch Money",
"LN": "LINK", "LN": "LINK",
@ -4530,6 +4584,7 @@
"LRG": "Largo Coin", "LRG": "Largo Coin",
"LRN": "Loopring [NEO]", "LRN": "Loopring [NEO]",
"LSD": "LightSpeedCoin", "LSD": "LightSpeedCoin",
"LSETH": "Liquid Staked ETH",
"LSK": "Lisk", "LSK": "Lisk",
"LSP": "Lumenswap", "LSP": "Lumenswap",
"LSS": "Lossless", "LSS": "Lossless",
@ -4626,6 +4681,7 @@
"MAEP": "Maester Protocol", "MAEP": "Maester Protocol",
"MAG": "Magnet", "MAG": "Magnet",
"MAGIC": "Magic", "MAGIC": "Magic",
"MAGICF": "MagicFox",
"MAHA": "MahaDAO", "MAHA": "MahaDAO",
"MAI": "Mindsync", "MAI": "Mindsync",
"MAID": "MaidSafe Coin", "MAID": "MaidSafe Coin",
@ -4639,6 +4695,7 @@
"MANDOX": "MandoX", "MANDOX": "MandoX",
"MANGA": "Manga Token", "MANGA": "Manga Token",
"MANNA": "Manna", "MANNA": "Manna",
"MANTLE": "Mantle",
"MAP": "MAP Protocol", "MAP": "MAP Protocol",
"MAPC": "MapCoin", "MAPC": "MapCoin",
"MAPE": "Mecha Morphing", "MAPE": "Mecha Morphing",
@ -4672,6 +4729,7 @@
"MATIC": "Polygon", "MATIC": "Polygon",
"MATPAD": "MaticPad", "MATPAD": "MaticPad",
"MATTER": "AntiMatter", "MATTER": "AntiMatter",
"MAV": "Maverick Protocol",
"MAX": "MaxCoin", "MAX": "MaxCoin",
"MAXR": "Max Revive", "MAXR": "Max Revive",
"MAY": "Theresa May Coin", "MAY": "Theresa May Coin",
@ -4776,6 +4834,7 @@
"MESA": "MetaVisa", "MESA": "MetaVisa",
"MESG": "MESG", "MESG": "MESG",
"MESH": "MeshBox", "MESH": "MeshBox",
"MESSI": "MESSI COIN",
"MET": "Metronome", "MET": "Metronome",
"META": "Metadium", "META": "Metadium",
"METAC": "Metacoin", "METAC": "Metacoin",
@ -4881,6 +4940,7 @@
"MIODIO": "MIODIOCOIN", "MIODIO": "MIODIOCOIN",
"MIOTA": "IOTA", "MIOTA": "IOTA",
"MIR": "Mirror Protocol", "MIR": "Mirror Protocol",
"MIRACLE": "MIRACLE",
"MIRC": "MIR COIN", "MIRC": "MIR COIN",
"MIS": "Mithril Share", "MIS": "Mithril Share",
"MISA": "Sangkara", "MISA": "Sangkara",
@ -4938,7 +4998,6 @@
"MNRB": "MoneyRebel", "MNRB": "MoneyRebel",
"MNS": "Monnos", "MNS": "Monnos",
"MNST": "MoonStarter", "MNST": "MoonStarter",
"MNT": "microNFT",
"MNTC": "Manet Coin", "MNTC": "Manet Coin",
"MNTG": "Monetas", "MNTG": "Monetas",
"MNTL": "AssetMantle", "MNTL": "AssetMantle",
@ -4967,6 +5026,7 @@
"MOF": "Molecular Future (TRC20)", "MOF": "Molecular Future (TRC20)",
"MOFI": "MobiFi", "MOFI": "MobiFi",
"MOFOLD": "Molecular Future (ERC20)", "MOFOLD": "Molecular Future (ERC20)",
"MOG": "Mog Coin",
"MOGU": "Mogu", "MOGU": "Mogu",
"MOGX": "Mogu", "MOGX": "Mogu",
"MOI": "MyOwnItem", "MOI": "MyOwnItem",
@ -4989,9 +5049,11 @@
"MONEYIMT": "MoneyToken", "MONEYIMT": "MoneyToken",
"MONF": "Monfter", "MONF": "Monfter",
"MONG": "MongCoin", "MONG": "MongCoin",
"MONG20": "Mongoose 2.0",
"MONI": "Monsta Infinite", "MONI": "Monsta Infinite",
"MONK": "Monkey Project", "MONK": "Monkey Project",
"MONKEY": "Monkey", "MONKEY": "Monkey",
"MONKEYS": "Monkeys Token",
"MONO": "MonoX", "MONO": "MonoX",
"MONONOKEINU": "Mononoke Inu", "MONONOKEINU": "Mononoke Inu",
"MONS": "Monsters Clan", "MONS": "Monsters Clan",
@ -5011,11 +5073,13 @@
"MOONSHOT": "Moonshot", "MOONSHOT": "Moonshot",
"MOOO": "Hashtagger", "MOOO": "Hashtagger",
"MOOV": "dotmoovs", "MOOV": "dotmoovs",
"MOOX": "Moox Protocol",
"MOPS": "Mops", "MOPS": "Mops",
"MORA": "Meliora", "MORA": "Meliora",
"MORE": "More Coin", "MORE": "More Coin",
"MOS": "MOS Coin", "MOS": "MOS Coin",
"MOT": "Olympus Labs", "MOT": "Olympus Labs",
"MOTG": "MetaOctagon",
"MOTI": "Motion", "MOTI": "Motion",
"MOTO": "Motocoin", "MOTO": "Motocoin",
"MOV": "MovieCoin", "MOV": "MovieCoin",
@ -5076,6 +5140,7 @@
"MSWAP": "MoneySwap", "MSWAP": "MoneySwap",
"MT": "MyToken", "MT": "MyToken",
"MTA": "Meta", "MTA": "Meta",
"MTB": "MetaBridge",
"MTBC": "Metabolic", "MTBC": "Metabolic",
"MTC": "MEDICAL TOKEN CURRENCY", "MTC": "MEDICAL TOKEN CURRENCY",
"MTCMN": "MTC Mesh", "MTCMN": "MTC Mesh",
@ -5108,6 +5173,7 @@
"MUE": "MonetaryUnit", "MUE": "MonetaryUnit",
"MULTI": "Multichain", "MULTI": "Multichain",
"MULTIBOT": "Multibot", "MULTIBOT": "Multibot",
"MULTIV": "Multiverse",
"MUN": "MUNcoin", "MUN": "MUNcoin",
"MUNCH": "Munch Token", "MUNCH": "Munch Token",
"MUSD": "mStable USD", "MUSD": "mStable USD",
@ -5648,6 +5714,7 @@
"OZP": "OZAPHYRE", "OZP": "OZAPHYRE",
"P202": "Project 202", "P202": "Project 202",
"P2PS": "P2P Solutions Foundation", "P2PS": "P2P Solutions Foundation",
"PAAL": "PAAL AI",
"PAC": "PAC Protocol", "PAC": "PAC Protocol",
"PACOCA": "Pacoca", "PACOCA": "Pacoca",
"PAD": "NearPad", "PAD": "NearPad",
@ -5736,6 +5803,7 @@
"PEARL": "Pearl Finance", "PEARL": "Pearl Finance",
"PEC": "PeaceCoin", "PEC": "PeaceCoin",
"PEEL": "Meta Apes", "PEEL": "Meta Apes",
"PEEPA": "Peepa",
"PEEPS": "The Peoples Coin", "PEEPS": "The Peoples Coin",
"PEG": "PegNet", "PEG": "PegNet",
"PEGS": "PegShares", "PEGS": "PegShares",
@ -5748,6 +5816,7 @@
"PEOPLE": "ConstitutionDAO", "PEOPLE": "ConstitutionDAO",
"PEOS": "pEOS", "PEOS": "pEOS",
"PEPE": "Pepe", "PEPE": "Pepe",
"PEPE20": "Pepe 2.0",
"PEPECASH": "Pepe Cash", "PEPECASH": "Pepe Cash",
"PEPPER": "Pepper Token", "PEPPER": "Pepper Token",
"PEPS": "PEPS Coin", "PEPS": "PEPS Coin",
@ -5822,6 +5891,7 @@
"PINK": "PinkCoin", "PINK": "PinkCoin",
"PINKX": "PantherCoin", "PINKX": "PantherCoin",
"PINMO": "Pinmo", "PINMO": "Pinmo",
"PINO": "Pinocchu",
"PINU": "Piccolo Inu", "PINU": "Piccolo Inu",
"PIO": "Pioneershares", "PIO": "Pioneershares",
"PIPI": "Pippi Finance", "PIPI": "Pippi Finance",
@ -5885,6 +5955,7 @@
"PLS": "Pulsechain", "PLS": "Pulsechain",
"PLSD": "PulseDogecoin", "PLSD": "PulseDogecoin",
"PLSPAD": "PulsePad", "PLSPAD": "PulsePad",
"PLSX": "PulseX",
"PLT": "Poollotto.finance", "PLT": "Poollotto.finance",
"PLTC": "PlatonCoin", "PLTC": "PlatonCoin",
"PLTX": "PlutusX", "PLTX": "PlutusX",
@ -5911,7 +5982,6 @@
"PNK": "Kleros", "PNK": "Kleros",
"PNL": "True PNL", "PNL": "True PNL",
"PNODE": "Pinknode", "PNODE": "Pinknode",
"PNP": "LogisticsX",
"PNT": "pNetwork Token", "PNT": "pNetwork Token",
"PNX": "PhantomX", "PNX": "PhantomX",
"PNY": "Peony Coin", "PNY": "Peony Coin",
@ -5927,6 +5997,7 @@
"POINTS": "Cryptsy Points", "POINTS": "Cryptsy Points",
"POK": "Pokmonsters", "POK": "Pokmonsters",
"POKEM": "Pokemonio", "POKEM": "Pokemonio",
"POKEMON": "Pokemon",
"POKER": "PokerCoin", "POKER": "PokerCoin",
"POKT": "Pocket Network", "POKT": "Pocket Network",
"POL": "Pool-X", "POL": "Pool-X",
@ -6010,6 +6081,7 @@
"PRIME": "Echelon Prime", "PRIME": "Echelon Prime",
"PRIMECHAIN": "PrimeChain", "PRIMECHAIN": "PrimeChain",
"PRINT": "Printer.Finance", "PRINT": "Printer.Finance",
"PRINTERIUM": "Printerium",
"PRINTS": "FingerprintsDAO", "PRINTS": "FingerprintsDAO",
"PRISM": "Prism", "PRISM": "Prism",
"PRIX": "Privatix", "PRIX": "Privatix",
@ -6033,7 +6105,7 @@
"PROTON": "Proton", "PROTON": "Proton",
"PROUD": "PROUD Money", "PROUD": "PROUD Money",
"PROXI": "PROXI", "PROXI": "PROXI",
"PRP": "Papyrus", "PRP": "Pepe Prime",
"PRPS": "Purpose", "PRPS": "Purpose",
"PRPT": "Purple Token", "PRPT": "Purple Token",
"PRQ": "PARSIQ", "PRQ": "PARSIQ",
@ -6042,7 +6114,7 @@
"PRTG": "Pre-Retogeum", "PRTG": "Pre-Retogeum",
"PRV": "PrivacySwap", "PRV": "PrivacySwap",
"PRVS": "Previse", "PRVS": "Previse",
"PRX": "Printerium", "PRX": "Parex",
"PRXY": "Proxy", "PRXY": "Proxy",
"PRY": "PRIMARY", "PRY": "PRIMARY",
"PSB": "Planet Sandbox", "PSB": "Planet Sandbox",
@ -6120,6 +6192,7 @@
"PYRAM": "Pyram Token", "PYRAM": "Pyram Token",
"PYRK": "Pyrk", "PYRK": "Pyrk",
"PYT": "Payther", "PYT": "Payther",
"PYUSD": "PayPal USD",
"PZM": "Prizm", "PZM": "Prizm",
"Q1S": "Quantum1Net", "Q1S": "Quantum1Net",
"Q2C": "QubitCoin", "Q2C": "QubitCoin",
@ -6178,6 +6251,7 @@
"QUA": "Quantum Tech", "QUA": "Quantum Tech",
"QUACK": "Rich Quack", "QUACK": "Rich Quack",
"QUAM": "Quam Network", "QUAM": "Quam Network",
"QUANT": "Quant Finance",
"QUARASHI": "Quarashi Network", "QUARASHI": "Quarashi Network",
"QUARTZ": "Sandclock", "QUARTZ": "Sandclock",
"QUASA": "Quasacoin", "QUASA": "Quasacoin",
@ -6201,7 +6275,7 @@
"RAC": "RAcoin", "RAC": "RAcoin",
"RACA": "Radio Caca", "RACA": "Radio Caca",
"RACEFI": "RaceFi", "RACEFI": "RaceFi",
"RAD": "Radicle", "RAD": "Radworks",
"RADAR": "DappRadar", "RADAR": "DappRadar",
"RADI": "RadicalCoin", "RADI": "RadicalCoin",
"RADIO": "RadioShack", "RADIO": "RadioShack",
@ -6220,7 +6294,7 @@
"RAM": "Ramifi Protocol", "RAM": "Ramifi Protocol",
"RAMP": "RAMP", "RAMP": "RAMP",
"RANKER": "RankerDao", "RANKER": "RankerDao",
"RAP": "Rapture", "RAP": "Philosoraptor",
"RAPDOGE": "RapDoge", "RAPDOGE": "RapDoge",
"RARE": "SuperRare", "RARE": "SuperRare",
"RARI": "Rarible", "RARI": "Rarible",
@ -6277,6 +6351,7 @@
"REA": "Realisto", "REA": "Realisto",
"REAL": "RealLink", "REAL": "RealLink",
"REALM": "Realm", "REALM": "Realm",
"REALMS": "Realms of Ethernity",
"REALPLATFORM": "REAL", "REALPLATFORM": "REAL",
"REALY": "Realy Metaverse", "REALY": "Realy Metaverse",
"REAP": "ReapChain", "REAP": "ReapChain",
@ -6287,6 +6362,7 @@
"RED": "RED TOKEN", "RED": "RED TOKEN",
"REDC": "RedCab", "REDC": "RedCab",
"REDCO": "Redcoin", "REDCO": "Redcoin",
"REDDIT": "Reddit",
"REDI": "REDi", "REDI": "REDi",
"REDLANG": "RED", "REDLANG": "RED",
"REDLC": "Redlight Chain", "REDLC": "Redlight Chain",
@ -6324,7 +6400,7 @@
"REST": "Restore", "REST": "Restore",
"RET": "RealTract", "RET": "RealTract",
"RETAIL": "Retail.Global", "RETAIL": "Retail.Global",
"RETH": "Realms of Ethernity", "RETH": "Rocket Pool ETH",
"RETH2": "rETH2", "RETH2": "rETH2",
"RETIRE": "Retire Token", "RETIRE": "Retire Token",
"REU": "REUCOIN", "REU": "REUCOIN",
@ -6351,6 +6427,7 @@
"RGP": "Rigel Protocol", "RGP": "Rigel Protocol",
"RGT": "Rari Governance Token", "RGT": "Rari Governance Token",
"RHEA": "Rhea", "RHEA": "Rhea",
"RHINO": "RHINO",
"RHOC": "RChain", "RHOC": "RChain",
"RHP": "Rhypton Club", "RHP": "Rhypton Club",
"RIC": "Riecoin", "RIC": "Riecoin",
@ -6490,6 +6567,7 @@
"RWE": "Real-World Evidence", "RWE": "Real-World Evidence",
"RWN": "Rowan Token", "RWN": "Rowan Token",
"RWS": "Robonomics Web Services", "RWS": "Robonomics Web Services",
"RXD": "Radiant",
"RXT": "RIMAUNANGIS", "RXT": "RIMAUNANGIS",
"RYC": "RoyalCoin", "RYC": "RoyalCoin",
"RYCN": "RoyalCoin 2.0", "RYCN": "RoyalCoin 2.0",
@ -6564,6 +6642,7 @@
"SBTC": "Super Bitcoin", "SBTC": "Super Bitcoin",
"SC": "Siacoin", "SC": "Siacoin",
"SCA": "SiaClassic", "SCA": "SiaClassic",
"SCAM": "Scam Coin",
"SCAP": "SafeCapital", "SCAP": "SafeCapital",
"SCAR": "Velhalla", "SCAR": "Velhalla",
"SCASH": "SpaceCash", "SCASH": "SpaceCash",
@ -6624,6 +6703,7 @@
"SEER": "SEER", "SEER": "SEER",
"SEI": "Sei", "SEI": "Sei",
"SEL": "SelenCoin", "SEL": "SelenCoin",
"SELF": "SELFCrypto",
"SEM": "Semux", "SEM": "Semux",
"SEN": "Sentaro", "SEN": "Sentaro",
"SENATE": "SENATE", "SENATE": "SENATE",
@ -6665,6 +6745,7 @@
"SGE": "Society of Galactic Exploration", "SGE": "Society of Galactic Exploration",
"SGLY": "Singularity", "SGLY": "Singularity",
"SGN": "Signals Network", "SGN": "Signals Network",
"SGO": "SafuuGO",
"SGOLD": "SpaceGold", "SGOLD": "SpaceGold",
"SGP": "SGPay", "SGP": "SGPay",
"SGR": "Sogur Currency", "SGR": "Sogur Currency",
@ -6684,6 +6765,7 @@
"SHEESH": "Sheesh it is bussin bussin", "SHEESH": "Sheesh it is bussin bussin",
"SHEESHA": "Sheesha Finance", "SHEESHA": "Sheesha Finance",
"SHELL": "Shell Token", "SHELL": "Shell Token",
"SHERA": "Shera Tokens",
"SHFL": "SHUFFLE!", "SHFL": "SHUFFLE!",
"SHFT": "Shyft Network", "SHFT": "Shyft Network",
"SHI": "Shirtum", "SHI": "Shirtum",
@ -6719,6 +6801,8 @@
"SHR": "ShareToken", "SHR": "ShareToken",
"SHREK": "ShrekCoin", "SHREK": "ShrekCoin",
"SHROOM": "Shroom.Finance", "SHROOM": "Shroom.Finance",
"SHROOMFOX": "Magic Shroom",
"SHS": "SHEESH",
"SHX": "Stronghold Token", "SHX": "Stronghold Token",
"SI": "Siren", "SI": "Siren",
"SIB": "SibCoin", "SIB": "SibCoin",
@ -7018,9 +7102,11 @@
"STEN": "Steneum Coin", "STEN": "Steneum Coin",
"STEP": "Step Finance", "STEP": "Step Finance",
"STEPH": "Step Hero", "STEPH": "Step Hero",
"STEPR": "Step",
"STEPS": "Steps", "STEPS": "Steps",
"STERLINGCOIN": "SterlingCoin", "STERLINGCOIN": "SterlingCoin",
"STETH": "Staked Ether", "STETH": "Staked Ether",
"STEWIE": "Stewie Coin",
"STEX": "STEX", "STEX": "STEX",
"STF": "Structure Finance", "STF": "Structure Finance",
"STFX": "STFX", "STFX": "STFX",
@ -7055,7 +7141,7 @@
"STR": "Sourceless", "STR": "Sourceless",
"STRAKS": "Straks", "STRAKS": "Straks",
"STRAX": "Stratis", "STRAX": "Stratis",
"STRAY": "Animal Token", "STRAY": "Stray Dog",
"STREAM": "STREAMIT COIN", "STREAM": "STREAMIT COIN",
"STRIP": "Stripto", "STRIP": "Stripto",
"STRK": "Strike", "STRK": "Strike",
@ -7361,6 +7447,7 @@
"TOM": "TOM Finance", "TOM": "TOM Finance",
"TOMAHAWKCOIN": "Tomahawkcoin", "TOMAHAWKCOIN": "Tomahawkcoin",
"TOMB": "Tomb", "TOMB": "Tomb",
"TOMI": "tomiNet",
"TOMO": "TomoChain", "TOMO": "TomoChain",
"TOMOE": "TomoChain ERC20", "TOMOE": "TomoChain ERC20",
"TOMS": "TomTomCoin", "TOMS": "TomTomCoin",
@ -7385,6 +7472,7 @@
"TOTM": "Totem", "TOTM": "Totem",
"TOWER": "Tower", "TOWER": "Tower",
"TOWN": "Town Star", "TOWN": "Town Star",
"TOX": "INTOverse",
"TOZ": "Tozex", "TOZ": "Tozex",
"TP": "Token Swap", "TP": "Token Swap",
"TPAD": "TrustPad", "TPAD": "TrustPad",
@ -7600,6 +7688,7 @@
"UNITY": "SuperNET", "UNITY": "SuperNET",
"UNIVRS": "Universe", "UNIVRS": "Universe",
"UNIX": "UniX", "UNIX": "UniX",
"UNLEASH": "UnleashClub",
"UNN": "UNION Protocol Governance Token", "UNN": "UNION Protocol Governance Token",
"UNO": "Unobtanium", "UNO": "Unobtanium",
"UNORE": "UnoRe", "UNORE": "UnoRe",
@ -7673,6 +7762,7 @@
"UTT": "United Traders Token", "UTT": "United Traders Token",
"UTU": "UTU Protocol", "UTU": "UTU Protocol",
"UUU": "U Network", "UUU": "U Network",
"UWU": "uwu",
"UZUMAKI": "Uzumaki Inu", "UZUMAKI": "Uzumaki Inu",
"VAB": "Vabble", "VAB": "Vabble",
"VADER": "Vader Protocol", "VADER": "Vader Protocol",
@ -7695,6 +7785,7 @@
"VCF": "Valencia CF Fan Token", "VCF": "Valencia CF Fan Token",
"VCG": "VCGamers", "VCG": "VCGamers",
"VCK": "28VCK", "VCK": "28VCK",
"VCORE": "VCORE",
"VDG": "VeriDocGlobal", "VDG": "VeriDocGlobal",
"VDL": "Vidulum", "VDL": "Vidulum",
"VDO": "VidioCoin", "VDO": "VidioCoin",
@ -7710,6 +7801,7 @@
"VEIL": "VEIL", "VEIL": "VEIL",
"VELA": "Vela Token", "VELA": "Vela Token",
"VELO": "Velo", "VELO": "Velo",
"VELOD": "Velodrome Finance",
"VELOX": "Velox", "VELOX": "Velox",
"VELOXPROJECT": "Velox", "VELOXPROJECT": "Velox",
"VEMP": "vEmpire DDAO", "VEMP": "vEmpire DDAO",
@ -7782,6 +7874,7 @@
"VNT": "VNT Chain", "VNT": "VNT Chain",
"VNTW": "Value Network Token", "VNTW": "Value Network Token",
"VNX": "VisionX", "VNX": "VisionX",
"VNXAU": "VNX Gold",
"VNXLU": "VNX Exchange", "VNXLU": "VNX Exchange",
"VOCO": "Provoco", "VOCO": "Provoco",
"VODKA": "Vodka Token", "VODKA": "Vodka Token",
@ -7902,7 +7995,8 @@
"WEC": "Whole Earth Coin", "WEC": "Whole Earth Coin",
"WEGEN": "WeGen Platform", "WEGEN": "WeGen Platform",
"WELD": "Weld", "WELD": "Weld",
"WELL": "Well", "WELL": "Moonwell",
"WELLTOKEN": "Well",
"WELT": "Fabwelt", "WELT": "Fabwelt",
"WELUPS": "Welups Blockchain", "WELUPS": "Welups Blockchain",
"WEMIX": "WEMIX", "WEMIX": "WEMIX",
@ -7958,6 +8052,7 @@
"WIX": "Wixlar", "WIX": "Wixlar",
"WIZ": "WIZ Protocol", "WIZ": "WIZ Protocol",
"WKD": "Wakanda Inu", "WKD": "Wakanda Inu",
"WLD": "Worldcoin",
"WLF": "Wolfs Group", "WLF": "Wolfs Group",
"WLITI": "wLITI", "WLITI": "wLITI",
"WLK": "Wolk", "WLK": "Wolk",
@ -7983,6 +8078,7 @@
"WNZ": "Winerz", "WNZ": "Winerz",
"WOA": "Wrapped Origin Axie", "WOA": "Wrapped Origin Axie",
"WOD": "World of Defish", "WOD": "World of Defish",
"WOID": "WORLD ID",
"WOJ": "Wojak Finance", "WOJ": "Wojak Finance",
"WOLF": "Insanity Coin", "WOLF": "Insanity Coin",
"WOLFILAND": "Wolfiland", "WOLFILAND": "Wolfiland",
@ -8000,6 +8096,7 @@
"WOOFY": "Woofy", "WOOFY": "Woofy",
"WOOL": "Wolf Game Wool", "WOOL": "Wolf Game Wool",
"WOONK": "Woonkly", "WOONK": "Woonkly",
"WOOO": "wooonen",
"WOOP": "Woonkly Power", "WOOP": "Woonkly Power",
"WOP": "WorldPay", "WOP": "WorldPay",
"WORLD": "World Token", "WORLD": "World Token",
@ -8010,6 +8107,7 @@
"WOZX": "Efforce", "WOZX": "Efforce",
"WPC": "WePiggy Coin", "WPC": "WePiggy Coin",
"WPE": "OPES (Wrapped PE)", "WPE": "OPES (Wrapped PE)",
"WPLS": "Wrapped Pulse",
"WPP": "Green Energy Token", "WPP": "Green Energy Token",
"WPR": "WePower", "WPR": "WePower",
"WQT": "Work Quest", "WQT": "Work Quest",
@ -8049,6 +8147,7 @@
"WZEC": "Wrapped Zcash", "WZEC": "Wrapped Zcash",
"WZENIQ": "Wrapped Zeniq (ETH)", "WZENIQ": "Wrapped Zeniq (ETH)",
"WZRD": "Wizardia", "WZRD": "Wizardia",
"X": "AI-X",
"X2": "X2Coin", "X2": "X2Coin",
"X2Y2": "X2Y2", "X2Y2": "X2Y2",
"X42": "X42 Protocol", "X42": "X42 Protocol",
@ -8096,7 +8195,7 @@
"XCI": "Cannabis Industry Coin", "XCI": "Cannabis Industry Coin",
"XCLR": "ClearCoin", "XCLR": "ClearCoin",
"XCM": "CoinMetro", "XCM": "CoinMetro",
"XCN": "Chain", "XCN": "Onyxcoin",
"XCO": "XCoin", "XCO": "XCoin",
"XCONSOL": "X-Consoles", "XCONSOL": "X-Consoles",
"XCP": "CounterParty", "XCP": "CounterParty",
@ -8365,6 +8464,7 @@
"YUANG": "Yuang Coin", "YUANG": "Yuang Coin",
"YUCJ": "Yu Coin", "YUCJ": "Yu Coin",
"YUCT": "Yucreat", "YUCT": "Yucreat",
"YUDI": "Yudi",
"YUM": "Yumerium", "YUM": "Yumerium",
"YUMMY": "Yummy", "YUMMY": "Yummy",
"YUP": "Crowdholding", "YUP": "Crowdholding",

View File

@ -1,4 +1,5 @@
{ {
"CYBER24781": "CyberConnect",
"LUNA1": "Terra", "LUNA1": "Terra",
"LUNA2": "Terra", "LUNA2": "Terra",
"SGB1": "Songbird", "SGB1": "Songbird",

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset <urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url> <url>
<loc>https://ghostfol.io/de</loc> <loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -50,6 +50,118 @@
<loc>https://ghostfol.io/de/ressourcen</loc> <loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ueber-uns</loc> <loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -66,6 +178,10 @@
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc> <loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en</loc> <loc>https://ghostfol.io/en</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -82,6 +198,10 @@
<loc>https://ghostfol.io/en/about/license</loc> <loc>https://ghostfol.io/en/about/license</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/about/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/blog</loc> <loc>https://ghostfol.io/en/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -134,6 +254,18 @@
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc> <loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/blog/2023/08/ghostfolio-joins-oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/faq</loc> <loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -172,6 +304,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -256,6 +392,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -314,6 +454,10 @@
<loc>https://ghostfol.io/es/sobre/licencia</loc> <loc>https://ghostfol.io/es/sobre/licencia</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/es/sobre/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc> <loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -334,6 +478,10 @@
<loc>https://ghostfol.io/fr/a-propos/licence</loc> <loc>https://ghostfol.io/fr/a-propos/licence</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/fr/a-propos/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc> <loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -388,12 +536,16 @@
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc> <loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc> <loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc> <loc>https://ghostfol.io/it/informazioni-su/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -418,6 +570,118 @@
<loc>https://ghostfol.io/it/risorse</loc> <loc>https://ghostfol.io/it/risorse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl</loc> <loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -427,7 +691,119 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/nl/kenmerken</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -452,6 +828,10 @@
<loc>https://ghostfol.io/nl/over/licentie</loc> <loc>https://ghostfol.io/nl/over/licentie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/over/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/over/privacybeleid</loc> <loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -465,7 +845,11 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc> <loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -512,8 +896,16 @@
<loc>https://ghostfol.io/pt/sobre/licenca</loc> <loc>https://ghostfol.io/pt/sobre/licenca</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/pt/sobre/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc> <loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/tr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
</urlset> </urlset>

View File

@ -7,6 +7,7 @@ import helmet from 'helmet';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware';
async function bootstrap() { async function bootstrap() {
const configApp = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
@ -23,7 +24,7 @@ async function bootstrap() {
defaultVersion: '1', defaultVersion: '1',
type: VersioningType.URI type: VersioningType.URI
}); });
app.setGlobalPrefix('api'); app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] });
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
@ -40,6 +41,7 @@ async function bootstrap() {
helmet({ helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
@ -51,7 +53,8 @@ async function bootstrap() {
); );
} }
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY'); app.use(HtmlTemplateMiddleware);
const HOST = configService.get<string>('HOST') || '0.0.0.0'; const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333; const PORT = configService.get<number>('PORT') || 3333;
@ -59,15 +62,6 @@ async function bootstrap() {
logLogo(); logLogo();
Logger.log(`Listening at http://${HOST}:${PORT}`); Logger.log(`Listening at http://${HOST}:${PORT}`);
Logger.log(''); Logger.log('');
if (BASE_CURRENCY) {
Logger.warn(
`The environment variable "BASE_CURRENCY" is deprecated and will be removed in Ghostfolio 2.0.`
);
Logger.warn(
'Please use the currency converter in the activity dialog instead.'
);
}
}); });
} }

View File

@ -0,0 +1,141 @@
import * as fs from 'fs';
import { join } from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
const descriptions = {
de: 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
en: 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.',
es: 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.',
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
tr: 'Ghostfolio, hisse senetleri, ETFler veya kripto para birimleri gibi varlıklarınızı birden fazla platformda takip etmenizi sağlayan bir kişisel finans panosudur.'
};
const title = 'Ghostfolio Open Source Wealth Management Software';
const titleShort = 'Ghostfolio';
let indexHtmlMap: { [languageCode: string]: string } = {};
try {
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({
...map,
[languageCode]: fs.readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8'
)
}),
{}
);
} catch {}
const locales = {
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}`
},
'/en/blog/2022/08/500-stars-on-github': {
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
title: `500 Stars - ${titleShort}`
},
'/en/blog/2022/10/hacktoberfest-2022': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
title: `Hacktoberfest 2022 - ${titleShort}`
},
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
featureGraphicPath: 'assets/images/blog/20221226.jpg',
title: `The importance of tracking your personal finances - ${titleShort}`
},
'/en/blog/2023/02/ghostfolio-meets-umbrel': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
title: `Ghostfolio meets Umbrel - ${titleShort}`
},
'/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 - ${titleShort}`
},
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
featureGraphicPath: 'assets/images/blog/20230520.jpg',
title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}`
},
'/en/blog/2023/07/exploring-the-path-to-fire': {
featureGraphicPath: 'assets/images/blog/20230701.jpg',
title: `Exploring the Path to FIRE - ${titleShort}`
},
'/en/blog/2023/08/ghostfolio-joins-oss-friends': {
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
title: `Ghostfolio joins OSS Friends - ${titleShort}`
},
'/en/blog/2023/09/ghostfolio-2': {
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
},
'/en/blog/2023/09/hacktoberfest-2023': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${titleShort}`
}
};
const isFileRequest = (filename: string) => {
if (filename === '/assets/LICENSE') {
return true;
} else if (
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}
return filename.split('.').pop() !== filename;
};
export const HtmlTemplateMiddleware = async (
request: Request,
response: Response,
next: NextFunction
) => {
const path = request.originalUrl.replace(/\/$/, '');
let languageCode = path.substr(1, 2);
if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) {
languageCode = DEFAULT_LANGUAGE_CODE;
}
const currentDate = format(new Date(), DATE_FORMAT);
const rootUrl = process.env.ROOT_URL || DEFAULT_ROOT_URL;
if (
path.startsWith('/api/') ||
isFileRequest(path) ||
!environment.production
) {
// Skip
next();
} else {
const indexHtml = interpolate(indexHtmlMap[languageCode], {
currentDate,
languageCode,
path,
rootUrl,
description: descriptions[languageCode],
featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title
});
return response.send(indexHtml);
}
};

View File

@ -1,4 +1,5 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
@ -11,10 +12,6 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, { this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
default: 'USD'
}),
BETTER_UPTIME_API_KEY: str({ default: '' }), BETTER_UPTIME_API_KEY: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }), CACHE_QUOTES_TTL: num({ default: 1 }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
@ -46,7 +43,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }), ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),

View File

@ -5,6 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
@ -28,6 +29,7 @@ import { DataGatheringProcessor } from './data-gathering.processor';
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [DataGatheringProcessor, DataGatheringService], providers: [DataGatheringProcessor, DataGatheringService],

View File

@ -13,6 +13,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { Job } from 'bull'; import { Job } from 'bull';
import { import {
addDays,
format, format,
getDate, getDate,
getMonth, getMonth,
@ -101,15 +102,7 @@ export class DataGatheringProcessor {
}); });
} }
// Count month one up for iteration currentDate = addDays(currentDate, 1);
currentDate = new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate) + 1,
0
)
);
} }
await this.marketDataService.updateMany({ data }); await this.marketDataService.updateMany({ data });

View File

@ -4,18 +4,20 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -34,6 +36,7 @@ export class DataGatheringService {
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -124,12 +127,14 @@ export class DataGatheringService {
uniqueAssets = await this.getUniqueAssets(); uniqueAssets = await this.getUniqueAssets();
} }
const assetProfiles = await this.dataProviderService.getAssetProfiles( if (uniqueAssets.length <= 0) {
uniqueAssets return;
); }
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
uniqueAssets const assetProfiles =
); await this.dataProviderService.getAssetProfiles(uniqueAssets);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -144,7 +149,9 @@ export class DataGatheringService {
}); });
} catch (error) { } catch (error) {
Logger.error( Logger.error(
`Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`, `Failed to enhance data for ${symbol} (${
assetProfile.dataSource
}) by ${dataEnhancer.getName()}`,
error, error,
'DataGatheringService' 'DataGatheringService'
); );
@ -255,6 +262,10 @@ export class DataGatheringService {
}); });
} }
private getEarliestDate(aStartDate: Date) {
return min([aStartDate, subYears(new Date(), 10)]);
}
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
@ -321,6 +332,14 @@ export class DataGatheringService {
} }
private async getSymbolsMax(): Promise<IDataGatheringItem[]> { private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const benchmarkAssetProfileIdMap: { [key: string]: boolean } = {};
(
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? []
).forEach(({ symbolProfileId }) => {
benchmarkAssetProfileIdMap[symbolProfileId] = true;
});
const startDate = const startDate =
( (
await this.prismaService.order.findFirst({ await this.prismaService.order.findFirst({
@ -334,7 +353,7 @@ export class DataGatheringService {
return { return {
dataSource, dataSource,
symbol, symbol,
date: min([startDate, subYears(new Date(), 10)]) date: this.getEarliestDate(startDate)
}; };
}); });
@ -343,6 +362,7 @@ export class DataGatheringService {
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { select: {
dataSource: true, dataSource: true,
id: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { date: true }, select: { date: true },
@ -364,9 +384,15 @@ export class DataGatheringService {
); );
}) })
.map((symbolProfile) => { .map((symbolProfile) => {
let date = symbolProfile.Order?.[0]?.date ?? startDate;
if (benchmarkAssetProfileIdMap[symbolProfile.id]) {
date = this.getEarliestDate(startDate);
}
return { return {
...symbolProfile, ...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate date
}; };
}); });

View File

@ -9,6 +9,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as Alphavantage from 'alphavantage';
import { format, isAfter, isBefore, parse } from 'date-fns'; import { format, isAfter, isBefore, parse } from 'date-fns';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces'; import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@ -20,7 +21,7 @@ export class AlphaVantageService implements DataProviderInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
this.alphaVantage = require('alphavantage')({ this.alphaVantage = Alphavantage({
key: this.configurationService.get('ALPHA_VANTAGE_API_KEY') key: this.configurationService.get('ALPHA_VANTAGE_API_KEY')
}); });
} }
@ -126,6 +127,9 @@ export class AlphaVantageService implements DataProviderInterface {
return { return {
items: result?.bestMatches?.map((bestMatch) => { items: result?.bestMatches?.map((bestMatch) => {
return { return {
assetClass: undefined,
assetSubClass: undefined,
currency: bestMatch['8. currency'],
dataSource: this.getName(), dataSource: this.getName(),
name: bestMatch['2. name'], name: bestMatch['2. name'],
symbol: bestMatch['1. symbol'] symbol: bestMatch['1. symbol']

View File

@ -1,10 +1,13 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
@ -15,19 +18,14 @@ import {
DataSource, DataSource,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import bent from 'bent';
import { format, fromUnixTime, getUnixTime } from 'date-fns'; import { format, fromUnixTime, getUnixTime } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface { export class CoinGeckoService implements DataProviderInterface {
private baseCurrency: string;
private readonly URL = 'https://api.coingecko.com/api/v3'; private readonly URL = 'https://api.coingecko.com/api/v3';
public constructor( public constructor() {}
private readonly configurationService: ConfigurationService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -39,14 +37,22 @@ export class CoinGeckoService implements DataProviderInterface {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataSource: this.getName(), dataSource: this.getName(),
symbol: aSymbol symbol: aSymbol
}; };
try { try {
const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200); const abortController = new AbortController();
const { name } = await get();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { name } = await got(`${this.URL}/coins/${aSymbol}`, {
// @ts-ignore
signal: abortController.signal
}).json<any>();
response.name = name; response.name = name;
} catch (error) { } catch (error) {
@ -79,17 +85,23 @@ export class CoinGeckoService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { prices } = await got(
`${ `${
this.URL this.URL
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime( }/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime(
from from
)}&to=${getUnixTime(to)}`, )}&to=${getUnixTime(to)}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const { prices } = await get(); ).json<any>();
const result: { const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
@ -132,23 +144,29 @@ export class CoinGeckoService implements DataProviderInterface {
} }
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/simple/price?ids=${aSymbols.join( `${this.URL}/simple/price?ids=${aSymbols.join(
',' ','
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`, )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const response = await get(); ).json<any>();
for (const symbol in response) { for (const symbol in response) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) { if (Object.prototype.hasOwnProperty.call(response, symbol)) {
results[symbol] = { results[symbol] = {
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO, dataSource: DataSource.COINGECKO,
marketPrice: response[symbol][this.baseCurrency.toLowerCase()], marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()],
marketState: 'open' marketState: 'open'
}; };
} }
@ -174,8 +192,16 @@ export class CoinGeckoService implements DataProviderInterface {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const get = bent(`${this.URL}/search?query=${query}`, 'GET', 'json', 200); const abortController = new AbortController();
const { coins } = await get();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { coins } = await got(`${this.URL}/search?query=${query}`, {
// @ts-ignore
signal: abortController.signal
}).json<any>();
items = coins.map(({ id: symbol, name }) => { items = coins.map(({ id: symbol, name }) => {
return { return {
@ -183,7 +209,7 @@ export class CoinGeckoService implements DataProviderInterface {
symbol, symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataSource: this.getName() dataSource: this.getName()
}; };
}); });

View File

@ -4,14 +4,18 @@ import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-p
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DataEnhancerService } from './data-enhancer.service';
@Module({ @Module({
exports: [ exports: [
'DataEnhancers', DataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService YahooFinanceDataEnhancerService,
'DataEnhancers'
], ],
imports: [ConfigurationModule, CryptocurrencyModule], imports: [ConfigurationModule, CryptocurrencyModule],
providers: [ providers: [
DataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService, YahooFinanceDataEnhancerService,
{ {

View File

@ -0,0 +1,44 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { HttpException, Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class DataEnhancerService {
public constructor(
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[]
) {}
public async enhance(aName: string) {
const dataEnhancer = this.dataEnhancers.find((dataEnhancer) => {
return dataEnhancer.getName() === aName;
});
if (!dataEnhancer) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
try {
const assetProfile = await dataEnhancer.enhance({
response: {
assetClass: 'EQUITY',
assetSubClass: 'ETF'
},
symbol: dataEnhancer.getTestSymbol()
});
if (
(assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 &&
(assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0
) {
return true;
}
} catch {}
return false;
}
}

View File

@ -1,15 +1,14 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import bent from 'bent'; import got from 'got';
const getJSON = bent('json');
@Injectable() @Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com'; private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countries = require('countries-list/dist/countries.json'); private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia'
@ -34,27 +33,83 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
const profile = await getJSON( let abortController = new AbortController();
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json`
).catch(() => {
return {};
});
const isin = profile.isin?.split(';')?.[0]; setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const profile = await got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,
{
// @ts-ignore
signal: abortController.signal
}
)
.json<any>()
.catch(() => {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
return got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
'.'
)?.[0]}.json`,
{
// @ts-ignore
signal: abortController.signal
}
)
.json<any>()
.catch(() => {
return {};
});
});
const isin = profile?.isin?.split(';')?.[0];
if (isin) { if (isin) {
response.isin = isin; response.isin = isin;
} }
const holdings = await getJSON( abortController = new AbortController();
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
).catch(() => { setTimeout(() => {
return getJSON( abortController.abort();
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${ }, DEFAULT_REQUEST_TIMEOUT);
symbol.split('.')?.[0]
}.json` const holdings = await got(
); `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
}); {
// @ts-ignore
signal: abortController.signal
}
)
.json<any>()
.catch(() => {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
return got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
'.'
)?.[0]}.json`,
{
// @ts-ignore
signal: abortController.signal
}
)
.json<any>()
.catch(() => {
return {};
});
});
if (holdings?.weight < 0.95) { if (holdings?.weight < 0.95) {
// Skip if data is inaccurate // Skip if data is inaccurate
@ -112,4 +167,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
public getName() { public getName() {
return 'TRACKINSIGHT'; return 'TRACKINSIGHT';
} }
public getTestSymbol() {
return 'QQQ';
}
} }

View File

@ -1,4 +1,3 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
@ -26,16 +25,13 @@ jest.mock(
); );
describe('YahooFinanceDataEnhancerService', () => { describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService; let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => { beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService(); cryptocurrencyService = new CryptocurrencyService();
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService cryptocurrencyService
); );
}); });

View File

@ -1,13 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper'; import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource, DataSource,
Prisma,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
@ -16,23 +16,18 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
let symbol = aYahooFinanceSymbol.replace( let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`), new RegExp(`-${DEFAULT_CURRENCY}$`),
this.baseCurrency DEFAULT_CURRENCY
); );
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) { if (symbol.includes('=X') && !symbol.includes(DEFAULT_CURRENCY)) {
symbol = `${this.baseCurrency}${symbol}`; symbol = `${DEFAULT_CURRENCY}${symbol}`;
} }
return symbol.replace('=X', ''); return symbol.replace('=X', '');
@ -47,21 +42,18 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
*/ */
public convertToYahooFinanceSymbol(aSymbol: string) { public convertToYahooFinanceSymbol(aSymbol: string) {
if ( if (
aSymbol.includes(this.baseCurrency) && aSymbol.includes(DEFAULT_CURRENCY) &&
aSymbol.length > this.baseCurrency.length aSymbol.length > DEFAULT_CURRENCY.length
) { ) {
if ( if (
isCurrency( isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length) aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
) )
) { ) {
return `${aSymbol}=X`; return `${aSymbol}=X`;
} else if ( } else if (
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace( aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY)
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
) )
) { ) {
// Add a dash before the last three characters // Add a dash before the last three characters
@ -69,8 +61,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
// DOGEUSD -> DOGE-USD // DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD // SOL1USD -> SOL1-USD
return aSymbol.replace( return aSymbol.replace(
new RegExp(`-?${this.baseCurrency}$`), new RegExp(`-?${DEFAULT_CURRENCY}$`),
`-${this.baseCurrency}` `-${DEFAULT_CURRENCY}`
); );
} }
} }
@ -99,15 +91,14 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
yahooSymbol = quotes[0].symbol; yahooSymbol = quotes[0].symbol;
} }
const { countries, sectors, url } = await this.getAssetProfile( const { countries, sectors, url } =
yahooSymbol await this.getAssetProfile(yahooSymbol);
);
if (countries) { if ((countries as unknown as Prisma.JsonArray)?.length > 0) {
response.countries = countries; response.countries = countries;
} }
if (sectors) { if ((sectors as unknown as Prisma.JsonArray)?.length > 0) {
response.sectors = sectors; response.sectors = sectors;
} }
@ -234,6 +225,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public getTestSymbol() {
return 'AAPL';
}
public parseAssetClass({ public parseAssetClass({
quoteType, quoteType,
shortName shortName

View File

@ -5,6 +5,10 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -14,21 +18,19 @@ import {
DataSource, DataSource,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { format, isToday } from 'date-fns'; import { format, isToday } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class EodHistoricalDataService implements DataProviderInterface { export class EodHistoricalDataService implements DataProviderInterface {
private apiKey: string; private apiKey: string;
private baseCurrency: string;
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY'); this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
} }
public canHandle(symbol: string) { public canHandle(symbol: string) {
@ -76,19 +78,24 @@ export class EodHistoricalDataService implements DataProviderInterface {
const symbol = this.convertToEodSymbol(aSymbol); const symbol = this.convertToEodSymbol(aSymbol);
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/eod/${symbol}?api_token=${ `${this.URL}/eod/${symbol}?api_token=${
this.apiKey this.apiKey
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format( }&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
to, to,
DATE_FORMAT DATE_FORMAT
)}&period={aGranularity}`, )}&period={aGranularity}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
).json<any>();
const response = await get();
return response.reduce( return response.reduce(
(result, historicalItem, index, array) => { (result, historicalItem, index, array) => {
@ -136,16 +143,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const realTimeResponse = await got(
`${this.URL}/real-time/${symbols[0]}?api_token=${ `${this.URL}/real-time/${symbols[0]}?api_token=${
this.apiKey this.apiKey
}&fmt=json&s=${symbols.join(',')}`, }&fmt=json&s=${symbols.join(',')}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
).json<any>();
const realTimeResponse = await get();
const quotes = const quotes =
symbols.length === 1 ? [realTimeResponse] : realTimeResponse; symbols.length === 1 ? [realTimeResponse] : realTimeResponse;
@ -174,7 +186,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
})?.currency; })?.currency;
result[this.convertFromEodSymbol(code)] = { result[this.convertFromEodSymbol(code)] = {
currency: currency ?? this.baseCurrency, currency: currency ?? DEFAULT_CURRENCY,
dataSource: DataSource.EOD_HISTORICAL_DATA, dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: close, marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed' marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
@ -185,24 +197,24 @@ export class EodHistoricalDataService implements DataProviderInterface {
{} {}
); );
if (response[`${this.baseCurrency}GBP`]) { if (response[`${DEFAULT_CURRENCY}GBP`]) {
response[`${this.baseCurrency}GBp`] = { response[`${DEFAULT_CURRENCY}GBp`] = {
...response[`${this.baseCurrency}GBP`], ...response[`${DEFAULT_CURRENCY}GBP`],
currency: `${this.baseCurrency}GBp`, currency: `${DEFAULT_CURRENCY}GBp`,
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}GBp`, symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[`${this.baseCurrency}GBP`].marketPrice value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice
}) })
}; };
} }
if (response[`${this.baseCurrency}ILS`]) { if (response[`${DEFAULT_CURRENCY}ILS`]) {
response[`${this.baseCurrency}ILA`] = { response[`${DEFAULT_CURRENCY}ILA`] = {
...response[`${this.baseCurrency}ILS`], ...response[`${DEFAULT_CURRENCY}ILS`],
currency: `${this.baseCurrency}ILA`, currency: `${DEFAULT_CURRENCY}ILA`,
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ILA`, symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[`${this.baseCurrency}ILS`].marketPrice value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice
}) })
}; };
} }
@ -271,7 +283,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (symbol.endsWith('.FOREX')) { if (symbol.endsWith('.FOREX')) {
symbol = symbol.replace('GBX', 'GBp'); symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', ''); symbol = symbol.replace('.FOREX', '');
symbol = `${this.baseCurrency}${symbol}`; symbol = `${DEFAULT_CURRENCY}${symbol}`;
} }
return symbol; return symbol;
@ -284,17 +296,17 @@ export class EodHistoricalDataService implements DataProviderInterface {
*/ */
private convertToEodSymbol(aSymbol: string) { private convertToEodSymbol(aSymbol: string) {
if ( if (
aSymbol.startsWith(this.baseCurrency) && aSymbol.startsWith(DEFAULT_CURRENCY) &&
aSymbol.length > this.baseCurrency.length aSymbol.length > DEFAULT_CURRENCY.length
) { ) {
if ( if (
isCurrency( isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length) aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
) )
) { ) {
return `${aSymbol return `${aSymbol
.replace('GBp', 'GBX') .replace('GBp', 'GBX')
.replace(this.baseCurrency, '')}.FOREX`; .replace(DEFAULT_CURRENCY, '')}.FOREX`;
} }
} }
@ -308,10 +320,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
symbol: string; symbol: string;
value: number; value: number;
}) { }) {
if (symbol === `${this.baseCurrency}GBp`) { if (symbol === `${DEFAULT_CURRENCY}GBp`) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) { } else if (symbol === `${DEFAULT_CURRENCY}ILA`) {
// Convert ILS to ILA // Convert ILS to ILA
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} }
@ -329,13 +341,19 @@ export class EodHistoricalDataService implements DataProviderInterface {
let searchResult = []; let searchResult = [];
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`, `${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const response = await get(); ).json<any>();
searchResult = response.map( searchResult = response.map(
({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => { ({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => {

View File

@ -5,18 +5,21 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import { format, isAfter, isBefore, isSameDay } from 'date-fns'; import { format, isAfter, isBefore, isSameDay } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class FinancialModelingPrepService implements DataProviderInterface { export class FinancialModelingPrepService implements DataProviderInterface {
private apiKey: string; private apiKey: string;
private baseCurrency: string;
private readonly URL = 'https://financialmodelingprep.com/api/v3'; private readonly URL = 'https://financialmodelingprep.com/api/v3';
public constructor( public constructor(
@ -25,7 +28,6 @@ export class FinancialModelingPrepService implements DataProviderInterface {
this.apiKey = this.configurationService.get( this.apiKey = this.configurationService.get(
'FINANCIAL_MODELING_PREP_API_KEY' 'FINANCIAL_MODELING_PREP_API_KEY'
); );
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
} }
public canHandle(symbol: string) { public canHandle(symbol: string) {
@ -64,13 +66,19 @@ export class FinancialModelingPrepService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { historical } = await got(
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`, `${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const { historical } = await get(); ).json<any>();
const result: { const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
@ -115,17 +123,23 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got(
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`, `${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const response = await get(); ).json<any>();
for (const { price, symbol } of response) { for (const { price, symbol } of response) {
results[symbol] = { results[symbol] = {
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP, dataSource: DataSource.FINANCIAL_MODELING_PREP,
marketPrice: price, marketPrice: price,
@ -153,13 +167,19 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const get = bent( const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const result = await got(
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`, `${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
'GET', {
'json', // @ts-ignore
200 signal: abortController.signal
); }
const result = await get(); ).json<any>();
items = result.map(({ currency, name, symbol }) => { items = result.map(({ currency, name, symbol }) => {
return { return {

View File

@ -10,4 +10,6 @@ export interface DataEnhancerInterface {
}): Promise<Partial<SymbolProfile>>; }): Promise<Partial<SymbolProfile>>;
getName(): string; getName(): string;
getTestSymbol(): string;
} }

View File

@ -6,6 +6,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
extractNumberFromString, extractNumberFromString,
@ -14,10 +15,10 @@ import {
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { addDays, format, isBefore } from 'date-fns'; import { addDays, format, isBefore } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
@ -95,10 +96,19 @@ export class ManualService implements DataProviderInterface {
return {}; return {};
} }
const get = bent(url, 'GET', 'string', 200, headers); const abortController = new AbortController();
const html = await get(); setTimeout(() => {
const $ = cheerio.load(html); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { body } = await got(url, {
headers,
// @ts-ignore
signal: abortController.signal
});
const $ = cheerio.load(body);
const value = extractNumberFromString($(selector).text()); const value = extractNumberFromString($(selector).text());

View File

@ -5,13 +5,16 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import {
DEFAULT_REQUEST_TIMEOUT,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import { format } from 'date-fns'; import { format } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class RapidApiService implements DataProviderInterface { export class RapidApiService implements DataProviderInterface {
@ -135,19 +138,25 @@ export class RapidApiService implements DataProviderInterface {
oneYearAgo: { value: number; valueText: string }; oneYearAgo: { value: number; valueText: string };
}> { }> {
try { try {
const get = bent( const abortController = new AbortController();
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
'GET', setTimeout(() => {
'json', abortController.abort();
200, }, DEFAULT_REQUEST_TIMEOUT);
{
useQueryString: true, const { fgi } = await got(
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY') {
} headers: {
); useQueryString: 'true',
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
},
// @ts-ignore
signal: abortController.signal
}
).json<any>();
const { fgi } = await get();
return fgi; return fgi;
} catch (error) { } catch (error) {
Logger.error(error, 'RapidApiService'); Logger.error(error, 'RapidApiService');

View File

@ -1,5 +1,4 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.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 { 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 { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -7,6 +6,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -18,15 +18,10 @@ import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -212,50 +207,50 @@ export class YahooFinanceService implements DataProviderInterface {
}; };
if ( if (
symbol === `${this.baseCurrency}GBP` && symbol === `${DEFAULT_CURRENCY}GBP` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}GBp=X`) yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}GBp=X`)
) { ) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
response[`${this.baseCurrency}GBp`] = { response[`${DEFAULT_CURRENCY}GBp`] = {
...response[symbol], ...response[symbol],
currency: 'GBp', currency: 'GBp',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}GBp`, symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[symbol].marketPrice value: response[symbol].marketPrice
}) })
}; };
} else if ( } else if (
symbol === `${this.baseCurrency}ILS` && symbol === `${DEFAULT_CURRENCY}ILS` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}ILA=X`) yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ILA=X`)
) { ) {
// Convert ILS to ILA // Convert ILS to ILA
response[`${this.baseCurrency}ILA`] = { response[`${DEFAULT_CURRENCY}ILA`] = {
...response[symbol], ...response[symbol],
currency: 'ILA', currency: 'ILA',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ILA`, symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[symbol].marketPrice value: response[symbol].marketPrice
}) })
}; };
} else if ( } else if (
symbol === `${this.baseCurrency}ZAR` && symbol === `${DEFAULT_CURRENCY}ZAR` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}ZAc=X`) yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ZAc=X`)
) { ) {
// Convert ZAR to ZAc (cents) // Convert ZAR to ZAc (cents)
response[`${this.baseCurrency}ZAc`] = { response[`${DEFAULT_CURRENCY}ZAc`] = {
...response[symbol], ...response[symbol],
currency: 'ZAc', currency: 'ZAc',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ZAc`, symbol: `${DEFAULT_CURRENCY}ZAc`,
value: response[symbol].marketPrice value: response[symbol].marketPrice
}) })
}; };
} }
} }
if (yahooFinanceSymbols.includes(`${this.baseCurrency}USX=X`)) { if (yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}USX=X`)) {
// Convert USD to USX (cent) // Convert USD to USX (cent)
response[`${this.baseCurrency}USX`] = { response[`${DEFAULT_CURRENCY}USX`] = {
currency: 'USX', currency: 'USX',
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(), marketPrice: new Big(1).mul(100).toNumber(),
@ -303,8 +298,8 @@ export class YahooFinanceService implements DataProviderInterface {
(quoteType === 'CRYPTOCURRENCY' && (quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
symbol.replace( symbol.replace(
new RegExp(`-${this.baseCurrency}$`), new RegExp(`-${DEFAULT_CURRENCY}$`),
this.baseCurrency DEFAULT_CURRENCY
) )
)) || )) ||
quoteTypes.includes(quoteType) quoteTypes.includes(quoteType)
@ -314,7 +309,7 @@ export class YahooFinanceService implements DataProviderInterface {
if (quoteType === 'CRYPTOCURRENCY') { if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database. // Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Transactions need to be converted manually to the base currency before // Transactions need to be converted manually to the base currency before
return symbol.includes(this.baseCurrency); return symbol.includes(DEFAULT_CURRENCY);
} else if (quoteType === 'FUTURE') { } else if (quoteType === 'FUTURE') {
// Allow GC=F, but not MGC=F // Allow GC=F, but not MGC=F
return symbol.length === 4; return symbol.length === 4;
@ -373,13 +368,13 @@ export class YahooFinanceService implements DataProviderInterface {
symbol: string; symbol: string;
value: number; value: number;
}) { }) {
if (symbol === `${this.baseCurrency}GBp`) { if (symbol === `${DEFAULT_CURRENCY}GBp`) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) { } else if (symbol === `${DEFAULT_CURRENCY}ILA`) {
// Convert ILS to ILA // Convert ILS to ILA
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ZAc`) { } else if (symbol === `${DEFAULT_CURRENCY}ZAc`) {
// Convert ZAR to ZAc (cents) // Convert ZAR to ZAc (cents)
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} }

View File

@ -1,10 +1,12 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, isToday } from 'date-fns'; import { format, isToday } from 'date-fns';
@ -12,13 +14,11 @@ import { isNumber, uniq } from 'lodash';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private baseCurrency: string;
private currencies: string[] = []; private currencies: string[] = [];
private currencyPairs: IDataGatheringItem[] = []; private currencyPairs: IDataGatheringItem[] = [];
private exchangeRates: { [currencyPair: string]: number } = {}; private exchangeRates: { [currencyPair: string]: number } = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -26,15 +26,23 @@ export class ExchangeRateDataService {
) {} ) {}
public getCurrencies() { public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency]; return this.currencies?.length > 0 ? this.currencies : [DEFAULT_CURRENCY];
} }
public getCurrencyPairs() { public getCurrencyPairs() {
return this.currencyPairs; return this.currencyPairs;
} }
public hasCurrencyPair(currency1: string, currency2: string) {
return this.currencyPairs.some(({ symbol }) => {
return (
symbol === `${currency1}${currency2}` ||
symbol === `${currency2}${currency1}`
);
});
}
public async initialize() { public async initialize() {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
this.currencies = await this.prepareCurrencies(); this.currencies = await this.prepareCurrencies();
this.currencyPairs = []; this.currencyPairs = [];
this.exchangeRates = {}; this.exchangeRates = {};
@ -104,9 +112,9 @@ export class ExchangeRateDataService {
if (!this.exchangeRates[symbol]) { if (!this.exchangeRates[symbol]) {
// Not found, calculate indirectly via base currency // Not found, calculate indirectly via base currency
this.exchangeRates[symbol] = this.exchangeRates[symbol] =
resultExtended[`${currency1}${this.baseCurrency}`]?.[date] resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[date]
?.marketPrice * ?.marketPrice *
resultExtended[`${this.baseCurrency}${currency2}`]?.[date] resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[date]
?.marketPrice; ?.marketPrice;
// Calculate the opposite direction // Calculate the opposite direction
@ -135,9 +143,8 @@ export class ExchangeRateDataService {
} else { } else {
// Calculate indirectly via base currency // Calculate indirectly via base currency
const factor1 = const factor1 =
this.exchangeRates[`${aFromCurrency}${this.baseCurrency}`]; this.exchangeRates[`${aFromCurrency}${DEFAULT_CURRENCY}`];
const factor2 = const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`];
this.exchangeRates[`${this.baseCurrency}${aToCurrency}`];
factor = factor1 * factor2; factor = factor1 * factor2;
@ -195,28 +202,28 @@ export class ExchangeRateDataService {
let marketPriceBaseCurrencyToCurrency: number; let marketPriceBaseCurrencyToCurrency: number;
try { try {
if (this.baseCurrency === aFromCurrency) { if (aFromCurrency === DEFAULT_CURRENCY) {
marketPriceBaseCurrencyFromCurrency = 1; marketPriceBaseCurrencyFromCurrency = 1;
} else { } else {
marketPriceBaseCurrencyFromCurrency = ( marketPriceBaseCurrencyFromCurrency = (
await this.marketDataService.get({ await this.marketDataService.get({
dataSource, dataSource,
date: aDate, date: aDate,
symbol: `${this.baseCurrency}${aFromCurrency}` symbol: `${DEFAULT_CURRENCY}${aFromCurrency}`
}) })
)?.marketPrice; )?.marketPrice;
} }
} catch {} } catch {}
try { try {
if (this.baseCurrency === aToCurrency) { if (aToCurrency === DEFAULT_CURRENCY) {
marketPriceBaseCurrencyToCurrency = 1; marketPriceBaseCurrencyToCurrency = 1;
} else { } else {
marketPriceBaseCurrencyToCurrency = ( marketPriceBaseCurrencyToCurrency = (
await this.marketDataService.get({ await this.marketDataService.get({
dataSource, dataSource,
date: aDate, date: aDate,
symbol: `${this.baseCurrency}${aToCurrency}` symbol: `${DEFAULT_CURRENCY}${aToCurrency}`
}) })
)?.marketPrice; )?.marketPrice;
} }
@ -286,14 +293,14 @@ export class ExchangeRateDataService {
private prepareCurrencyPairs(aCurrencies: string[]) { private prepareCurrencyPairs(aCurrencies: string[]) {
return aCurrencies return aCurrencies
.filter((currency) => { .filter((currency) => {
return currency !== this.baseCurrency; return currency !== DEFAULT_CURRENCY;
}) })
.map((currency) => { .map((currency) => {
return { return {
currency1: this.baseCurrency, currency1: DEFAULT_CURRENCY,
currency2: currency, currency2: currency,
dataSource: this.dataProviderService.getDataSourceForExchangeRates(), dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
symbol: `${this.baseCurrency}${currency}` symbol: `${DEFAULT_CURRENCY}${currency}`
}; };
}); });
} }

View File

@ -3,7 +3,6 @@ import { CleanedEnvAccessors } from 'envalid';
export interface Environment extends CleanedEnvAccessors { export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
BETTER_UPTIME_API_KEY: string; BETTER_UPTIME_API_KEY: string;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;

View File

@ -65,9 +65,8 @@ export class TwitterBotService {
status += benchmarkListing; status += benchmarkListing;
} }
const { data: createdTweet } = await this.twitterClient.v2.tweet( const { data: createdTweet } =
status await this.twitterClient.v2.tweet(status);
);
Logger.log( Logger.log(
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`, `Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`,

View File

@ -63,6 +63,10 @@
"baseHref": "/pt/", "baseHref": "/pt/",
"localize": ["pt"] "localize": ["pt"]
}, },
"development-tr": {
"baseHref": "/tr/",
"localize": ["tr"]
},
"production": { "production": {
"fileReplacements": [ "fileReplacements": [
{ {
@ -110,6 +114,9 @@
{ {
"command": "cp apps/client/src/assets/favicon.ico dist/apps/client" "command": "cp apps/client/src/assets/favicon.ico dist/apps/client"
}, },
{
"command": "cp apps/client/src/assets/index.html dist/apps/client"
},
{ {
"command": "cp apps/client/src/assets/robots.txt dist/apps/client" "command": "cp apps/client/src/assets/robots.txt dist/apps/client"
}, },
@ -162,6 +169,9 @@
"development-pt": { "development-pt": {
"browserTarget": "client:build:development-pt" "browserTarget": "client:build:development-pt"
}, },
"development-tr": {
"browserTarget": "client:build:development-tr"
},
"production": { "production": {
"browserTarget": "client:build:production" "browserTarget": "client:build:production"
} }
@ -179,7 +189,8 @@
"messages.fr.xlf", "messages.fr.xlf",
"messages.it.xlf", "messages.it.xlf",
"messages.nl.xlf", "messages.nl.xlf",
"messages.pt.xlf" "messages.pt.xlf",
"messages.tr.xlf"
] ]
} }
}, },
@ -223,6 +234,10 @@
"pt": { "pt": {
"baseHref": "/pt/", "baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf" "translation": "apps/client/src/locales/messages.pt.xlf"
},
"tr": {
"baseHref": "/tr/",
"translation": "apps/client/src/locales/messages.tr.xlf"
} }
}, },
"sourceLocale": "en" "sourceLocale": "en"

View File

@ -4,25 +4,29 @@ import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strate
import { ModulePreloadService } from './core/module-preload.service'; import { ModulePreloadService } from './core/module-preload.service';
export const paths = {
about: $localize`about`,
faq: $localize`faq`,
features: $localize`features`,
license: $localize`license`,
markets: $localize`markets`,
pricing: $localize`pricing`,
privacyPolicy: $localize`privacy-policy`,
register: $localize`register`,
resources: $localize`resources`
};
const routes: Routes = [ const routes: Routes = [
...[ {
'about', path: paths.about,
/////
'a-propos',
'informazioni-su',
'over',
'sobre',
'ueber-uns'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule) import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
})), },
{ {
path: 'account', path: 'account',
loadChildren: () => loadChildren: () =>
import('./pages/account/account-page.module').then( import('./pages/user-account/user-account-page.module').then(
(m) => m.AccountPageModule (m) => m.UserAccountPageModule
) )
}, },
{ {
@ -42,64 +46,40 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
}, },
...['blog'].map((path) => ({ {
path, path: 'blog',
loadChildren: () => loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
})), },
{ {
path: 'demo', path: 'demo',
loadChildren: () => loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
}, },
...[ {
'faq', path: paths.faq,
/////
'domande-piu-frequenti',
'foire-aux-questions',
'haeufig-gestellte-fragen',
'perguntas-mais-frequentes',
'preguntas-mas-frecuentes',
'vaak-gestelde-vragen'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule) import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
})), },
...[ {
'features', path: paths.features,
/////
'fonctionnalites',
'funcionalidades',
'funzionalita',
'kenmerken'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/features/features-page.module').then( import('./pages/features/features-page.module').then(
(m) => m.FeaturesPageModule (m) => m.FeaturesPageModule
) )
})), },
{ {
path: 'home', path: 'home',
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
...[ {
'markets', path: paths.markets,
/////
'maerkte',
'marches',
'markten',
'mercados',
'mercati'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/markets/markets-page.module').then( import('./pages/markets/markets-page.module').then(
(m) => m.MarketsPageModule (m) => m.MarketsPageModule
) )
})), },
{ {
path: 'open', path: 'open',
loadChildren: () => loadChildren: () =>
@ -119,53 +99,27 @@ const routes: Routes = [
(m) => m.PortfolioPageModule (m) => m.PortfolioPageModule
) )
}, },
...[ {
'pricing', path: paths.pricing,
/////
'precios',
'precos',
'preise',
'prezzi',
'prijzen',
'prix'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/pricing/pricing-page.module').then( import('./pages/pricing/pricing-page.module').then(
(m) => m.PricingPageModule (m) => m.PricingPageModule
) )
})), },
...[ {
'register', path: paths.register,
/////
'enregistrement',
'iscrizione',
'registo',
'registratie',
'registrierung',
'registro'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/register/register-page.module').then( import('./pages/register/register-page.module').then(
(m) => m.RegisterPageModule (m) => m.RegisterPageModule
) )
})), },
...[ {
'resources', path: paths.resources,
/////
'bronnen',
'recursos',
'ressourcen',
'ressources',
'risorse'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/resources/resources-page.module').then( import('./pages/resources/resources-page.module').then(
(m) => m.ResourcesPageModule (m) => m.ResourcesPageModule
) )
})), },
{ {
path: 'start', path: 'start',
loadChildren: () => loadChildren: () =>

View File

@ -1,37 +1,26 @@
<header> <header>
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[info]="info"
[pageTitle]="pageTitle"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
</header>
<main role="main">
<div <div
*ngIf="canCreateAccount || (info?.systemMessage && user)" *ngIf="canCreateAccount || (info?.systemMessage && user)"
class="container info-message-container" class="info-message-container"
> >
<div class="row"> <div class="info-message-inner-container position-fixed w-100">
<div class="col-md-8 offset-md-2 text-center"> <div class="align-items-center d-flex h-100 justify-content-center">
<a <a
*ngIf="canCreateAccount" *ngIf="canCreateAccount"
class="text-center" class="text-center"
[routerLink]="['/register']" [routerLink]="routerLinkRegister"
> >
<div <div
class="cursor-pointer d-inline-block info-message px-3 py-2" class="cursor-pointer d-inline-block info-message"
(click)="onCreateAccount()" (click)="onCreateAccount()"
> >
<span>You are using the Live Demo.</span> <span i18n>You are using the Live Demo.</span>
<span class="a ml-2">Create Account</span> <span class="a ml-2" i18n>Create Account</span>
</div></a </div></a
> >
<div <div
*ngIf="!canCreateAccount && info?.systemMessage && user" *ngIf="!canCreateAccount && info?.systemMessage && user"
class="cursor-pointer d-inline-block info-message px-3 py-2 text-truncate" class="cursor-pointer d-inline-block info-message text-truncate"
(click)="onShowSystemMessage()" (click)="onShowSystemMessage()"
> >
{{ info.systemMessage }} {{ info.systemMessage }}
@ -40,25 +29,22 @@
</div> </div>
</div> </div>
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[hasTabs]="hasTabs"
[info]="info"
[pageTitle]="pageTitle"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
</header>
<main role="main">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<footer <footer *ngIf="showFooter" class="d-flex justify-content-center py-4 w-100">
*ngIf="
(currentRoute === 'blog' ||
currentRoute === 'faq' ||
currentRoute === 'features' ||
currentRoute === 'markets' ||
currentRoute === 'open' ||
currentRoute === 'p' ||
currentRoute === 'pricing' ||
currentRoute === 'resources' ||
currentRoute === 'register' ||
currentRoute === 'start') &&
deviceType !== 'mobile'
"
class="d-flex justify-content-center py-4 w-100"
>
<div class="container"> <div class="container">
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-sm"> <div class="col-sm">
@ -68,36 +54,38 @@
<div class="h6 mt-2" i18n>Personal Finance</div> <div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li *ngIf="hasPermissionToAccessFearAndGreedIndex"> <li *ngIf="hasPermissionToAccessFearAndGreedIndex">
<a i18n [routerLink]="['/markets']">Markets</a> <a i18n [routerLink]="routerLinkMarkets">Markets</a>
</li> </li>
<li><a i18n [routerLink]="['/resources']">Resources</a></li> <li><a i18n [routerLink]="routerLinkResources">Resources</a></li>
</ul> </ul>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="h6 mt-2">Ghostfolio</div> <div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li><a i18n [routerLink]="['/about']">About</a></li> <li><a i18n [routerLink]="routerLinkAbout">About</a></li>
<li *ngIf="hasPermissionForBlog"> <li *ngIf="hasPermissionForBlog">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li> <li>
<a i18n [routerLink]="['/about', 'changelog']">Changelog</a> <a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a>
</li> </li>
<li><a i18n [routerLink]="['/features']">Features</a></li> <li><a i18n [routerLink]="routerLinkFeatures">Features</a></li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a> <a i18n [routerLink]="routerLinkFaq"
>Frequently Asked Questions (FAQ)</a
>
</li> </li>
<li> <li>
<a i18n [routerLink]="['/about', 'license']">License</a> <a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li> </li>
<li *ngIf="hasPermissionForStatistics"> <li *ngIf="hasPermissionForStatistics">
<a [routerLink]="['/open']">Open Startup</a> <a [routerLink]="['/open']">Open Startup</a>
</li> </li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/pricing']">Pricing</a> <a i18n [routerLink]="routerLinkPricing">Pricing</a>
</li> </li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/about', 'privacy-policy']" <a i18n [routerLink]="routerLinkAboutPrivacyPolicy"
>Privacy Policy</a >Privacy Policy</a
> >
</li> </li>
@ -164,6 +152,11 @@
<li> <li>
<a href="../pt" title="Ghostfolio in Português">Português</a> <a href="../pt" title="Ghostfolio in Português">Português</a>
</li> </li>
<!--
<li>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
</li>
-->
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -4,31 +4,47 @@
display: block; display: block;
min-height: 100vh; min-height: 100vh;
&.has-info-message {
header {
height: calc(2 * var(--mat-toolbar-standard-height));
.info-message-container {
height: var(--mat-toolbar-standard-height);
.info-message-inner-container {
background-color: rgba(var(--palette-primary-500), 1);
height: var(--mat-toolbar-standard-height);
z-index: 999;
.info-message {
color: rgba(var(--palette-foreground-text), 1);
font-size: 80%;
max-width: 100%;
.a {
font-weight: 500;
}
}
}
}
}
main {
min-height: calc(100vh - 2 * var(--mat-toolbar-standard-height));
}
}
footer { footer {
background-color: rgba(var(--palette-foreground-text), 0.05); background-color: rgba(var(--palette-foreground-text), 0.05);
font-size: 90%; font-size: 90%;
} }
header {
height: var(--mat-toolbar-standard-height);
}
main { main {
min-height: 100vh; min-height: calc(100vh - var(--mat-toolbar-standard-height));
padding-top: 5rem;
.info-message-container {
height: 3.5rem;
margin-top: -0.5rem;
.info-message {
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);
font-weight: 500;
}
}
}
} }
} }
@ -36,12 +52,4 @@
footer { footer {
background-color: rgba(var(--palette-foreground-text-dark), 0.05); background-color: rgba(var(--palette-foreground-text-dark), 0.05);
} }
main {
.info-message-container {
.info-message {
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
}
}
}
} }

View File

@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
HostBinding,
Inject, Inject,
OnDestroy, OnDestroy,
OnInit OnInit
@ -28,16 +29,36 @@ import { UserService } from './services/user/user.service';
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss']
}) })
export class AppComponent implements OnDestroy, OnInit { export class AppComponent implements OnDestroy, OnInit {
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public canCreateAccount: boolean; public canCreateAccount: boolean;
public currentRoute: string; public currentRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public hasInfoMessage: boolean;
public hasPermissionForBlog: boolean; public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasTabs = false;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkAboutChangelog = ['/' + $localize`about`, 'changelog'];
public routerLinkAboutLicense = ['/' + $localize`about`, $localize`license`];
public routerLinkAboutPrivacyPolicy = [
'/' + $localize`about`,
$localize`privacy-policy`
];
public routerLinkFaq = ['/' + $localize`faq`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
public showFooter = false;
public user: User; public user: User;
public version = environment.version; public version = environment.version;
@ -89,6 +110,27 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegments = urlSegmentGroup.segments; const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path; this.currentRoute = urlSegments[0].path;
this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === 'admin' ||
this.currentRoute === 'home' ||
this.currentRoute === 'portfolio' ||
this.currentRoute === 'zen') &&
this.deviceType !== 'mobile';
this.showFooter =
(this.currentRoute === 'blog' ||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
this.currentRoute === this.routerLinkFeatures[0].slice(1) ||
this.currentRoute === this.routerLinkMarkets[0].slice(1) ||
this.currentRoute === 'open' ||
this.currentRoute === 'p' ||
this.currentRoute === this.routerLinkPricing[0].slice(1) ||
this.currentRoute === this.routerLinkRegister[0].slice(1) ||
this.currentRoute === this.routerLinkResources[0].slice(1) ||
this.currentRoute === 'start') &&
this.deviceType !== 'mobile';
if (this.deviceType === 'mobile') { if (this.deviceType === 'mobile') {
setTimeout(() => { setTimeout(() => {
const index = this.title.getTitle().indexOf(''); const index = this.title.getTitle().indexOf('');
@ -113,6 +155,12 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.createUserAccount permissions.createUserAccount
); );
this.hasInfoMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!this.info.systemMessage;
this.initializeTheme(this.user?.settings.colorScheme); this.initializeTheme(this.user?.settings.colorScheme);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -1,3 +1,15 @@
<div *ngIf="hasPermissionToCreateAccess" class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
Add Access
</a>
</div>
<table class="gf-table w-100" mat-table [dataSource]="dataSource"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias"> <ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>

View File

@ -19,6 +19,7 @@ import { Access } from '@ghostfolio/common/interfaces';
}) })
export class AccessTableComponent implements OnChanges, OnInit { export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[]; @Input() accesses: Access[];
@Input() hasPermissionToCreateAccess = false;
@Input() showActions: boolean; @Input() showActions: boolean;
@Output() accessDeleted = new EventEmitter<string>(); @Output() accessDeleted = new EventEmitter<string>();

View File

@ -3,13 +3,20 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { AccessTableComponent } from './access-table.component'; import { AccessTableComponent } from './access-table.component';
@NgModule({ @NgModule({
declarations: [AccessTableComponent], declarations: [AccessTableComponent],
exports: [AccessTableComponent], exports: [AccessTableComponent],
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule], imports: [
CommonModule,
MatButtonModule,
MatMenuModule,
MatTableModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfPortfolioAccessTableModule {} export class GfPortfolioAccessTableModule {}

View File

@ -29,13 +29,13 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss'] styleUrls: ['./account-detail-dialog.component.scss']
}) })
export class AccountDetailDialog implements OnDestroy, OnInit { export class AccountDetailDialog implements OnDestroy, OnInit {
public accountType: string;
public balance: number; public balance: number;
public currency: string; public currency: string;
public equity: number; public equity: number;
public name: string; public name: string;
public orders: OrderWithAccount[]; public orders: OrderWithAccount[];
public platformName: string; public platformName: string;
public transactionCount: number;
public user: User; public user: User;
public valueInBaseCurrency: number; public valueInBaseCurrency: number;
@ -65,15 +65,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(
({ ({
accountType,
balance, balance,
currency, currency,
name, name,
Platform, Platform,
transactionCount,
value, value,
valueInBaseCurrency valueInBaseCurrency
}) => { }) => {
this.accountType = translate(accountType);
this.balance = balance; this.balance = balance;
this.currency = currency; this.currency = currency;
@ -85,6 +84,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.name = name; this.name = name;
this.platformName = Platform?.name ?? '-'; this.platformName = Platform?.name ?? '-';
this.transactionCount = transactionCount;
this.valueInBaseCurrency = valueInBaseCurrency; this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -44,8 +44,8 @@
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="accountType" <gf-value i18n size="medium" [value]="transactionCount"
>Account Type</gf-value >Activities</gf-value
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">

View File

@ -85,7 +85,7 @@
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="px-1 text-right" class="justify-content-end px-1"
mat-header-cell mat-header-cell
mat-sort-header="transactionCount" mat-sort-header="transactionCount"
> >
@ -93,9 +93,7 @@
<span class="d-none d-sm-block" i18n>Activities</span> <span class="d-none d-sm-block" i18n>Activities</span>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<ng-container *ngIf="element.accountType === 'SECURITIES'">{{ {{ element.transactionCount }}
element.transactionCount
}}</ng-container>
</td> </td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell> <td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
{{ transactionCount }} {{ transactionCount }}

View File

@ -29,7 +29,7 @@
}" }"
[title]=" [title]="
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1) (itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
| date : defaultDateFormat) ?? '' | date: defaultDateFormat) ?? ''
" "
(click)=" (click)="
onOpenMarketDataDetail({ onOpenMarketDataDetail({

View File

@ -154,7 +154,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
day: string; day: string;
yearMonth: string; yearMonth: string;
}) { }) {
const date = new Date(`${yearMonth}-${day}`); const date = parseISO(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
if (isSameDay(date, new Date())) { if (isSameDay(date, new Date())) {

View File

@ -60,6 +60,11 @@ export class AdminMarketDataComponent
}; };
}) })
.concat([ .concat([
{
id: 'CURRENCIES',
label: $localize`Currencies`,
type: <Filter['type']>'PRESET_ID'
},
{ {
id: 'ETF_WITHOUT_COUNTRIES', id: 'ETF_WITHOUT_COUNTRIES',
label: $localize`ETFs without Countries`, label: $localize`ETFs without Countries`,

View File

@ -72,19 +72,6 @@
</div> </div>
</div> </div>
</div> </div>
<div
*ngIf="info?.tags?.length > 0"
class="align-items-start d-flex my-3"
>
<div class="w-50" i18n>Tags</div>
<div class="w-50">
<table>
<tr *ngFor="let tag of info.tags">
<td class="pl-1">{{ tag.name }}</td>
</tr>
</table>
</div>
</div>
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>User Signup</div> <div class="w-50" i18n>User Signup</div>
<div class="w-50"> <div class="w-50">
@ -169,6 +156,8 @@
<mat-option value="7 days">7 Days</mat-option> <mat-option value="7 days">7 Days</mat-option>
<mat-option value="14 days">14 Days</mat-option> <mat-option value="14 days">14 Days</mat-option>
<mat-option value="30 days">30 Days</mat-option> <mat-option value="30 days">30 Days</mat-option>
<mat-option value="90 days">90 Days</mat-option>
<mat-option value="180 days">180 Days</mat-option>
<mat-option value="1 year">1 Year</mat-option> <mat-option value="1 year">1 Year</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>

View File

@ -19,7 +19,7 @@ import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-account-platform.component'; import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -114,6 +114,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((platforms) => { .subscribe((platforms) => {
this.platforms = platforms; this.platforms = platforms;
this.dataSource = new MatTableDataSource(platforms); this.dataSource = new MatTableDataSource(platforms);
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get; this.dataSource.sortingDataAccessor = get;
@ -130,7 +131,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url: null url: null
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
@ -170,7 +170,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url url
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });

View File

@ -15,8 +15,8 @@ export class CreateOrUpdatePlatformDialog {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>
) {} ) {}
public onCancel() { public onCancel() {

View File

@ -6,7 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { CreateOrUpdatePlatformDialog } from './create-or-update-account-platform.component'; import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog.component';
@NgModule({ @NgModule({
declarations: [CreateOrUpdatePlatformDialog], declarations: [CreateOrUpdatePlatformDialog],

View File

@ -1,15 +1,14 @@
<div class="container"> <div class="container">
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="text-center" i18n>Platforms</h3> <h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform></gf-admin-platform> <gf-admin-platform />
</div> </div>
</div> </div>
<!--
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="text-center" i18n>Tags</h3> <h2 class="text-center" i18n>Tags</h2>
<gf-admin-tag />
</div> </div>
</div> </div>
-->
</div> </div>

View File

@ -8,7 +8,6 @@ import { Subject } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
selector: 'gf-admin-settings', selector: 'gf-admin-settings',
styleUrls: ['./admin-settings.component.scss'], styleUrls: ['./admin-settings.component.scss'],
templateUrl: './admin-settings.component.html' templateUrl: './admin-settings.component.html'

View File

@ -2,12 +2,18 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module'; import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { AdminSettingsComponent } from './admin-settings.component'; import { AdminSettingsComponent } from './admin-settings.component';
@NgModule({ @NgModule({
declarations: [AdminSettingsComponent], declarations: [AdminSettingsComponent],
imports: [CommonModule, GfAdminPlatformModule, RouterModule], imports: [
CommonModule,
GfAdminPlatformModule,
GfAdminTagModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfAdminSettingsModule {} export class GfAdminSettingsModule {}

View File

@ -0,0 +1,85 @@
<div class="container">
<div class="row">
<div class="col">
<div class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createTagDialog: true }"
[routerLink]="[]"
>
Add Tag
</a>
</div>
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="name"
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="name"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
</td>
</ng-container>
<ng-container matColumnDef="activities">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="activityCount"
>
<ng-container i18n>Activities</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th
*matHeaderCellDef
class="px-1 text-center"
i18n
mat-header-cell
></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="tagMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button>
<button mat-menu-item (click)="onDeleteTag(element.id)">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
</div>
</div>
</div>

View File

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

View File

@ -0,0 +1,199 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Tag } from '@prisma/client';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-tag',
styleUrls: ['./admin-tag.component.scss'],
templateUrl: './admin-tag.component.html'
})
export class AdminTagComponent implements OnInit, OnDestroy {
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource();
public deviceType: string;
public displayedColumns = ['name', 'activities', 'actions'];
public tags: Tag[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createTagDialog']) {
this.openCreateTagDialog();
} else if (params['editTagDialog']) {
if (this.tags) {
const tag = this.tags.find(({ id }) => {
return id === params['tagId'];
});
this.openUpdateTagDialog(tag);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.fetchTags();
}
public onDeleteTag(aId: string) {
const confirmation = confirm(
$localize`Do you really want to delete this tag?`
);
if (confirmation) {
this.deleteTag(aId);
}
}
public onUpdateTag({ id }: Tag) {
this.router.navigate([], {
queryParams: { editTagDialog: true, tagId: id }
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deleteTag(aId: string) {
this.adminService
.deleteTag(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
private fetchTags() {
this.adminService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.changeDetectorRef.markForCheck();
});
}
private openCreateTagDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
data: {
tag: {
name: null
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: CreateTagDto = data?.tag;
if (tag) {
this.adminService
.postTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private openUpdateTagDialog({ id, name }) {
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
data: {
tag: {
id,
name
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: UpdateTagDto = data?.tag;
if (tag) {
this.adminService
.putTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { AdminTagComponent } from './admin-tag.component';
import { GfCreateOrUpdateTagDialogModule } from './create-or-update-tag-dialog/create-or-update-tag-dialog.module';
@NgModule({
declarations: [AdminTagComponent],
exports: [AdminTagComponent],
imports: [
CommonModule,
GfCreateOrUpdateTagDialogModule,
MatButtonModule,
MatMenuModule,
MatSortModule,
MatTableModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminTagModule {}

View File

@ -0,0 +1,30 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-or-update-tag-dialog',
styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html'
})
export class CreateOrUpdateTagDialog {
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>
) {}
public onCancel() {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,23 @@
<form #addTagForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1>
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.tag.name" />
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!addTagForm.form.valid"
[mat-dialog-close]="data"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog.component';
@NgModule({
declarations: [CreateOrUpdateTagDialog],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
]
})
export class GfCreateOrUpdateTagDialogModule {}

View File

@ -0,0 +1,5 @@
import { Tag } from '@prisma/client';
export interface CreateOrUpdateTagDialogParams {
tag: Tag;
}

View File

@ -1,14 +1,17 @@
<mat-toolbar class="px-2"> <mat-toolbar class="px-0">
<ng-container *ngIf="user"> <ng-container *ngIf="user">
<a <div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
class="align-items-center d-flex h-100 no-min-width px-2 rounded-0" <a
mat-button class="align-items-center justify-content-start rounded-0"
[routerLink]="['/']" mat-button
> [ngClass]="{ 'w-100': hasTabs }"
<gf-logo [label]="pageTitle"></gf-logo> [routerLink]="['/']"
</a> >
<gf-logo class="px-2" [label]="pageTitle"></gf-logo>
</a>
</div>
<span class="spacer"></span> <span class="spacer"></span>
<ul class="alig-items-center d-flex list-inline m-0"> <ul class="alig-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
@ -26,7 +29,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -39,7 +42,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -52,7 +55,7 @@
</li> </li>
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item"> <li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -65,14 +68,14 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'resources', 'font-weight-bold': currentRoute === routeResources,
'text-decoration-underline': currentRoute === 'resources' 'text-decoration-underline': currentRoute === routeResources
}" }"
[routerLink]="['/resources']" [routerLink]="routerLinkResources"
>Resources</a >Resources</a
> >
</li> </li>
@ -83,27 +86,27 @@
class="list-inline-item" class="list-inline-item"
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'pricing', 'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === 'pricing' 'text-decoration-underline': currentRoute === routePricing
}" }"
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
>Pricing</a >Pricing</a
> >
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'about', 'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === 'about' 'text-decoration-underline': currentRoute === routeAbout
}" }"
[routerLink]="['/about']" [routerLink]="routerLinkAbout"
>About</a >About</a
> >
</li> </li>
@ -129,33 +132,37 @@
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0"> <ng-container *ngIf="user?.access?.length > 0">
<button mat-menu-item (click)="impersonateAccount(null)"> <button mat-menu-item (click)="impersonateAccount(null)">
<ion-icon <span class="align-items-center d-flex">
*ngIf="user?.access?.length > 0" <ion-icon
class="mr-2" *ngIf="user?.access?.length > 0"
[name]=" class="mr-2"
impersonationId [name]="
? 'radio-button-off-outline' impersonationId
: 'radio-button-on-outline' ? 'radio-button-off-outline'
" : 'radio-button-on-outline'
></ion-icon> "
<span i18n>Me</span> ></ion-icon>
<span i18n>Me</span>
</span>
</button> </button>
<button <button
*ngFor="let accessItem of user?.access" *ngFor="let accessItem of user?.access"
mat-menu-item mat-menu-item
(click)="impersonateAccount(accessItem.id)" (click)="impersonateAccount(accessItem.id)"
> >
<ion-icon <span class="align-items-center d-flex">
class="mr-2" <ion-icon
name="square-outline" class="mr-2"
[name]=" name="square-outline"
accessItem.id === impersonationId [name]="
? 'radio-button-on-outline' accessItem.id === impersonationId
: 'radio-button-off-outline' ? 'radio-button-on-outline'
" : 'radio-button-off-outline'
></ion-icon> "
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span> ></ion-icon>
<span *ngIf="!accessItem.alias" i18n>User</span> <span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span>
</span>
</button> </button>
<hr class="m-0" /> <hr class="m-0" />
</ng-container> </ng-container>
@ -210,9 +217,9 @@
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'resources' 'font-weight-bold': currentRoute === routeResources
}" }"
[routerLink]="['/resources']" [routerLink]="routerLinkResources"
>Resources</a >Resources</a
> >
<a <a
@ -223,16 +230,16 @@
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }" [ngClass]="{ 'font-weight-bold': currentRoute === routePricing }"
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
>Pricing</a >Pricing</a
> >
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }" [ngClass]="{ 'font-weight-bold': currentRoute === routeAbout }"
[routerLink]="['/about']" [routerLink]="routerLinkAbout"
>About Ghostfolio</a >About Ghostfolio</a
> >
<hr class="d-flex d-sm-none m-0" /> <hr class="d-flex d-sm-none m-0" />
@ -242,53 +249,58 @@
</ul> </ul>
</ng-container> </ng-container>
<ng-container *ngIf="user === null"> <ng-container *ngIf="user === null">
<a <div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0" <a
mat-button class="align-items-center justify-content-start rounded-0"
[routerLink]="['/']" mat-button
> [ngClass]="{ 'w-100': hasTabs }"
<gf-logo [routerLink]="['/']"
[label]="pageTitle" >
[showLabel]="currentRoute !== 'register'" <gf-logo
></gf-logo> class="px-2"
</a> [label]="pageTitle"
[showLabel]="currentRoute !== 'register'"
></gf-logo>
</a>
</div>
<span class="spacer"></span> <span class="spacer"></span>
<ul class="alig-items-center d-flex list-inline m-0"> <ul class="alig-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'features', 'font-weight-bold': currentRoute === routeFeatures,
'text-decoration-underline': currentRoute === 'features' 'text-decoration-underline': currentRoute === routeFeatuers
}" }"
[routerLink]="['/features']" [routerLink]="routerLinkFeatures"
>Features</a >Features</a
> >
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'about', 'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === 'about' 'text-decoration-underline': currentRoute === routeAbout
}" }"
[routerLink]="['/about']" [routerLink]="routerLinkAbout"
>About</a >About</a
> >
</li> </li>
<li *ngIf="hasPermissionForSubscription" class="list-inline-item"> <li *ngIf="hasPermissionForSubscription" class="list-inline-item">
<a <a
class="d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'pricing', 'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === 'pricing' 'text-decoration-underline': currentRoute === routePricing
}" }"
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
>Pricing</a >Pricing</a
> >
</li> </li>
@ -297,14 +309,14 @@
class="list-inline-item" class="list-inline-item"
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'markets', 'font-weight-bold': currentRoute === routeMarkets,
'text-decoration-underline': currentRoute === 'markets' 'text-decoration-underline': currentRoute === routeMarkets
}" }"
[routerLink]="['/markets']" [routerLink]="routerLinkMarkets"
>Markets</a >Markets</a
> >
</li> </li>
@ -317,19 +329,19 @@
></a> ></a>
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<button class="mx-1" mat-flat-button (click)="openLoginDialog()"> <button class="d-sm-block" mat-flat-button (click)="openLoginDialog()">
<ng-container i18n>Sign in</ng-container> <ng-container i18n>Sign in</ng-container>
</button> </button>
</li> </li>
<li <li
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser" *ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
class="list-inline-item" class="list-inline-item ml-1"
> >
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
color="primary" color="primary"
mat-flat-button mat-flat-button
[routerLink]="['/register']" [routerLink]="routerLinkRegister"
><ng-container i18n>Get started</ng-container> ><ng-container i18n>Get started</ng-container>
</a> </a>
</li> </li>

View File

@ -7,8 +7,18 @@
.mat-toolbar { .mat-toolbar {
background-color: var(--light-background); background-color: var(--light-background);
.spacer { .logo-container {
flex: 1 1 auto; &.filled {
background-color: rgba(var(--palette-foreground-base), 0.02);
}
@media (min-width: 576px) {
width: 14rem;
}
}
.list-inline-item {
margin: 0;
} }
.mdc-button { .mdc-button {
@ -24,11 +34,21 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
} }
.spacer {
flex: 1 1 auto;
}
} }
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
.mat-toolbar { .mat-toolbar {
background-color: var(--dark-background); background-color: var(--dark-background);
.logo-container {
&.filled {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
}
}
} }
} }

View File

@ -29,6 +29,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
}) })
export class HeaderComponent implements OnChanges { export class HeaderComponent implements OnChanges {
@Input() currentRoute: string; @Input() currentRoute: string;
@Input() hasTabs: boolean;
@Input() info: InfoItem; @Input() info: InfoItem;
@Input() pageTitle: string; @Input() pageTitle: string;
@Input() user: User; @Input() user: User;
@ -42,6 +43,17 @@ export class HeaderComponent implements OnChanges {
public hasPermissionToCreateUser: boolean; public hasPermissionToCreateUser: boolean;
public impersonationId: string; public impersonationId: string;
public isMenuOpen: boolean; public isMenuOpen: boolean;
public routeAbout = $localize`about`;
public routeFeatures = $localize`features`;
public routeMarkets = $localize`markets`;
public routePricing = $localize`pricing`;
public routeResources = $localize`resources`;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();

View File

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Markets</h3> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted"> <div class="mb-2 text-center text-muted">

View File

@ -101,7 +101,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
this.isLoading = true; this.isLoading = true;
this.dataService this.dataService
.fetchPortfolioDetails({}) .fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ summary }) => { .subscribe(({ summary }) => {
this.summary = summary; this.summary = summary;
@ -121,7 +121,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef.onAction().subscribe(() => {
this.router.navigate(['/pricing']); this.router.navigate(['/' + $localize`pricing`]);
}); });
} }

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