Compare commits
179 Commits
Author | SHA1 | Date | |
---|---|---|---|
b088df2fa3 | |||
f45d8f616a | |||
d8300502ce | |||
502d51ad29 | |||
bc33e5f147 | |||
48ba8f936b | |||
05ec4cce05 | |||
d74f283707 | |||
0f8bc7db32 | |||
431500f28a | |||
9672de174e | |||
c6aa06b933 | |||
1f46a6b6f3 | |||
1bed940bc0 | |||
f9eb3cc3c5 | |||
2519c3ffb0 | |||
91013d1d10 | |||
6deefb9c43 | |||
d0744e07df | |||
93e1ee3ba7 | |||
dceaa55a6c | |||
8b4d55925d | |||
754b49e50f | |||
6ccbda8169 | |||
b0fb986208 | |||
0b59fc639d | |||
7ddd6f27b5 | |||
c5d56f4b47 | |||
2f2b712999 | |||
c2fd31f5e5 | |||
f2d70f9070 | |||
f41dd9cd8e | |||
7d238b4935 | |||
da6591fca0 | |||
1f9b9e9998 | |||
49c4ea306d | |||
ccb5c664ef | |||
97e165ff69 | |||
45aefb6a45 | |||
2435535975 | |||
bd3d43bf05 | |||
02dc7c52b1 | |||
ff59fd4196 | |||
4955555ddd | |||
a98c788a26 | |||
9c16af81c7 | |||
2df27100f0 | |||
6cf6538719 | |||
0fd3db3228 | |||
18835149e2 | |||
6c9779fb0d | |||
3e98f097ef | |||
183ac8fa2b | |||
9036f53e7d | |||
f7c04e469a | |||
b5f01c0d15 | |||
5a23cd34ad | |||
6e87f34c6f | |||
6618aa2e9b | |||
0d25a96f7e | |||
4f6d9d3a76 | |||
928f6f0c45 | |||
09e95ddcee | |||
2d003225bc | |||
de93cabd69 | |||
51489cca81 | |||
f7f4c3afb1 | |||
0821086e41 | |||
7a905fde63 | |||
d2882b1119 | |||
3a500598c5 | |||
42274917e0 | |||
8ba50f2729 | |||
f22071f061 | |||
d2312371a6 | |||
ba837c3c30 | |||
d85d83a0f5 | |||
62e8594c57 | |||
509f95ea30 | |||
43d0b55004 | |||
c0f130a077 | |||
90dc34380e | |||
286e41eb21 | |||
4973d0261d | |||
c4a62dfd68 | |||
4d6be0a507 | |||
b259ab7b0c | |||
e1ac5245c7 | |||
d4fea075af | |||
cef7fa79de | |||
ca05397dcd | |||
2a11977001 | |||
fb1a5c93ef | |||
77e9791e03 | |||
efd9e7a5c7 | |||
d9ced885e1 | |||
5fe07cb85f | |||
af008aa74f | |||
ca7bf27c20 | |||
0866587cab | |||
622bb8b0cf | |||
16b9fbe00e | |||
c9353d0a39 | |||
ea101dd3bd | |||
cd67ce82fa | |||
d5b3c52602 | |||
bdf72164b1 | |||
455a2d2e92 | |||
9c0f46b587 | |||
8533606177 | |||
6728e04ff7 | |||
2bf4f1237a | |||
4857b2e620 | |||
68a9a7f6f9 | |||
81ef95e13e | |||
b633132757 | |||
2b0f961370 | |||
30f1a3514a | |||
ed735e0b29 | |||
b89ccd2dde | |||
df6d39377f | |||
d5d14497d6 | |||
09c300661a | |||
92382e0b4d | |||
c25f532487 | |||
5d26d94586 | |||
73b6784e9f | |||
6159f48a62 | |||
7d34fba7c1 | |||
c434b730a8 | |||
2d23c566f1 | |||
ba220eaee9 | |||
09023214ce | |||
1ceabb6e6b | |||
421072c7fa | |||
0d421e7181 | |||
f5180ce88f | |||
aabf27dc96 | |||
421809ae95 | |||
d3234f9e77 | |||
a40be2f744 | |||
e62da06c5c | |||
b7f635bdfc | |||
0a465f125d | |||
c02e390bc1 | |||
f9bec0d793 | |||
2f44748f79 | |||
97504756be | |||
6a802a62a0 | |||
51ca26bb4d | |||
2ecc8dbc4e | |||
c0e0e2401e | |||
1a30c180bc | |||
39d4f80f36 | |||
3693091ad6 | |||
bf52f1137d | |||
54ea6c84b4 | |||
689e50ae1a | |||
677757fdf0 | |||
58d9816f01 | |||
5f3d445f1d | |||
fce6caebc2 | |||
d0a4f5c000 | |||
b5e2a3aa91 | |||
f47883fb0b | |||
2932744a68 | |||
73c0f02e06 | |||
382fe24f29 | |||
908876ca6e | |||
99cf9f8802 | |||
7444ff97fc | |||
834a48466e | |||
a9526430c2 | |||
fce3b2084e | |||
f5a50a95de | |||
06dfb91f82 | |||
be36050d76 | |||
7931e6950d | |||
04eb452e04 |
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,7 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
**Bug Description**
|
**Bug Description**
|
||||||
|
|
||||||
@ -36,9 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
|||||||
|
|
||||||
<!-- Please complete the following information -->
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
- [ ] Cloud
|
- Cloud or Self-hosted
|
||||||
- [ ] Self-hosted
|
|
||||||
|
|
||||||
- Ghostfolio Version X.Y.Z
|
- Ghostfolio Version X.Y.Z
|
||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -33,4 +33,4 @@ jobs:
|
|||||||
run: yarn test
|
run: yarn test
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: yarn build:all
|
run: yarn build:production
|
||||||
|
@ -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,
|
||||||
|
348
CHANGELOG.md
348
CHANGELOG.md
@ -5,6 +5,337 @@ 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).
|
||||||
|
|
||||||
|
## 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 the footer to the public page
|
||||||
|
- Added a `copy-assets` `Nx` target to the client build
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the alignment of the region percentages on the allocations page
|
||||||
|
- Improved the alignment of the region percentages on the public page
|
||||||
|
- Improved the redirection of the home page to the localized home page
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `angular` from version `15.2.5` to `16.1.8`
|
||||||
|
- Upgraded `nestjs` from version `9.1.4` to `10.1.3`
|
||||||
|
- Upgraded `Nx` from version `16.0.3` to `16.5.5`
|
||||||
|
|
||||||
|
## 1.296.0 - 2023-08-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the validation in the activities import by reducing the list to unique asset profiles
|
||||||
|
- Optimized the data gathering in the activities import
|
||||||
|
|
||||||
|
## 1.295.0 - 2023-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a step by step introduction for new users
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Removed the _Stay signed in_ setting on _Sign in with fingerprint_ activation
|
||||||
|
|
||||||
|
## 1.294.0 - 2023-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the allocations by market chart on the allocations page by unavailable data
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Considered liabilities in the total account value calculation
|
||||||
|
|
||||||
|
## 1.293.0 - 2023-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added error handling for the _Redis_ connections to keep the app running if the connection fails
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Set the `lastmod` dates of `sitemap.xml` dynamically
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the missing values in the holdings table
|
||||||
|
- Fixed the `no such file or directory` error caused by the missing `favicon.ico` file
|
||||||
|
|
||||||
|
## 1.292.0 - 2023-07-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced the allocations by market chart on the allocations page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.4.2` to `2.4.3`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the public page
|
||||||
|
|
||||||
|
## 1.291.0 - 2023-07-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Broken down the emergency fund by cash and assets
|
||||||
|
- Added support for account balance time series
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed queries to presets in the historical market data table of the admin control panel
|
||||||
|
|
||||||
|
## 1.290.0 - 2023-07-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added hints to the activity types in the create or edit activity dialog
|
||||||
|
- Added queries to the historical market data table of the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the login dialog
|
||||||
|
- Disabled the caching in the health check endpoints for data providers
|
||||||
|
- Improved the content of the Frequently Asked Questions (FAQ) page
|
||||||
|
- Upgraded `prisma` from version `4.15.0` to `4.16.2`
|
||||||
|
|
||||||
|
## 1.289.0 - 2023-07-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.4.1` to `2.4.2`
|
||||||
|
|
||||||
|
## 1.288.0 - 2023-07-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the loading state during filtering on the allocations page
|
||||||
|
- Beautified the names with ampersand (`&`) in the asset profile
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.287.0 - 2023-07-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hid the average buy price in the position detail chart if there is no holding
|
||||||
|
- Improved the language localization for French (`fr`)
|
||||||
|
- Refactored the blog articles to standalone components
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the sorting by currency in the activities table
|
||||||
|
|
||||||
|
## 1.286.0 - 2023-07-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the creation of (wealth) items and liabilities
|
||||||
|
|
||||||
|
## 1.285.0 - 2023-07-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Exploring the Path to Financial Independence and Retiring Early (FIRE)_
|
||||||
|
- Added pagination to the historical market data table of the admin control panel
|
||||||
|
- Added the attribute `headers` to the scraper configuration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the asset profile details dialog in the admin control panel by the scraper configuration
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.284.0 - 2023-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the currency to the cash balance in the create or update account dialog
|
||||||
|
- Added the ability to add an index for benchmarks as an asset profile in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded the _Internet Identity_ dependencies from version `0.15.1` to `0.15.7`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the clone functionality of a transaction caused by the symbol search component
|
||||||
|
|
||||||
|
## 1.283.5 - 2023-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the caching for current market prices
|
||||||
|
- Added a loading indicator to the import dividends dialog
|
||||||
|
- Set up the `helmet` middleware to protect the app from web vulnerabilities by setting HTTP headers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the selected item of the holding selector in the import dividends dialog
|
||||||
|
- Extended the symbol search component by asset sub classes
|
||||||
|
|
||||||
|
## 1.282.0 - 2023-06-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an icon to the external links in the footer navigation
|
||||||
|
- Added the ability to add an asset profile in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the use of permissions on the about page
|
||||||
|
- Harmonized the use of permissions on the landing page
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Improved the language localization for Portuguese (`pt`)
|
||||||
|
- Updated the binary targets of `linux-arm64-openssl` for `prisma`
|
||||||
|
|
||||||
|
## 1.281.0 - 2023-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the feature overview page by liabilities
|
||||||
|
- Set up the language localization for Portuguese (`pt`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extracted the symbol search to a dedicated component
|
||||||
|
- Improved the column headers in the holdings table for mobile
|
||||||
|
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
|
||||||
|
|
||||||
|
## 1.280.1 - 2023-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for liabilities
|
||||||
|
|
||||||
## 1.279.0 - 2023-06-10
|
## 1.279.0 - 2023-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -371,7 +702,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)
|
||||||
@ -793,7 +1124,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Added support for the dividend timeline grouped by year
|
- Added support for the dividend timeline grouped by year
|
||||||
- Added support for the investment timeline grouped by year
|
- Added support for the investment timeline grouped by year
|
||||||
- Set up the language localization for Français (`fr`)
|
- Set up the language localization for Français (`fr`)
|
||||||
- Set up the language localization for Português (`pt`)
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -934,7 +1264,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
|
||||||
@ -1073,7 +1403,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Added support to change the appearance (dark mode) in user settings
|
- Added support to change the appearance (dark mode) in user settings
|
||||||
- Added the total amount chart to the investment timeline
|
- Added the total amount chart to the investment timeline
|
||||||
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
|
- Set up the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -1352,7 +1682,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
|
||||||
@ -1681,7 +2011,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
|
||||||
@ -3336,7 +3666,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
|
||||||
@ -3802,7 +4132,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
|
||||||
|
|
||||||
@ -4116,7 +4446,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
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
|||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
COPY ./apps apps
|
COPY ./apps apps
|
||||||
|
|
||||||
RUN yarn build:all
|
RUN yarn build:production
|
||||||
|
|
||||||
# Prepare the dist image with additional node_modules
|
# Prepare the dist image with additional node_modules
|
||||||
WORKDIR /ghostfolio/dist/apps/api
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
@ -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:prod" ]
|
CMD [ "yarn", "start:production" ]
|
||||||
|
@ -153,7 +153,6 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `yarn build:dev` to build the source code including the assets
|
|
||||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `yarn database:setup` to initialize the database schema
|
1. Run `yarn database:setup` to initialize the database schema
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
@ -263,7 +262,9 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
|
|
||||||
## Community Projects
|
## Community Projects
|
||||||
|
|
||||||
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||||
|
|
||||||
|
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
"outputs": ["{options.outputPath}"]
|
"outputs": ["{options.outputPath}"]
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nx/node:node",
|
"executor": "@nx/js:node",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "api:build"
|
"buildTarget": "api:build"
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.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 { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
|
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
@ -15,6 +16,7 @@ import { AccountService } from './account.service';
|
|||||||
controllers: [AccountController],
|
controllers: [AccountController],
|
||||||
exports: [AccountService],
|
exports: [AccountService],
|
||||||
imports: [
|
imports: [
|
||||||
|
AccountBalanceModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountService {
|
export class AccountService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async account(
|
public async account({
|
||||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
id_userId
|
||||||
): Promise<Account | null> {
|
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||||
return this.prismaService.account.findUnique({
|
const { id, userId } = id_userId;
|
||||||
where: accountWhereUniqueInput
|
|
||||||
|
const [account] = await this.accounts({
|
||||||
|
where: { id, userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async accountWithOrders(
|
public async accountWithOrders(
|
||||||
@ -50,9 +56,11 @@ export class AccountService {
|
|||||||
Platform?: Platform;
|
Platform?: Platform;
|
||||||
})[]
|
})[]
|
||||||
> {
|
> {
|
||||||
const { include, skip, take, cursor, where, orderBy } = params;
|
const { include = {}, skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
return this.prismaService.account.findMany({
|
include.balances = { orderBy: { date: 'desc' }, take: 1 };
|
||||||
|
|
||||||
|
const accounts = await this.prismaService.account.findMany({
|
||||||
cursor,
|
cursor,
|
||||||
include,
|
include,
|
||||||
orderBy,
|
orderBy,
|
||||||
@ -60,15 +68,36 @@ export class AccountService {
|
|||||||
take,
|
take,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return accounts.map((account) => {
|
||||||
|
account = { ...account, balance: account.balances[0]?.value ?? 0 };
|
||||||
|
|
||||||
|
delete account.balances;
|
||||||
|
|
||||||
|
return account;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createAccount(
|
public async createAccount(
|
||||||
data: Prisma.AccountCreateInput,
|
data: Prisma.AccountCreateInput,
|
||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
return this.prismaService.account.create({
|
const account = await this.prismaService.account.create({
|
||||||
data
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.prismaService.accountBalance.create({
|
||||||
|
data: {
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: { id: account.id, userId: aUserId }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: data.balance
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAccount(
|
public async deleteAccount(
|
||||||
@ -167,6 +196,18 @@ export class AccountService {
|
|||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
|
|
||||||
|
await this.prismaService.accountBalance.create({
|
||||||
|
data: {
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: where.id_userId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: <number>data.balance
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return this.prismaService.account.update({
|
return this.prismaService.account.update({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
@ -202,16 +243,17 @@ export class AccountService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (amountInCurrencyOfAccount) {
|
if (amountInCurrencyOfAccount) {
|
||||||
await this.prismaService.account.update({
|
await this.accountBalanceService.createAccountBalance({
|
||||||
data: {
|
date,
|
||||||
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
Account: {
|
||||||
},
|
connect: {
|
||||||
where: {
|
id_userId: {
|
||||||
id_userId: {
|
userId,
|
||||||
userId,
|
id: accountId
|
||||||
id: accountId
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -13,7 +16,10 @@ import {
|
|||||||
Filter
|
Filter
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type {
|
||||||
|
MarketDataPreset,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -26,12 +32,13 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource, MarketData } 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';
|
||||||
@ -110,7 +117,7 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: `${dataSource}-${symbol}`
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -146,7 +153,7 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: `${dataSource}-${symbol}`
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -179,7 +186,7 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: `${dataSource}-${symbol}`
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -226,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(
|
||||||
@ -245,7 +252,12 @@ export class AdminController {
|
|||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
|
@Query('presetId') presetId?: MarketDataPreset,
|
||||||
|
@Query('skip') skip?: number,
|
||||||
|
@Query('sortColumn') sortColumn?: string,
|
||||||
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
|
@Query('take') take?: number
|
||||||
): Promise<AdminMarketData> {
|
): Promise<AdminMarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
@ -270,7 +282,14 @@ export class AdminController {
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
return this.adminService.getMarketData(filters);
|
return this.adminService.getMarketData({
|
||||||
|
filters,
|
||||||
|
presetId,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
|
take: isNaN(take) ? undefined : take
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@ -314,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' },
|
||||||
@ -328,6 +347,28 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async addProfileData(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<SymbolProfile | never> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteProfileData(
|
public async deleteProfileData(
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
PROPERTY_CURRENCIES
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -14,25 +18,55 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
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 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 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({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<SymbolProfile | never> {
|
||||||
|
try {
|
||||||
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfiles[symbol]?.currency) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile not found for ${symbol} (${dataSource})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.symbolProfileService.add(
|
||||||
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile of ${symbol} (${dataSource}) already exists`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
@ -45,15 +79,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
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@ -65,9 +99,34 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
public async getMarketData({
|
||||||
|
filters,
|
||||||
|
presetId,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
skip,
|
||||||
|
take = Number.MAX_SAFE_INTEGER
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
presetId?: MarketDataPreset;
|
||||||
|
skip?: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
take?: number;
|
||||||
|
}): Promise<AdminMarketData> {
|
||||||
|
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
|
||||||
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
|
if (presetId === 'CURRENCIES') {
|
||||||
|
return this.getMarketDataForCurrencies();
|
||||||
|
} else if (
|
||||||
|
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||||
|
presetId === 'ETF_WITHOUT_SECTORS'
|
||||||
|
) {
|
||||||
|
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||||
|
}
|
||||||
|
|
||||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
filters,
|
filters,
|
||||||
(filter) => {
|
(filter) => {
|
||||||
@ -75,42 +134,33 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.groupBy({
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
|
||||||
|
|
||||||
if (filtersByAssetSubClass) {
|
if (filtersByAssetSubClass) {
|
||||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
} else {
|
|
||||||
currencyPairsToGather = this.exchangeRateDataService
|
|
||||||
.getCurrencyPairs()
|
|
||||||
.map(({ dataSource, symbol }) => {
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketData.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === dataSource &&
|
|
||||||
marketDataItem.symbol === symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
marketDataItemCount,
|
|
||||||
symbol,
|
|
||||||
assetClass: 'CASH',
|
|
||||||
countriesCount: 0,
|
|
||||||
sectorsCount: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
if (sortColumn) {
|
||||||
await this.prismaService.symbolProfile.findMany({
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
|
||||||
|
if (sortColumn === 'activitiesCount') {
|
||||||
|
orderBy = {
|
||||||
|
Order: {
|
||||||
|
_count: sortDirection
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let [assetProfiles, count] = await Promise.all([
|
||||||
|
this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
where,
|
where,
|
||||||
orderBy: [{ symbol: 'asc' }],
|
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Order: true }
|
select: { Order: true }
|
||||||
@ -129,38 +179,64 @@ export class AdminService {
|
|||||||
sectors: true,
|
sectors: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
).map((symbolProfile) => {
|
this.prismaService.symbolProfile.count({ where })
|
||||||
const countriesCount = symbolProfile.countries
|
]);
|
||||||
? Object.keys(symbolProfile.countries).length
|
|
||||||
: 0;
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketData.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
|
||||||
marketDataItem.symbol === symbolProfile.symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
const sectorsCount = symbolProfile.sectors
|
|
||||||
? Object.keys(symbolProfile.sectors).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
let marketData = assetProfiles.map(
|
||||||
countriesCount,
|
({
|
||||||
marketDataItemCount,
|
_count,
|
||||||
sectorsCount,
|
assetClass,
|
||||||
activitiesCount: symbolProfile._count.Order,
|
assetSubClass,
|
||||||
assetClass: symbolProfile.assetClass,
|
comment,
|
||||||
assetSubClass: symbolProfile.assetSubClass,
|
countries,
|
||||||
comment: symbolProfile.comment,
|
dataSource,
|
||||||
dataSource: symbolProfile.dataSource,
|
Order,
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
sectors,
|
||||||
symbol: symbolProfile.symbol
|
symbol
|
||||||
};
|
}) => {
|
||||||
});
|
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketDataItems.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
comment,
|
||||||
|
countriesCount,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
marketDataItemCount,
|
||||||
|
sectorsCount,
|
||||||
|
activitiesCount: _count.Order,
|
||||||
|
date: Order?.[0]?.date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (presetId) {
|
||||||
|
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
||||||
|
marketData = marketData.filter(({ countriesCount }) => {
|
||||||
|
return countriesCount === 0;
|
||||||
|
});
|
||||||
|
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
||||||
|
marketData = marketData.filter(({ sectorsCount }) => {
|
||||||
|
return sectorsCount === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
count = marketData.length;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
count,
|
||||||
|
marketData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,12 +274,14 @@ export class AdminService {
|
|||||||
public async patchAssetProfileData({
|
public async patchAssetProfileData({
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
await this.symbolProfileService.updateSymbolProfile({
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
});
|
});
|
||||||
@ -234,6 +312,36 @@ export class AdminService {
|
|||||||
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'
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
@ -5,6 +6,10 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
scraperConfiguration?: Prisma.InputJsonObject;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
symbolMapping?: {
|
symbolMapping?: {
|
||||||
|
@ -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,6 +36,7 @@ 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 { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
@ -70,19 +75,30 @@ import { UserModule } from './user/user.module';
|
|||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
serveStaticOptions: {
|
exclude: ['/api*', '/sitemap.xml'],
|
||||||
/*etag: false // Disable etag header to fix PWA
|
|
||||||
setHeaders: (res, path) => {
|
|
||||||
if (path.includes('ngsw.json')) {
|
|
||||||
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
|
|
||||||
// https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache
|
|
||||||
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
},
|
|
||||||
rootPath: join(__dirname, '..', 'client'),
|
rootPath: join(__dirname, '..', 'client'),
|
||||||
exclude: ['/api*']
|
serveStaticOptions: {
|
||||||
|
setHeaders: (res) => {
|
||||||
|
if (res.req?.path === '/') {
|
||||||
|
let languageCode = DEFAULT_LANGUAGE_CODE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const code = res.req.headers['accept-language']
|
||||||
|
.split(',')[0]
|
||||||
|
.split('-')[0];
|
||||||
|
|
||||||
|
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
|
||||||
|
languageCode = code;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
res.set('Location', `/${languageCode}`);
|
||||||
|
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
SitemapModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
@ -91,10 +107,4 @@ import { UserModule } from './user/user.module';
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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(
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,11 +66,11 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
const promises: Promise<number>[] = [];
|
const promises: Promise<number>[] = [];
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes(
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
})
|
})
|
||||||
);
|
});
|
||||||
|
|
||||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||||
|
@ -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,
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController } from './export.controller';
|
||||||
@ -10,10 +11,11 @@ import { ExportService } from './export.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AccountModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
PrismaModule,
|
OrderModule,
|
||||||
RedisCacheModule
|
RedisCacheModule
|
||||||
],
|
],
|
||||||
controllers: [ExportController],
|
controllers: [ExportController],
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|
||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(
|
||||||
|
private readonly accountService: AccountService,
|
||||||
|
private readonly orderService: OrderService
|
||||||
|
) {}
|
||||||
|
|
||||||
public async export({
|
public async export({
|
||||||
activityIds,
|
activityIds,
|
||||||
@ -14,36 +18,40 @@ export class ExportService {
|
|||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
const accounts = await this.prismaService.account.findMany({
|
const accounts = (
|
||||||
orderBy: {
|
await this.accountService.accounts({
|
||||||
name: 'asc'
|
orderBy: {
|
||||||
},
|
name: 'asc'
|
||||||
select: {
|
},
|
||||||
accountType: true,
|
where: { userId }
|
||||||
balance: true,
|
})
|
||||||
comment: true,
|
).map(
|
||||||
currency: true,
|
({
|
||||||
id: true,
|
accountType,
|
||||||
isExcluded: true,
|
balance,
|
||||||
name: true,
|
comment,
|
||||||
platformId: true
|
currency,
|
||||||
},
|
id,
|
||||||
where: { userId }
|
isExcluded,
|
||||||
});
|
name,
|
||||||
|
platformId
|
||||||
|
}) => {
|
||||||
|
return {
|
||||||
|
accountType,
|
||||||
|
balance,
|
||||||
|
comment,
|
||||||
|
currency,
|
||||||
|
id,
|
||||||
|
isExcluded,
|
||||||
|
name,
|
||||||
|
platformId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let activities = await this.prismaService.order.findMany({
|
let activities = await this.orderService.orders({
|
||||||
|
include: { SymbolProfile: true },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
|
||||||
accountId: true,
|
|
||||||
comment: true,
|
|
||||||
date: true,
|
|
||||||
fee: true,
|
|
||||||
id: true,
|
|
||||||
quantity: true,
|
|
||||||
SymbolProfile: true,
|
|
||||||
type: true,
|
|
||||||
unitPrice: true
|
|
||||||
},
|
|
||||||
where: { userId }
|
where: { userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,232 +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 } 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 = '';
|
|
||||||
|
|
||||||
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'
|
|
||||||
);
|
|
||||||
} 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 1’000 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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
request.path.startsWith('/api/') ||
|
|
||||||
this.isFileRequest(request.url) ||
|
|
||||||
!environment.production
|
|
||||||
) {
|
|
||||||
// Skip
|
|
||||||
next();
|
|
||||||
} 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, ETF’s 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.includes('auth/ey')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filename.split('.').pop() !== filename;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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(
|
||||||
|
@ -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 {}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -8,10 +8,15 @@ import {
|
|||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
getAssetProfileIdentifier,
|
||||||
|
parseDate
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
AccountWithPlatform,
|
AccountWithPlatform,
|
||||||
@ -20,13 +25,15 @@ 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 { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
@ -202,7 +209,7 @@ export class ImportService {
|
|||||||
|
|
||||||
for (const activity of activitiesDto) {
|
for (const activity of activitiesDto) {
|
||||||
if (!activity.dataSource) {
|
if (!activity.dataSource) {
|
||||||
if (activity.type === 'ITEM') {
|
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||||
activity.dataSource = DataSource.MANUAL;
|
activity.dataSource = DataSource.MANUAL;
|
||||||
} else {
|
} else {
|
||||||
activity.dataSource =
|
activity.dataSource =
|
||||||
@ -220,8 +227,7 @@ export class ImportService {
|
|||||||
|
|
||||||
const assetProfiles = await this.validateActivities({
|
const assetProfiles = await this.validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport
|
||||||
userId
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
@ -243,17 +249,47 @@ 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: assetProfile,
|
fee,
|
||||||
type,
|
quantity,
|
||||||
unitPrice
|
SymbolProfile,
|
||||||
} of activitiesExtendedWithErrors) {
|
type,
|
||||||
|
unitPrice
|
||||||
|
}
|
||||||
|
] of activitiesExtendedWithErrors.entries()) {
|
||||||
|
const assetProfile = assetProfiles[
|
||||||
|
getAssetProfileIdentifier({
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
})
|
||||||
|
] ?? {
|
||||||
|
currency: SymbolProfile.currency,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
countries,
|
||||||
|
createdAt,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
id,
|
||||||
|
isin,
|
||||||
|
name,
|
||||||
|
scraperConfiguration,
|
||||||
|
sectors,
|
||||||
|
symbol,
|
||||||
|
symbolMapping,
|
||||||
|
url,
|
||||||
|
updatedAt
|
||||||
|
} = assetProfile;
|
||||||
const validatedAccount = accounts.find(({ id }) => {
|
const validatedAccount = accounts.find(({ id }) => {
|
||||||
return id === accountId;
|
return id === accountId;
|
||||||
});
|
});
|
||||||
@ -264,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,
|
||||||
@ -279,23 +344,22 @@ export class ImportService {
|
|||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
isDraft: isAfter(date, endOfToday()),
|
isDraft: isAfter(date, endOfToday()),
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
assetClass: assetProfile.assetClass,
|
assetClass,
|
||||||
assetSubClass: assetProfile.assetSubClass,
|
assetSubClass,
|
||||||
comment: assetProfile.comment,
|
countries,
|
||||||
countries: assetProfile.countries,
|
createdAt,
|
||||||
createdAt: assetProfile.createdAt,
|
currency,
|
||||||
currency: assetProfile.currency,
|
dataSource,
|
||||||
dataSource: assetProfile.dataSource,
|
id,
|
||||||
id: assetProfile.id,
|
isin,
|
||||||
isin: assetProfile.isin,
|
name,
|
||||||
name: assetProfile.name,
|
scraperConfiguration,
|
||||||
scraperConfiguration: assetProfile.scraperConfiguration,
|
sectors,
|
||||||
sectors: assetProfile.sectors,
|
symbol,
|
||||||
symbol: assetProfile.currency,
|
symbolMapping,
|
||||||
symbolMapping: assetProfile.symbolMapping,
|
updatedAt,
|
||||||
updatedAt: assetProfile.updatedAt,
|
url,
|
||||||
url: assetProfile.url,
|
comment: assetProfile.comment
|
||||||
...assetProfiles[assetProfile.symbol]
|
|
||||||
},
|
},
|
||||||
Account: validatedAccount,
|
Account: validatedAccount,
|
||||||
symbolProfileId: undefined,
|
symbolProfileId: undefined,
|
||||||
@ -318,14 +382,14 @@ export class ImportService {
|
|||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
currency: assetProfile.currency,
|
currency,
|
||||||
dataSource: assetProfile.dataSource,
|
dataSource,
|
||||||
symbol: assetProfile.symbol
|
symbol
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
dataSource: assetProfile.dataSource,
|
dataSource,
|
||||||
symbol: assetProfile.symbol
|
symbol
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -337,24 +401,49 @@ export class ImportService {
|
|||||||
|
|
||||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
|
|
||||||
//@ts-ignore
|
|
||||||
activities.push({
|
activities.push({
|
||||||
...order,
|
...order,
|
||||||
error,
|
error,
|
||||||
value,
|
value,
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
fee,
|
fee,
|
||||||
assetProfile.currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
|
//@ts-ignore
|
||||||
|
SymbolProfile: assetProfile,
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
assetProfile.currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activities.sort((activity1, activity2) => {
|
||||||
|
return Number(activity1.date) - Number(activity2.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDryRun) {
|
||||||
|
// Gather symbol data in the background, if not dry run
|
||||||
|
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
|
||||||
|
return getAssetProfileIdentifier({
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherSymbols(
|
||||||
|
uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return activities;
|
return activities;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -446,25 +535,30 @@ export class ImportService {
|
|||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport
|
||||||
userId
|
|
||||||
}: {
|
}: {
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
|
||||||
}) {
|
}) {
|
||||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetProfiles: {
|
const assetProfiles: {
|
||||||
[symbol: string]: Partial<SymbolProfile>;
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
|
const uniqueActivitiesDto = uniqBy(
|
||||||
|
activitiesDto,
|
||||||
|
({ dataSource, symbol }) => {
|
||||||
|
return getAssetProfileIdentifier({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, symbol }
|
{ currency, dataSource, symbol }
|
||||||
] of activitiesDto.entries()) {
|
] of uniqueActivitiesDto.entries()) {
|
||||||
if (dataSource !== 'MANUAL') {
|
if (dataSource !== 'MANUAL') {
|
||||||
const assetProfile = (
|
const assetProfile = (
|
||||||
await this.dataProviderService.getAssetProfiles([
|
await this.dataProviderService.getAssetProfiles([
|
||||||
@ -472,19 +566,26 @@ 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}"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
assetProfiles[symbol] = assetProfile;
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||||
|
assetProfile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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]
|
||||||
})
|
})
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
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,
|
||||||
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 +31,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 +45,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 +140,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,17 +168,13 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countDockerHubPulls(): Promise<number> {
|
private async countDockerHubPulls(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const { pull_count } = await got(
|
||||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||||
'GET',
|
|
||||||
'json',
|
|
||||||
200,
|
|
||||||
{
|
{
|
||||||
'User-Agent': 'request'
|
headers: { 'User-Agent': 'request' }
|
||||||
}
|
}
|
||||||
);
|
).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');
|
||||||
@ -193,16 +185,9 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubContributors(): Promise<number> {
|
private async countGitHubContributors(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const { body } = await got('https://github.com/ghostfolio/ghostfolio');
|
||||||
'https://github.com/ghostfolio/ghostfolio',
|
|
||||||
'GET',
|
|
||||||
'string',
|
|
||||||
200,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const html = await get();
|
const $ = cheerio.load(body);
|
||||||
const $ = cheerio.load(html);
|
|
||||||
|
|
||||||
return extractNumberFromString(
|
return extractNumberFromString(
|
||||||
$(
|
$(
|
||||||
@ -218,17 +203,13 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubStargazers(): Promise<number> {
|
private async countGitHubStargazers(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const { stargazers_count } = await got(
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||||
'GET',
|
|
||||||
'json',
|
|
||||||
200,
|
|
||||||
{
|
{
|
||||||
'User-Agent': 'request'
|
headers: { 'User-Agent': 'request' }
|
||||||
}
|
}
|
||||||
);
|
).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');
|
||||||
@ -238,10 +219,7 @@ export class InfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +310,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,22 +323,21 @@ export class InfoService {
|
|||||||
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||||
)) as string;
|
)) as string;
|
||||||
|
|
||||||
const get = bent(
|
const { data } = await got(
|
||||||
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
`https://betteruptime.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(
|
|
||||||
'BETTER_UPTIME_API_KEY'
|
|
||||||
)}`
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data } = await get();
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.configurationService.get(
|
||||||
|
'BETTER_UPTIME_API_KEY'
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).json<any>();
|
||||||
|
|
||||||
return data.attributes.availability / 100;
|
return data.attributes.availability / 100;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService');
|
||||||
|
@ -2,7 +2,7 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/sy
|
|||||||
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 +41,11 @@ export class LogoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getBuffer(aUrl: string) {
|
private getBuffer(aUrl: string) {
|
||||||
const get = bent(
|
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' }
|
||||||
}
|
}
|
||||||
);
|
).buffer();
|
||||||
return get();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -36,6 +37,7 @@ import { UpdateOrderDto } from './update-order.dto';
|
|||||||
export class OrderController {
|
export class OrderController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
@ -123,7 +125,7 @@ export class OrderController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.orderService.createOrder({
|
const order = await this.orderService.createOrder({
|
||||||
...data,
|
...data,
|
||||||
date: parseISO(data.date),
|
date: parseISO(data.date),
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
@ -144,6 +146,19 @@ export class OrderController {
|
|||||||
User: { connect: { id: this.request.user.id } },
|
User: { connect: { id: this.request.user.id } },
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!order.isDraft) {
|
||||||
|
// Gather symbol data in the background, if not draft
|
||||||
|
this.dataGatheringService.gatherSymbols([
|
||||||
|
{
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
date: order.date,
|
||||||
|
symbol: data.symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.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 { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
|
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.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';
|
||||||
@ -31,6 +32,6 @@ import { OrderService } from './order.service';
|
|||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [AccountService, OrderService]
|
providers: [AccountBalanceService, AccountService, OrderService]
|
||||||
})
|
})
|
||||||
export class OrderModule {}
|
export class OrderModule {}
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
@ -96,7 +97,7 @@ 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') {
|
if (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;
|
||||||
@ -117,7 +118,7 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue({
|
this.dataGatheringService.addJobToQueue({
|
||||||
data: {
|
data: {
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
@ -125,23 +126,13 @@ export class OrderService {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
|
jobId: getAssetProfileIdentifier({
|
||||||
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
|
||||||
|
|
||||||
if (!isDraft) {
|
|
||||||
// Gather symbol data of order in the background, if not draft
|
|
||||||
this.dataGatheringService.gatherSymbols([
|
|
||||||
{
|
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
|
||||||
date: <Date>data.date,
|
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
delete data.assetSubClass;
|
delete data.assetSubClass;
|
||||||
@ -159,6 +150,11 @@ export class OrderService {
|
|||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
|
const isDraft =
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
? false
|
||||||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
const order = await this.prismaService.order.create({
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
...orderData,
|
...orderData,
|
||||||
@ -201,7 +197,7 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.type === 'ITEM') {
|
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -320,7 +316,11 @@ export class OrderService {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
return (
|
||||||
|
withExcludedAccounts ||
|
||||||
|
!order.Account ||
|
||||||
|
order.Account?.isExcluded === false
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map((order) => {
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
@ -368,7 +368,7 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
|
@ -98,13 +98,13 @@ describe('CurrentRateService', () => {
|
|||||||
[],
|
[],
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
propertyService
|
propertyService,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
marketDataService = new MarketDataService(null);
|
marketDataService = new MarketDataService(null);
|
||||||
|
@ -38,7 +38,7 @@ export class CurrentRateService {
|
|||||||
if (includeToday) {
|
if (includeToday) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.dataProviderService
|
this.dataProviderService
|
||||||
.getQuotes(dataGatheringItems)
|
.getQuotes({ items: dataGatheringItems })
|
||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result: GetValueObject[] = [];
|
const result: GetValueObject[] = [];
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface PortfolioOrder {
|
export interface PortfolioOrder {
|
||||||
@ -9,6 +9,7 @@ export interface PortfolioOrder {
|
|||||||
name: string;
|
name: string;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
tags?: Tag[];
|
||||||
type: TypeOfOrder;
|
type: TypeOfOrder;
|
||||||
unitPrice: Big;
|
unitPrice: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource, Tag } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface TransactionPointSymbol {
|
export interface TransactionPointSymbol {
|
||||||
@ -9,5 +9,6 @@ export interface TransactionPointSymbol {
|
|||||||
investment: Big;
|
investment: Big;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
tags?: Tag[];
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
}
|
}
|
||||||
|
@ -114,6 +114,7 @@ export class PortfolioCalculator {
|
|||||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||||
quantity: newQuantity,
|
quantity: newQuantity,
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
|
tags: order.tags,
|
||||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -125,6 +126,7 @@ export class PortfolioCalculator {
|
|||||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||||
quantity: order.quantity.mul(factor),
|
quantity: order.quantity.mul(factor),
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
|
tags: order.tags,
|
||||||
transactionCount: 1
|
transactionCount: 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -492,6 +494,7 @@ export class PortfolioCalculator {
|
|||||||
: null,
|
: null,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
|
tags: item.tags,
|
||||||
transactionCount: item.transactionCount
|
transactionCount: item.transactionCount
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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'))
|
||||||
@ -134,7 +133,7 @@ export class PortfolioController {
|
|||||||
portfolioPosition.netPerformance = null;
|
portfolioPosition.netPerformance = null;
|
||||||
portfolioPosition.quantity = null;
|
portfolioPosition.quantity = null;
|
||||||
portfolioPosition.valueInPercentage =
|
portfolioPosition.valueInPercentage =
|
||||||
portfolioPosition.value / totalValue;
|
portfolioPosition.valueInBaseCurrency / totalValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
|
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
|
||||||
@ -161,9 +160,12 @@ export class PortfolioController {
|
|||||||
'emergencyFund',
|
'emergencyFund',
|
||||||
'excludedAccountsAndActivities',
|
'excludedAccountsAndActivities',
|
||||||
'fees',
|
'fees',
|
||||||
|
'fireWealth',
|
||||||
'items',
|
'items',
|
||||||
|
'liabilities',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
|
'totalInvestment',
|
||||||
'totalSell'
|
'totalSell'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -176,6 +178,9 @@ export class PortfolioController {
|
|||||||
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,
|
||||||
|
marketsAdvanced: hasDetails
|
||||||
|
? portfolioPosition.marketsAdvanced
|
||||||
|
: undefined,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : []
|
sectors: hasDetails ? portfolioPosition.sectors : []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -436,15 +441,15 @@ 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);
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
allocationInPercentage: portfolioPosition.value / totalValue,
|
allocationInPercentage:
|
||||||
|
portfolioPosition.valueInBaseCurrency / totalValue,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
dataSource: portfolioPosition.dataSource,
|
dataSource: portfolioPosition.dataSource,
|
||||||
@ -455,7 +460,7 @@ export class PortfolioController {
|
|||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
symbol: portfolioPosition.symbol,
|
symbol: portfolioPosition.symbol,
|
||||||
url: portfolioPosition.url,
|
url: portfolioPosition.url,
|
||||||
valueInPercentage: portfolioPosition.value / totalValue
|
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
|
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.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';
|
||||||
@ -36,6 +37,7 @@ import { RulesService } from './rules.service';
|
|||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
AccountBalanceService,
|
||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
|
@ -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
|
||||||
@ -42,7 +42,6 @@ import type {
|
|||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
DateRange,
|
DateRange,
|
||||||
GroupBy,
|
GroupBy,
|
||||||
Market,
|
|
||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
RequestWithUser,
|
RequestWithUser,
|
||||||
UserWithSettings
|
UserWithSettings
|
||||||
@ -84,16 +83,15 @@ import {
|
|||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
import { RulesService } from './rules.service';
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
|
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
|
||||||
const developedMarkets = require('../../assets/countries/developed-markets.json');
|
const developedMarkets = require('../../assets/countries/developed-markets.json');
|
||||||
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
|
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
|
||||||
|
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,
|
||||||
@ -103,9 +101,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,
|
||||||
@ -469,9 +465,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,
|
||||||
@ -504,15 +499,17 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataGatheringItems = currentPositions.positions.map((position) => {
|
const dataGatheringItems = currentPositions.positions.map(
|
||||||
return {
|
({ dataSource, symbol }) => {
|
||||||
dataSource: position.dataSource,
|
return {
|
||||||
symbol: position.symbol
|
dataSource,
|
||||||
};
|
symbol
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.getQuotes(dataGatheringItems),
|
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
|
||||||
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
|
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -536,30 +533,79 @@ export class PortfolioService {
|
|||||||
const symbolProfile = symbolProfileMap[item.symbol];
|
const symbolProfile = symbolProfileMap[item.symbol];
|
||||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||||
|
|
||||||
const markets: { [key in Market]: number } = {
|
const markets: PortfolioPosition['markets'] = {
|
||||||
|
[UNKNOWN_KEY]: 0,
|
||||||
developedMarkets: 0,
|
developedMarkets: 0,
|
||||||
emergingMarkets: 0,
|
emergingMarkets: 0,
|
||||||
otherMarkets: 0
|
otherMarkets: 0
|
||||||
};
|
};
|
||||||
|
const marketsAdvanced: PortfolioPosition['marketsAdvanced'] = {
|
||||||
|
[UNKNOWN_KEY]: 0,
|
||||||
|
asiaPacific: 0,
|
||||||
|
emergingMarkets: 0,
|
||||||
|
europe: 0,
|
||||||
|
japan: 0,
|
||||||
|
northAmerica: 0,
|
||||||
|
otherMarkets: 0
|
||||||
|
};
|
||||||
|
|
||||||
for (const country of symbolProfile.countries) {
|
if (symbolProfile.countries.length > 0) {
|
||||||
if (developedMarkets.includes(country.code)) {
|
for (const country of symbolProfile.countries) {
|
||||||
markets.developedMarkets = new Big(markets.developedMarkets)
|
if (developedMarkets.includes(country.code)) {
|
||||||
.plus(country.weight)
|
markets.developedMarkets = new Big(markets.developedMarkets)
|
||||||
.toNumber();
|
.plus(country.weight)
|
||||||
} else if (emergingMarkets.includes(country.code)) {
|
.toNumber();
|
||||||
markets.emergingMarkets = new Big(markets.emergingMarkets)
|
} else if (emergingMarkets.includes(country.code)) {
|
||||||
.plus(country.weight)
|
markets.emergingMarkets = new Big(markets.emergingMarkets)
|
||||||
.toNumber();
|
.plus(country.weight)
|
||||||
} else {
|
.toNumber();
|
||||||
markets.otherMarkets = new Big(markets.otherMarkets)
|
} else {
|
||||||
.plus(country.weight)
|
markets.otherMarkets = new Big(markets.otherMarkets)
|
||||||
.toNumber();
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (country.code === 'JP') {
|
||||||
|
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else if (country.code === 'CA' || country.code === 'US') {
|
||||||
|
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else if (asiaPacificMarkets.includes(country.code)) {
|
||||||
|
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else if (emergingMarkets.includes(country.code)) {
|
||||||
|
marketsAdvanced.emergingMarkets = new Big(
|
||||||
|
marketsAdvanced.emergingMarkets
|
||||||
|
)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else if (europeMarkets.includes(country.code)) {
|
||||||
|
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else {
|
||||||
|
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
|
||||||
|
.plus(value)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
|
||||||
|
.plus(value)
|
||||||
|
.toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
holdings[item.symbol] = {
|
holdings[item.symbol] = {
|
||||||
markets,
|
markets,
|
||||||
|
marketsAdvanced,
|
||||||
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
|
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
|
||||||
? 0
|
? 0
|
||||||
: value.div(filteredValueInBaseCurrency).toNumber(),
|
: value.div(filteredValueInBaseCurrency).toNumber(),
|
||||||
@ -581,9 +627,10 @@ export class PortfolioService {
|
|||||||
quantity: item.quantity.toNumber(),
|
quantity: item.quantity.toNumber(),
|
||||||
sectors: symbolProfile.sectors,
|
sectors: symbolProfile.sectors,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
|
tags: item.tags,
|
||||||
transactionCount: item.transactionCount,
|
transactionCount: item.transactionCount,
|
||||||
url: symbolProfile.url,
|
url: symbolProfile.url,
|
||||||
value: value.toNumber()
|
valueInBaseCurrency: value.toNumber()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -626,7 +673,7 @@ export class PortfolioService {
|
|||||||
const emergencyFundInCash = emergencyFund
|
const emergencyFundInCash = emergencyFund
|
||||||
.minus(
|
.minus(
|
||||||
this.getEmergencyFundPositionsValueInBaseCurrency({
|
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||||
activities: orders
|
holdings
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
@ -643,7 +690,7 @@ export class PortfolioService {
|
|||||||
holdings[userCurrency] = {
|
holdings[userCurrency] = {
|
||||||
...emergencyFundCashPositions[userCurrency],
|
...emergencyFundCashPositions[userCurrency],
|
||||||
investment: emergencyFundInCash,
|
investment: emergencyFundInCash,
|
||||||
value: emergencyFundInCash
|
valueInBaseCurrency: emergencyFundInCash
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -654,7 +701,7 @@ export class PortfolioService {
|
|||||||
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
|
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
|
||||||
emergencyFundPositionsValueInBaseCurrency:
|
emergencyFundPositionsValueInBaseCurrency:
|
||||||
this.getEmergencyFundPositionsValueInBaseCurrency({
|
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||||
activities: orders
|
holdings
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -740,6 +787,7 @@ export class PortfolioService {
|
|||||||
name: order.SymbolProfile?.name,
|
name: order.SymbolProfile?.name,
|
||||||
quantity: new Big(order.quantity),
|
quantity: new Big(order.quantity),
|
||||||
symbol: order.SymbolProfile.symbol,
|
symbol: order.SymbolProfile.symbol,
|
||||||
|
tags: order.tags,
|
||||||
type: order.type,
|
type: order.type,
|
||||||
unitPrice: new Big(order.unitPrice)
|
unitPrice: new Big(order.unitPrice)
|
||||||
}));
|
}));
|
||||||
@ -756,9 +804,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
|
||||||
@ -897,9 +944,9 @@ export class PortfolioService {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const currentData = await this.dataProviderService.getQuotes([
|
const currentData = await this.dataProviderService.getQuotes({
|
||||||
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
|
items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }]
|
||||||
]);
|
});
|
||||||
const marketPrice = currentData[aSymbol]?.marketPrice;
|
const marketPrice = currentData[aSymbol]?.marketPrice;
|
||||||
|
|
||||||
let historicalData = await this.dataProviderService.getHistorical(
|
let historicalData = await this.dataProviderService.getHistorical(
|
||||||
@ -992,23 +1039,22 @@ 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)
|
||||||
);
|
);
|
||||||
|
|
||||||
const dataGatheringItem = positions.map((position) => {
|
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
dataSource: position.dataSource,
|
dataSource,
|
||||||
symbol: position.symbol
|
symbol
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.getQuotes(dataGatheringItem),
|
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
|
||||||
this.symbolProfileService.getSymbolProfiles(
|
this.symbolProfileService.getSymbolProfiles(
|
||||||
positions.map(({ dataSource, symbol }) => {
|
positions.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
@ -1184,9 +1230,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)
|
||||||
@ -1276,7 +1321,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
if (cashPositions[account.currency]) {
|
if (cashPositions[account.currency]) {
|
||||||
cashPositions[account.currency].investment += convertedBalance;
|
cashPositions[account.currency].investment += convertedBalance;
|
||||||
cashPositions[account.currency].value += convertedBalance;
|
cashPositions[account.currency].valueInBaseCurrency += convertedBalance;
|
||||||
} else {
|
} else {
|
||||||
cashPositions[account.currency] = this.getInitialCashPosition({
|
cashPositions[account.currency] = this.getInitialCashPosition({
|
||||||
balance: convertedBalance,
|
balance: convertedBalance,
|
||||||
@ -1288,7 +1333,9 @@ export class PortfolioService {
|
|||||||
for (const symbol of Object.keys(cashPositions)) {
|
for (const symbol of Object.keys(cashPositions)) {
|
||||||
// Calculate allocations for each currency
|
// Calculate allocations for each currency
|
||||||
cashPositions[symbol].allocationInPercentage = value.gt(0)
|
cashPositions[symbol].allocationInPercentage = value.gt(0)
|
||||||
? new Big(cashPositions[symbol].value).div(value).toNumber()
|
? new Big(cashPositions[symbol].valueInBaseCurrency)
|
||||||
|
.div(value)
|
||||||
|
.toNumber()
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1302,12 +1349,11 @@ export class PortfolioService {
|
|||||||
}: {
|
}: {
|
||||||
activities: OrderWithAccount[];
|
activities: OrderWithAccount[];
|
||||||
date?: Date;
|
date?: Date;
|
||||||
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
}) {
|
}) {
|
||||||
return activities
|
return activities
|
||||||
.filter((activity) => {
|
.filter((activity) => {
|
||||||
// Filter out all activities before given date and type dividend
|
// Filter out all activities before given date (drafts) and type dividend
|
||||||
return (
|
return (
|
||||||
isBefore(date, new Date(activity.date)) &&
|
isBefore(date, new Date(activity.date)) &&
|
||||||
activity.type === TypeOfOrder.DIVIDEND
|
activity.type === TypeOfOrder.DIVIDEND
|
||||||
@ -1389,13 +1435,13 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getEmergencyFundPositionsValueInBaseCurrency({
|
private getEmergencyFundPositionsValueInBaseCurrency({
|
||||||
activities
|
holdings
|
||||||
}: {
|
}: {
|
||||||
activities: Activity[];
|
holdings: PortfolioDetails['holdings'];
|
||||||
}) {
|
}) {
|
||||||
const emergencyFundOrders = activities.filter((activity) => {
|
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
|
||||||
return (
|
return (
|
||||||
activity.tags?.some(({ id }) => {
|
tags?.some(({ id }) => {
|
||||||
return id === EMERGENCY_FUND_TAG_ID;
|
return id === EMERGENCY_FUND_TAG_ID;
|
||||||
}) ?? false
|
}) ?? false
|
||||||
);
|
);
|
||||||
@ -1403,18 +1449,9 @@ export class PortfolioService {
|
|||||||
|
|
||||||
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
|
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
|
||||||
|
|
||||||
for (const order of emergencyFundOrders) {
|
for (const { valueInBaseCurrency } of emergencyFundHoldings) {
|
||||||
if (order.type === 'BUY') {
|
valueInBaseCurrencyOfEmergencyFundPositions =
|
||||||
valueInBaseCurrencyOfEmergencyFundPositions =
|
valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency);
|
||||||
valueInBaseCurrencyOfEmergencyFundPositions.plus(
|
|
||||||
order.valueInBaseCurrency
|
|
||||||
);
|
|
||||||
} else if (order.type === 'SELL') {
|
|
||||||
valueInBaseCurrencyOfEmergencyFundPositions =
|
|
||||||
valueInBaseCurrencyOfEmergencyFundPositions.minus(
|
|
||||||
order.valueInBaseCurrency
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
|
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
|
||||||
@ -1431,7 +1468,7 @@ export class PortfolioService {
|
|||||||
}) {
|
}) {
|
||||||
return activities
|
return activities
|
||||||
.filter((activity) => {
|
.filter((activity) => {
|
||||||
// Filter out all activities before given date
|
// Filter out all activities before given date (drafts)
|
||||||
return isBefore(date, new Date(activity.date));
|
return isBefore(date, new Date(activity.date));
|
||||||
})
|
})
|
||||||
.map(({ fee, SymbolProfile }) => {
|
.map(({ fee, SymbolProfile }) => {
|
||||||
@ -1473,24 +1510,25 @@ export class PortfolioService {
|
|||||||
quantity: 0,
|
quantity: 0,
|
||||||
sectors: [],
|
sectors: [],
|
||||||
symbol: currency,
|
symbol: currency,
|
||||||
|
tags: [],
|
||||||
transactionCount: 0,
|
transactionCount: 0,
|
||||||
value: balance
|
valueInBaseCurrency: balance
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
|
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
|
||||||
return orders
|
return activities
|
||||||
.filter((order) => {
|
.filter((activity) => {
|
||||||
// Filter out all orders before given date and type item
|
// Filter out all activities before given date (drafts) and type item
|
||||||
return (
|
return (
|
||||||
isBefore(date, new Date(order.date)) &&
|
isBefore(date, new Date(activity.date)) &&
|
||||||
order.type === TypeOfOrder.ITEM
|
activity.type === TypeOfOrder.ITEM
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
new Big(quantity).mul(unitPrice).toNumber(),
|
||||||
order.SymbolProfile.currency,
|
SymbolProfile.currency,
|
||||||
this.request.user.Settings.settings.baseCurrency
|
this.request.user.Settings.settings.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@ -1500,6 +1538,30 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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':
|
||||||
@ -1601,6 +1663,10 @@ 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 items = this.getItems(activities).toNumber();
|
||||||
|
const liabilities = this.getLiabilities({
|
||||||
|
activities,
|
||||||
|
userCurrency
|
||||||
|
}).toNumber();
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
||||||
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
||||||
@ -1633,6 +1699,7 @@ export class PortfolioService {
|
|||||||
.plus(performanceInformation.performance.currentValue)
|
.plus(performanceInformation.performance.currentValue)
|
||||||
.plus(items)
|
.plus(items)
|
||||||
.plus(excludedAccountsAndActivities)
|
.plus(excludedAccountsAndActivities)
|
||||||
|
.minus(liabilities)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||||
@ -1659,11 +1726,21 @@ export class PortfolioService {
|
|||||||
fees,
|
fees,
|
||||||
firstOrderDate,
|
firstOrderDate,
|
||||||
items,
|
items,
|
||||||
|
liabilities,
|
||||||
netWorth,
|
netWorth,
|
||||||
totalBuy,
|
totalBuy,
|
||||||
totalSell,
|
totalSell,
|
||||||
committedFunds: committedFunds.toNumber(),
|
committedFunds: committedFunds.toNumber(),
|
||||||
emergencyFund: emergencyFund.toNumber(),
|
emergencyFund: {
|
||||||
|
assets: emergencyFundPositionsValueInBaseCurrency,
|
||||||
|
cash: emergencyFund
|
||||||
|
.minus(emergencyFundPositionsValueInBaseCurrency)
|
||||||
|
.toNumber(),
|
||||||
|
total: emergencyFund.toNumber()
|
||||||
|
},
|
||||||
|
fireWealth: new Big(performanceInformation.performance.currentValue)
|
||||||
|
.minus(emergencyFundPositionsValueInBaseCurrency)
|
||||||
|
.toNumber(),
|
||||||
ordersCount: activities.filter(({ type }) => {
|
ordersCount: activities.filter(({ type }) => {
|
||||||
return type === 'BUY' || type === 'SELL';
|
return type === 'BUY' || type === 'SELL';
|
||||||
}).length
|
}).length
|
||||||
@ -1686,7 +1763,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,
|
||||||
@ -1715,6 +1792,7 @@ export class PortfolioService {
|
|||||||
name: order.SymbolProfile?.name,
|
name: order.SymbolProfile?.name,
|
||||||
quantity: new Big(order.quantity),
|
quantity: new Big(order.quantity),
|
||||||
symbol: order.SymbolProfile.symbol,
|
symbol: order.SymbolProfile.symbol,
|
||||||
|
tags: order.tags,
|
||||||
type: order.type,
|
type: order.type,
|
||||||
unitPrice: new Big(
|
unitPrice: new Big(
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
@ -1755,12 +1833,12 @@ export class PortfolioService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const ordersOfTypeItem = await this.orderService.getOrders({
|
const ordersOfTypeItemOrLiability = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
withExcludedAccounts,
|
withExcludedAccounts,
|
||||||
types: ['ITEM']
|
types: ['ITEM', 'LIABILITY']
|
||||||
});
|
});
|
||||||
|
|
||||||
const accounts: PortfolioDetails['accounts'] = {};
|
const accounts: PortfolioDetails['accounts'] = {};
|
||||||
@ -1800,13 +1878,14 @@ export class PortfolioService {
|
|||||||
return accountId === account.id;
|
return accountId === account.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
const ordersOfTypeItemByAccount = ordersOfTypeItem.filter(
|
const ordersOfTypeItemOrLiabilityByAccount =
|
||||||
({ accountId }) => {
|
ordersOfTypeItemOrLiability.filter(({ accountId }) => {
|
||||||
return accountId === account.id;
|
return accountId === account.id;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount);
|
ordersByAccount = ordersByAccount.concat(
|
||||||
|
ordersOfTypeItemOrLiabilityByAccount
|
||||||
|
);
|
||||||
|
|
||||||
accounts[account.id] = {
|
accounts[account.id] = {
|
||||||
balance: account.balance,
|
balance: account.balance,
|
||||||
@ -1846,7 +1925,7 @@ export class PortfolioService {
|
|||||||
order.unitPrice ??
|
order.unitPrice ??
|
||||||
0);
|
0);
|
||||||
|
|
||||||
if (order.type === 'SELL') {
|
if (order.type === 'LIABILITY' || order.type === 'SELL') {
|
||||||
currentValueOfSymbolInBaseCurrency *= -1;
|
currentValueOfSymbolInBaseCurrency *= -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1906,7 +1985,7 @@ export class PortfolioService {
|
|||||||
return (
|
return (
|
||||||
aUser.Settings?.settings.baseCurrency ??
|
aUser.Settings?.settings.baseCurrency ??
|
||||||
this.request.user?.Settings?.settings.baseCurrency ??
|
this.request.user?.Settings?.settings.baseCurrency ??
|
||||||
this.baseCurrency
|
DEFAULT_CURRENCY
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
|
||||||
|
import type { RedisStore } from './redis-store.interface';
|
||||||
|
|
||||||
|
export interface RedisCache extends Cache {
|
||||||
|
store: RedisStore;
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { Store } from 'cache-manager';
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
export interface RedisStore extends Store {
|
||||||
|
getClient: () => ReturnType<typeof createClient>;
|
||||||
|
isCacheableValue: (value: any) => boolean;
|
||||||
|
name: 'redis';
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
import type { RedisClientOptions } from 'redis';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache.service';
|
import { RedisCacheService } from './redis-cache.service';
|
||||||
|
|
||||||
@ -11,7 +13,7 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
imports: [ConfigurationModule],
|
imports: [ConfigurationModule],
|
||||||
inject: [ConfigurationService],
|
inject: [ConfigurationService],
|
||||||
useFactory: async (configurationService: ConfigurationService) => {
|
useFactory: async (configurationService: ConfigurationService) => {
|
||||||
return <CacheManagerOptions>{
|
return <RedisClientOptions>{
|
||||||
host: configurationService.get('REDIS_HOST'),
|
host: configurationService.get('REDIS_HOST'),
|
||||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||||
password: configurationService.get('REDIS_PASSWORD'),
|
password: configurationService.get('REDIS_PASSWORD'),
|
||||||
|
@ -1,18 +1,32 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Cache } from 'cache-manager';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { RedisCache } from './interfaces/redis-cache.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedisCacheService {
|
export class RedisCacheService {
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
) {}
|
) {
|
||||||
|
const client = cache.store.getClient();
|
||||||
|
|
||||||
|
client.on('error', (error) => {
|
||||||
|
Logger.error(error, 'RedisCacheService');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async get(key: string): Promise<string> {
|
public async get(key: string): Promise<string> {
|
||||||
return await this.cache.get(key);
|
return await this.cache.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
||||||
|
}
|
||||||
|
|
||||||
public async remove(key: string) {
|
public async remove(key: string) {
|
||||||
await this.cache.del(key);
|
await this.cache.del(key);
|
||||||
}
|
}
|
||||||
@ -22,8 +36,10 @@ export class RedisCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async set(key: string, value: string, ttlInSeconds?: number) {
|
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||||
await this.cache.set(key, value, {
|
await this.cache.set(
|
||||||
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
key,
|
||||||
});
|
value,
|
||||||
|
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
36
apps/api/src/app/sitemap/sitemap.controller.ts
Normal file
36
apps/api/src/app/sitemap/sitemap.controller.ts
Normal 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)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
24
apps/api/src/app/sitemap/sitemap.module.ts
Normal file
24
apps/api/src/app/sitemap/sitemap.module.ts
Normal 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 {}
|
@ -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,
|
||||||
|
@ -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';
|
||||||
|
|
||||||
@ -36,10 +37,12 @@ export class SymbolController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async lookupSymbol(
|
public async lookupSymbol(
|
||||||
@Query() { query = '' }
|
@Query('includeIndices') includeIndices: boolean = false,
|
||||||
|
@Query('query') query = ''
|
||||||
): Promise<{ items: LookupItem[] }> {
|
): Promise<{ items: LookupItem[] }> {
|
||||||
try {
|
try {
|
||||||
return this.symbolService.lookup({
|
return this.symbolService.lookup({
|
||||||
|
includeIndices,
|
||||||
query: query.toLowerCase(),
|
query: query.toLowerCase(),
|
||||||
user: this.request.user
|
user: this.request.user
|
||||||
});
|
});
|
||||||
@ -91,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(
|
||||||
|
@ -27,9 +27,9 @@ export class SymbolService {
|
|||||||
dataGatheringItem: IDataGatheringItem;
|
dataGatheringItem: IDataGatheringItem;
|
||||||
includeHistoricalData?: number;
|
includeHistoricalData?: number;
|
||||||
}): Promise<SymbolItem> {
|
}): Promise<SymbolItem> {
|
||||||
const quotes = await this.dataProviderService.getQuotes([
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
dataGatheringItem
|
items: [dataGatheringItem]
|
||||||
]);
|
});
|
||||||
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
||||||
|
|
||||||
if (dataGatheringItem.dataSource && marketPrice >= 0) {
|
if (dataGatheringItem.dataSource && marketPrice >= 0) {
|
||||||
@ -81,9 +81,11 @@ export class SymbolService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async lookup({
|
public async lookup({
|
||||||
|
includeIndices = false,
|
||||||
query,
|
query,
|
||||||
user
|
user
|
||||||
}: {
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
user: UserWithSettings;
|
user: UserWithSettings;
|
||||||
}): Promise<{ items: LookupItem[] }> {
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
@ -95,6 +97,7 @@ export class SymbolService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { items } = await this.dataProviderService.search({
|
const { items } = await this.dataProviderService.search({
|
||||||
|
includeIndices,
|
||||||
query,
|
query,
|
||||||
user
|
user
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
@ -14,24 +18,23 @@ import {
|
|||||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
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 { sortBy } from 'lodash';
|
import { sortBy } 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(
|
||||||
@ -123,7 +126,7 @@ export class UserService {
|
|||||||
id,
|
id,
|
||||||
provider,
|
provider,
|
||||||
role,
|
role,
|
||||||
Settings,
|
Settings: Settings as UserWithSettings['Settings'],
|
||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
activityCount: Analytics?.activityCount
|
activityCount: Analytics?.activityCount
|
||||||
@ -144,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
|
||||||
@ -165,11 +167,29 @@ export class UserService {
|
|||||||
user.subscription =
|
user.subscription =
|
||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
|
||||||
if (
|
if (user.subscription?.type === 'Basic') {
|
||||||
Analytics?.activityCount % 20 === 0 &&
|
const daysSinceRegistration = differenceInDays(
|
||||||
user.subscription?.type === 'Basic'
|
new Date(),
|
||||||
) {
|
user.createdAt
|
||||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
);
|
||||||
|
let frequency = 20;
|
||||||
|
|
||||||
|
if (daysSinceRegistration > 180) {
|
||||||
|
frequency = 3;
|
||||||
|
} else if (daysSinceRegistration > 60) {
|
||||||
|
frequency = 5;
|
||||||
|
} else if (daysSinceRegistration > 30) {
|
||||||
|
frequency = 10;
|
||||||
|
} else if (daysSinceRegistration > 15) {
|
||||||
|
frequency = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Analytics?.activityCount % frequency === 1) {
|
||||||
|
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset benchmark
|
||||||
|
user.Settings.settings.benchmark = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.subscription?.type === 'Premium') {
|
if (user.subscription?.type === 'Premium') {
|
||||||
@ -247,7 +267,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'
|
||||||
}
|
}
|
||||||
@ -255,7 +275,7 @@ export class UserService {
|
|||||||
Settings: {
|
Settings: {
|
||||||
create: {
|
create: {
|
||||||
settings: {
|
settings: {
|
||||||
currency: this.baseCurrency
|
currency: DEFAULT_CURRENCY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1
apps/api/src/assets/countries/asia-pacific-markets.json
Normal file
1
apps/api/src/assets/countries/asia-pacific-markets.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
["AU", "HK", "NZ", "SG"]
|
19
apps/api/src/assets/countries/europe-markets.json
Normal file
19
apps/api/src/assets/countries/europe-markets.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
[
|
||||||
|
"AT",
|
||||||
|
"BE",
|
||||||
|
"CH",
|
||||||
|
"DE",
|
||||||
|
"DK",
|
||||||
|
"ES",
|
||||||
|
"FI",
|
||||||
|
"FR",
|
||||||
|
"GB",
|
||||||
|
"IE",
|
||||||
|
"IL",
|
||||||
|
"IT",
|
||||||
|
"LU",
|
||||||
|
"NL",
|
||||||
|
"NO",
|
||||||
|
"PT",
|
||||||
|
"SE"
|
||||||
|
]
|
@ -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 People’s Coin",
|
"PEEPS": "The People’s 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",
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"CYBER24781": "CyberConnect",
|
||||||
"LUNA1": "Terra",
|
"LUNA1": "Terra",
|
||||||
"LUNA2": "Terra",
|
"LUNA2": "Terra",
|
||||||
"SGB1": "Songbird",
|
"SGB1": "Songbird",
|
||||||
|
663
apps/api/src/assets/sitemap.xml
Normal file
663
apps/api/src/assets/sitemap.xml
Normal file
@ -0,0 +1,663 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset
|
||||||
|
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||||
|
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/blog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/features</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/maerkte</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/preise</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/registrierung</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</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-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-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>
|
||||||
|
<loc>https://ghostfol.io/de/ueber-uns</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/about</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/about/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/about/license</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/about/oss-friends</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</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/faq</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/features</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/markets</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/pricing</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/register</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-getquin</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-plannix</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sharesight</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-simple-portfolio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/funcionalidades</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/mercados</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/precios</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/recursos</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/registro</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/sobre</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/sobre/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/sobre/licencia</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/sobre/oss-friends</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/a-propos</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/a-propos/oss-friends</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/enregistrement</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/marches</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/prix</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/ressources</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/funzionalita</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/informazioni-su</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/informazioni-su/oss-friends</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/iscrizione</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/mercati</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/prezzi</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/kenmerken</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/markten</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/over</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/over/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/over/licentie</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/over/oss-friends</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/prijzen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/registratie</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/vaak-gestelde-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>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/blog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/funcionalidades</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/mercados</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/open</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/precos</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/recursos</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/registo</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/sobre</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/sobre/oss-friends</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
@ -1,26 +1,30 @@
|
|||||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import * as bodyParser from 'body-parser';
|
import * as bodyParser from 'body-parser';
|
||||||
|
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);
|
||||||
const configService = configApp.get<ConfigService>(ConfigService);
|
const configService = configApp.get<ConfigService>(ConfigService);
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
logger: environment.production
|
logger: environment.production
|
||||||
? ['error', 'log', 'warn']
|
? ['error', 'log', 'warn']
|
||||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||||
});
|
});
|
||||||
|
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
app.enableVersioning({
|
app.enableVersioning({
|
||||||
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,
|
||||||
@ -32,7 +36,25 @@ async function bootstrap() {
|
|||||||
// Support 10mb csv/json files for importing activities
|
// Support 10mb csv/json files for importing activities
|
||||||
app.use(bodyParser.json({ limit: '10mb' }));
|
app.use(bodyParser.json({ limit: '10mb' }));
|
||||||
|
|
||||||
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
|
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to 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
|
||||||
|
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
|
||||||
|
}
|
||||||
|
},
|
||||||
|
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
@ -40,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.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
136
apps/api/src/middlewares/html-template.middleware.ts
Normal file
136
apps/api/src/middlewares/html-template.middleware.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
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, ETF’s 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.'
|
||||||
|
};
|
||||||
|
|
||||||
|
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 1’000 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}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,10 @@
|
|||||||
|
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
exports: [AccountBalanceService],
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [AccountBalanceService]
|
||||||
|
})
|
||||||
|
export class AccountBalanceModule {}
|
@ -0,0 +1,16 @@
|
|||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccountBalanceService {
|
||||||
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async createAccountBalance(
|
||||||
|
data: Prisma.AccountBalanceCreateInput
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
return this.prismaService.accountBalance.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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,11 +12,8 @@ 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_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
|
||||||
@ -45,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' }),
|
||||||
|
@ -2,6 +2,7 @@ import {
|
|||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
|
||||||
@ -48,7 +49,7 @@ export class CronService {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: `${dataSource}-${symbol}`
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
@ -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],
|
||||||
|
@ -4,14 +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 { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
import {
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
DATE_FORMAT,
|
||||||
|
getAssetProfileIdentifier,
|
||||||
|
resetHours
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
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';
|
||||||
@ -30,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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -120,12 +127,10 @@ export class DataGatheringService {
|
|||||||
uniqueAssets = await this.getUniqueAssets();
|
uniqueAssets = await this.getUniqueAssets();
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetProfiles = await this.dataProviderService.getAssetProfiles(
|
const assetProfiles =
|
||||||
uniqueAssets
|
await this.dataProviderService.getAssetProfiles(uniqueAssets);
|
||||||
);
|
const symbolProfiles =
|
||||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
|
||||||
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) => {
|
||||||
@ -221,7 +226,10 @@ export class DataGatheringService {
|
|||||||
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||||
jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}`
|
jobId: `${getAssetProfileIdentifier({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
})}-${format(date, DATE_FORMAT)}`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -248,6 +256,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);
|
||||||
|
|
||||||
@ -314,6 +326,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({
|
||||||
@ -327,7 +347,7 @@ export class DataGatheringService {
|
|||||||
return {
|
return {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: min([startDate, subYears(new Date(), 10)])
|
date: this.getEarliestDate(startDate)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -336,6 +356,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 },
|
||||||
@ -357,9 +378,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
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -114,8 +114,14 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
const result = await this.alphaVantage.data.search(aQuery);
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
|
const result = await this.alphaVantage.data.search(query);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: result?.bestMatches?.map((bestMatch) => {
|
items: result?.bestMatches?.map((bestMatch) => {
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
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 } 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 +15,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 +34,13 @@ 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 { name } = await got(`${this.URL}/coins/${aSymbol}`).json<any>();
|
||||||
const { name } = await get();
|
|
||||||
|
|
||||||
response.name = name;
|
response.name = name;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -79,17 +73,13 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
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<any>();
|
||||||
'json',
|
|
||||||
200
|
|
||||||
);
|
|
||||||
const { prices } = await get();
|
|
||||||
|
|
||||||
const result: {
|
const result: {
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
@ -132,23 +122,19 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
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<any>();
|
||||||
'json',
|
|
||||||
200
|
|
||||||
);
|
|
||||||
const response = await get();
|
|
||||||
|
|
||||||
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'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -164,17 +150,19 @@ export class CoinGeckoService implements DataProviderInterface {
|
|||||||
return 'bitcoin';
|
return 'bitcoin';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
let items: LookupItem[] = [];
|
let items: LookupItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const { coins } = await got(
|
||||||
`${this.URL}/search?query=${aQuery}`,
|
`${this.URL}/search?query=${query}`
|
||||||
'GET',
|
).json<any>();
|
||||||
'json',
|
|
||||||
200
|
|
||||||
);
|
|
||||||
const { coins } = await get();
|
|
||||||
|
|
||||||
items = coins.map(({ id: symbol, name }) => {
|
items = coins.map(({ id: symbol, name }) => {
|
||||||
return {
|
return {
|
||||||
@ -182,7 +170,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()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
{
|
{
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -3,13 +3,11 @@ 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 +32,43 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await getJSON(
|
const profile = await got(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json`
|
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`
|
||||||
).catch(() => {
|
)
|
||||||
return {};
|
.json<any>()
|
||||||
});
|
.catch(() => {
|
||||||
|
return got(
|
||||||
|
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
|
||||||
|
'.'
|
||||||
|
)?.[0]}.json`
|
||||||
|
)
|
||||||
|
.json<any>()
|
||||||
|
.catch(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const isin = profile.isin?.split(';')?.[0];
|
const isin = profile?.isin?.split(';')?.[0];
|
||||||
|
|
||||||
if (isin) {
|
if (isin) {
|
||||||
response.isin = isin;
|
response.isin = isin;
|
||||||
}
|
}
|
||||||
|
|
||||||
const holdings = await getJSON(
|
const holdings = await got(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
|
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
|
||||||
).catch(() => {
|
)
|
||||||
return getJSON(
|
.json<any>()
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
|
.catch(() => {
|
||||||
symbol.split('.')?.[0]
|
return got(
|
||||||
}.json`
|
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
|
||||||
);
|
'.'
|
||||||
});
|
)?.[0]}.json`
|
||||||
|
)
|
||||||
|
.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 +126,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
public getName() {
|
public getName() {
|
||||||
return 'TRACKINSIGHT';
|
return 'TRACKINSIGHT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getTestSymbol() {
|
||||||
|
return 'QQQ';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,6 +126,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
|||||||
let name = longName;
|
let name = longName;
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
|
name = name.replace('&', '&');
|
||||||
|
|
||||||
name = name.replace('Amundi Index Solutions - ', '');
|
name = name.replace('Amundi Index Solutions - ', '');
|
||||||
name = name.replace('iShares ETF (CH) - ', '');
|
name = name.replace('iShares ETF (CH) - ', '');
|
||||||
name = name.replace('iShares III Public Limited Company - ', '');
|
name = name.replace('iShares III Public Limited Company - ', '');
|
||||||
@ -232,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
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||||
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
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 { 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 {
|
||||||
IDataGatheringItem,
|
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
@ -11,6 +11,7 @@ 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_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
|
import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||||
@ -27,7 +28,8 @@ export class DataProviderService {
|
|||||||
private readonly dataProviderInterfaces: DataProviderInterface[],
|
private readonly dataProviderInterfaces: DataProviderInterface[],
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService
|
private readonly propertyService: PropertyService,
|
||||||
|
private readonly redisCacheService: RedisCacheService
|
||||||
) {
|
) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
@ -43,12 +45,15 @@ export class DataProviderService {
|
|||||||
const dataProvider = this.getDataProvider(dataSource);
|
const dataProvider = this.getDataProvider(dataSource);
|
||||||
const symbol = dataProvider.getTestSymbol();
|
const symbol = dataProvider.getTestSymbol();
|
||||||
|
|
||||||
const quotes = await this.getQuotes([
|
const quotes = await this.getQuotes({
|
||||||
{
|
items: [
|
||||||
dataSource,
|
{
|
||||||
symbol
|
dataSource,
|
||||||
}
|
symbol
|
||||||
]);
|
}
|
||||||
|
],
|
||||||
|
useCache: false
|
||||||
|
});
|
||||||
|
|
||||||
if (quotes[symbol]?.marketPrice > 0) {
|
if (quotes[symbol]?.marketPrice > 0) {
|
||||||
return true;
|
return true;
|
||||||
@ -57,14 +62,16 @@ export class DataProviderService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
|
public async getAssetProfiles(items: UniqueAsset[]): Promise<{
|
||||||
[symbol: string]: Partial<SymbolProfile>;
|
[symbol: string]: Partial<SymbolProfile>;
|
||||||
}> {
|
}> {
|
||||||
const response: {
|
const response: {
|
||||||
[symbol: string]: Partial<SymbolProfile>;
|
[symbol: string]: Partial<SymbolProfile>;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => {
|
||||||
|
return dataSource;
|
||||||
|
});
|
||||||
|
|
||||||
const promises = [];
|
const promises = [];
|
||||||
|
|
||||||
@ -125,7 +132,7 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aItems: IDataGatheringItem[],
|
aItems: UniqueAsset[],
|
||||||
aGranularity: Granularity = 'month',
|
aGranularity: Granularity = 'month',
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date
|
to: Date
|
||||||
@ -153,11 +160,11 @@ export class DataProviderService {
|
|||||||
)}'`
|
)}'`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const dataSources = aItems.map((item) => {
|
const dataSources = aItems.map(({ dataSource }) => {
|
||||||
return item.dataSource;
|
return dataSource;
|
||||||
});
|
});
|
||||||
const symbols = aItems.map((item) => {
|
const symbols = aItems.map(({ symbol }) => {
|
||||||
return item.symbol;
|
return symbol;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -190,7 +197,7 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getHistoricalRaw(
|
public async getHistoricalRaw(
|
||||||
aDataGatheringItems: IDataGatheringItem[],
|
aDataGatheringItems: UniqueAsset[],
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date
|
to: Date
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@ -227,7 +234,13 @@ export class DataProviderService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes(items: IDataGatheringItem[]): Promise<{
|
public async getQuotes({
|
||||||
|
items,
|
||||||
|
useCache = true
|
||||||
|
}: {
|
||||||
|
items: UniqueAsset[];
|
||||||
|
useCache?: boolean;
|
||||||
|
}): Promise<{
|
||||||
[symbol: string]: IDataProviderResponse;
|
[symbol: string]: IDataProviderResponse;
|
||||||
}> {
|
}> {
|
||||||
const response: {
|
const response: {
|
||||||
@ -235,9 +248,44 @@ export class DataProviderService {
|
|||||||
} = {};
|
} = {};
|
||||||
const startTimeTotal = performance.now();
|
const startTimeTotal = performance.now();
|
||||||
|
|
||||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
// Get items from cache
|
||||||
|
const itemsToFetch: UniqueAsset[] = [];
|
||||||
|
|
||||||
const promises = [];
|
for (const { dataSource, symbol } of items) {
|
||||||
|
if (useCache) {
|
||||||
|
const quoteString = await this.redisCacheService.get(
|
||||||
|
this.redisCacheService.getQuoteKey({ dataSource, symbol })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (quoteString) {
|
||||||
|
try {
|
||||||
|
const cachedDataProviderResponse = JSON.parse(quoteString);
|
||||||
|
response[symbol] = cachedDataProviderResponse;
|
||||||
|
continue;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsToFetch.push({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberOfItemsInCache = Object.keys(response)?.length;
|
||||||
|
|
||||||
|
if (numberOfItemsInCache) {
|
||||||
|
Logger.debug(
|
||||||
|
`Fetched ${numberOfItemsInCache} quote${
|
||||||
|
numberOfItemsInCache > 1 ? 's' : ''
|
||||||
|
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
|
||||||
|
3
|
||||||
|
)} seconds`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsGroupedByDataSource = groupBy(itemsToFetch, ({ dataSource }) => {
|
||||||
|
return dataSource;
|
||||||
|
});
|
||||||
|
|
||||||
|
const promises: Promise<any>[] = [];
|
||||||
|
|
||||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||||
itemsGroupedByDataSource
|
itemsGroupedByDataSource
|
||||||
@ -271,6 +319,15 @@ export class DataProviderService {
|
|||||||
result
|
result
|
||||||
)) {
|
)) {
|
||||||
response[symbol] = dataProviderResponse;
|
response[symbol] = dataProviderResponse;
|
||||||
|
|
||||||
|
this.redisCacheService.set(
|
||||||
|
this.redisCacheService.getQuoteKey({
|
||||||
|
dataSource: DataSource[dataSource],
|
||||||
|
symbol
|
||||||
|
}),
|
||||||
|
JSON.stringify(dataProviderResponse),
|
||||||
|
this.configurationService.get('CACHE_QUOTES_TTL')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(
|
Logger.debug(
|
||||||
@ -283,7 +340,7 @@ export class DataProviderService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.marketDataService.updateMany({
|
this.marketDataService.updateMany({
|
||||||
data: Object.keys(response)
|
data: Object.keys(response)
|
||||||
.filter((symbol) => {
|
.filter((symbol) => {
|
||||||
return (
|
return (
|
||||||
@ -322,9 +379,11 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async search({
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
query,
|
query,
|
||||||
user
|
user
|
||||||
}: {
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
user: UserWithSettings;
|
user: UserWithSettings;
|
||||||
}): Promise<{ items: LookupItem[] }> {
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
@ -347,7 +406,12 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const dataSource of dataSources) {
|
for (const dataSource of dataSources) {
|
||||||
promises.push(this.getDataProvider(DataSource[dataSource]).search(query));
|
promises.push(
|
||||||
|
this.getDataProvider(DataSource[dataSource]).search({
|
||||||
|
includeIndices,
|
||||||
|
query
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResults = await Promise.all(promises);
|
const searchResults = await Promise.all(promises);
|
||||||
|
@ -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,19 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
const symbol = this.convertToEodSymbol(aSymbol);
|
const symbol = this.convertToEodSymbol(aSymbol);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
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',
|
timeout: {
|
||||||
200
|
request: DEFAULT_REQUEST_TIMEOUT
|
||||||
);
|
}
|
||||||
|
}
|
||||||
const response = await get();
|
).json<any>();
|
||||||
|
|
||||||
return response.reduce(
|
return response.reduce(
|
||||||
(result, historicalItem, index, array) => {
|
(result, historicalItem, index, array) => {
|
||||||
@ -136,16 +138,16 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
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',
|
timeout: {
|
||||||
200
|
request: DEFAULT_REQUEST_TIMEOUT
|
||||||
);
|
}
|
||||||
|
}
|
||||||
const realTimeResponse = await get();
|
).json<any>();
|
||||||
|
|
||||||
const quotes =
|
const quotes =
|
||||||
symbols.length === 1 ? [realTimeResponse] : realTimeResponse;
|
symbols.length === 1 ? [realTimeResponse] : realTimeResponse;
|
||||||
@ -156,7 +158,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return !symbol.endsWith('.FOREX');
|
return !symbol.endsWith('.FOREX');
|
||||||
})
|
})
|
||||||
.map((symbol) => {
|
.map((symbol) => {
|
||||||
return this.search(symbol);
|
return this.search({ query: symbol });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -174,7 +176,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 +187,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
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -219,8 +221,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
return 'AAPL.US';
|
return 'AAPL.US';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
const searchResult = await this.getSearchResult(aQuery);
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
|
const searchResult = await this.getSearchResult(query);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: searchResult
|
items: searchResult
|
||||||
@ -265,7 +273,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;
|
||||||
@ -278,17 +286,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`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,10 +310,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();
|
||||||
}
|
}
|
||||||
@ -323,13 +331,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
let searchResult = [];
|
let searchResult = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const response = await got(
|
||||||
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
|
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
|
||||||
'GET',
|
{
|
||||||
'json',
|
timeout: {
|
||||||
200
|
request: DEFAULT_REQUEST_TIMEOUT
|
||||||
);
|
}
|
||||||
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 }) => {
|
||||||
|
@ -5,18 +5,18 @@ 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, 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 +25,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 +63,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const { historical } = await got(
|
||||||
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
|
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`
|
||||||
'GET',
|
).json<any>();
|
||||||
'json',
|
|
||||||
200
|
|
||||||
);
|
|
||||||
const { historical } = await get();
|
|
||||||
|
|
||||||
const result: {
|
const result: {
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
@ -115,17 +110,13 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const response = await got(
|
||||||
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`,
|
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`
|
||||||
'GET',
|
).json<any>();
|
||||||
'json',
|
|
||||||
200
|
|
||||||
);
|
|
||||||
const response = await get();
|
|
||||||
|
|
||||||
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,
|
||||||
@ -143,17 +134,19 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
|||||||
return 'AAPL';
|
return 'AAPL';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
let items: LookupItem[] = [];
|
let items: LookupItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const result = await got(
|
||||||
`${this.URL}/search?query=${aQuery}&apikey=${this.apiKey}`,
|
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`
|
||||||
'GET',
|
).json<any>();
|
||||||
'json',
|
|
||||||
200
|
|
||||||
);
|
|
||||||
const result = await get();
|
|
||||||
|
|
||||||
items = result.map(({ currency, name, symbol }) => {
|
items = result.map(({ currency, name, symbol }) => {
|
||||||
return {
|
return {
|
||||||
|
@ -153,7 +153,13 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
return 'INDEXSP:.INX';
|
return 'INDEXSP:.INX';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
const items = await this.prismaService.symbolProfile.findMany({
|
const items = await this.prismaService.symbolProfile.findMany({
|
||||||
select: {
|
select: {
|
||||||
assetClass: true,
|
assetClass: true,
|
||||||
@ -169,14 +175,14 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
name: {
|
name: {
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
startsWith: aQuery
|
startsWith: query
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
symbol: {
|
symbol: {
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
startsWith: aQuery
|
startsWith: query
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -10,4 +10,6 @@ export interface DataEnhancerInterface {
|
|||||||
}): Promise<Partial<SymbolProfile>>;
|
}): Promise<Partial<SymbolProfile>>;
|
||||||
|
|
||||||
getName(): string;
|
getName(): string;
|
||||||
|
|
||||||
|
getTestSymbol(): string;
|
||||||
}
|
}
|
||||||
|
@ -42,5 +42,11 @@ export interface DataProviderInterface {
|
|||||||
|
|
||||||
getTestSymbol(): string;
|
getTestSymbol(): string;
|
||||||
|
|
||||||
search(aQuery: string): Promise<{ items: LookupItem[] }>;
|
search({
|
||||||
|
includeIndices,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }>;
|
||||||
}
|
}
|
||||||
|
@ -14,10 +14,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 {
|
||||||
@ -67,8 +67,12 @@ export class ManualService implements DataProviderInterface {
|
|||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||||
[{ symbol, dataSource: this.getName() }]
|
[{ symbol, dataSource: this.getName() }]
|
||||||
);
|
);
|
||||||
const { defaultMarketPrice, selector, url } =
|
const {
|
||||||
symbolProfile.scraperConfiguration ?? {};
|
defaultMarketPrice,
|
||||||
|
headers = {},
|
||||||
|
selector,
|
||||||
|
url
|
||||||
|
} = symbolProfile.scraperConfiguration ?? {};
|
||||||
|
|
||||||
if (defaultMarketPrice) {
|
if (defaultMarketPrice) {
|
||||||
const historical: {
|
const historical: {
|
||||||
@ -91,10 +95,9 @@ export class ManualService implements DataProviderInterface {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const get = bent(url, 'GET', 'string', 200, {});
|
const { body } = await got(url, { headers });
|
||||||
|
|
||||||
const html = await get();
|
const $ = cheerio.load(body);
|
||||||
const $ = cheerio.load(html);
|
|
||||||
|
|
||||||
const value = extractNumberFromString($(selector).text());
|
const value = extractNumberFromString($(selector).text());
|
||||||
|
|
||||||
@ -171,7 +174,13 @@ export class ManualService implements DataProviderInterface {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
let items = await this.prismaService.symbolProfile.findMany({
|
let items = await this.prismaService.symbolProfile.findMany({
|
||||||
select: {
|
select: {
|
||||||
assetClass: true,
|
assetClass: true,
|
||||||
@ -187,14 +196,14 @@ export class ManualService implements DataProviderInterface {
|
|||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
name: {
|
name: {
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
startsWith: aQuery
|
startsWith: query
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dataSource: this.getName(),
|
dataSource: this.getName(),
|
||||||
symbol: {
|
symbol: {
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
startsWith: aQuery
|
startsWith: query
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -10,8 +10,8 @@ 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 {
|
||||||
@ -117,7 +117,13 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,19 +135,17 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
oneYearAgo: { value: number; valueText: string };
|
oneYearAgo: { value: number; valueText: string };
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const { fgi } = await got(
|
||||||
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
|
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
|
||||||
'GET',
|
|
||||||
'json',
|
|
||||||
200,
|
|
||||||
{
|
{
|
||||||
useQueryString: true,
|
headers: {
|
||||||
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
|
useQueryString: 'true',
|
||||||
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
|
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
|
||||||
|
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
).json<any>();
|
||||||
|
|
||||||
const { fgi } = await get();
|
|
||||||
return fgi;
|
return fgi;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'RapidApiService');
|
Logger.error(error, 'RapidApiService');
|
||||||
|
@ -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(),
|
||||||
@ -275,11 +270,23 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return 'AAPL';
|
return 'AAPL';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
const items: LookupItem[] = [];
|
const items: LookupItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const searchResult = await yahooFinance.search(aQuery);
|
const quoteTypes = ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'];
|
||||||
|
|
||||||
|
if (includeIndices) {
|
||||||
|
quoteTypes.push('INDEX');
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResult = await yahooFinance.search(query);
|
||||||
|
|
||||||
const quotes = searchResult.quotes
|
const quotes = searchResult.quotes
|
||||||
.filter((quote) => {
|
.filter((quote) => {
|
||||||
@ -291,18 +298,18 @@ 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
|
||||||
)
|
)
|
||||||
)) ||
|
)) ||
|
||||||
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
|
quoteTypes.includes(quoteType)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.filter(({ quoteType, symbol }) => {
|
.filter(({ quoteType, symbol }) => {
|
||||||
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;
|
||||||
@ -361,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();
|
||||||
}
|
}
|
||||||
|
@ -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 = {};
|
||||||
@ -64,11 +72,11 @@ export class ExchangeRateDataService {
|
|||||||
if (Object.keys(result).length !== this.currencyPairs.length) {
|
if (Object.keys(result).length !== this.currencyPairs.length) {
|
||||||
// Load currencies directly from data provider as a fallback
|
// Load currencies directly from data provider as a fallback
|
||||||
// if historical data is not fully available
|
// if historical data is not fully available
|
||||||
const quotes = await this.dataProviderService.getQuotes(
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
this.currencyPairs.map(({ dataSource, symbol }) => {
|
items: this.currencyPairs.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
})
|
})
|
||||||
);
|
});
|
||||||
|
|
||||||
for (const symbol of Object.keys(quotes)) {
|
for (const symbol of Object.keys(quotes)) {
|
||||||
if (isNumber(quotes[symbol].marketPrice)) {
|
if (isNumber(quotes[symbol].marketPrice)) {
|
||||||
@ -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
|
||||||
@ -125,17 +133,18 @@ export class ExchangeRateDataService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let factor = 1;
|
let factor: number;
|
||||||
|
|
||||||
if (aFromCurrency !== aToCurrency) {
|
if (aFromCurrency === aToCurrency) {
|
||||||
|
factor = 1;
|
||||||
|
} else {
|
||||||
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
|
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
|
||||||
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
|
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
|
||||||
} 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;
|
||||||
|
|
||||||
@ -171,7 +180,9 @@ export class ExchangeRateDataService {
|
|||||||
|
|
||||||
let factor: number;
|
let factor: number;
|
||||||
|
|
||||||
if (aFromCurrency !== aToCurrency) {
|
if (aFromCurrency === aToCurrency) {
|
||||||
|
factor = 1;
|
||||||
|
} else {
|
||||||
const dataSource =
|
const dataSource =
|
||||||
this.dataProviderService.getDataSourceForExchangeRates();
|
this.dataProviderService.getDataSourceForExchangeRates();
|
||||||
const symbol = `${aFromCurrency}${aToCurrency}`;
|
const symbol = `${aFromCurrency}${aToCurrency}`;
|
||||||
@ -191,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;
|
||||||
}
|
}
|
||||||
@ -282,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}`
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,8 @@ 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_TTL: number;
|
CACHE_TTL: number;
|
||||||
DATA_SOURCE_EXCHANGE_RATES: string;
|
DATA_SOURCE_EXCHANGE_RATES: string;
|
||||||
DATA_SOURCE_IMPORT: string;
|
DATA_SOURCE_IMPORT: string;
|
||||||
|
@ -15,6 +15,12 @@ import { continents, countries } from 'countries-list';
|
|||||||
export class SymbolProfileService {
|
export class SymbolProfileService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async add(
|
||||||
|
assetProfile: Prisma.SymbolProfileCreateInput
|
||||||
|
): Promise<SymbolProfile | never> {
|
||||||
|
return this.prismaService.symbolProfile.create({ data: assetProfile });
|
||||||
|
}
|
||||||
|
|
||||||
public async delete({ dataSource, symbol }: UniqueAsset) {
|
public async delete({ dataSource, symbol }: UniqueAsset) {
|
||||||
return this.prismaService.symbolProfile.delete({
|
return this.prismaService.symbolProfile.delete({
|
||||||
where: { dataSource_symbol: { dataSource, symbol } }
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
@ -90,11 +96,12 @@ export class SymbolProfileService {
|
|||||||
public updateSymbolProfile({
|
public updateSymbolProfile({
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
return this.prismaService.symbolProfile.update({
|
return this.prismaService.symbolProfile.update({
|
||||||
data: { comment, symbolMapping },
|
data: { comment, scraperConfiguration, symbolMapping },
|
||||||
where: { dataSource_symbol: { dataSource, symbol } }
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -189,6 +196,8 @@ export class SymbolProfileService {
|
|||||||
if (scraperConfiguration) {
|
if (scraperConfiguration) {
|
||||||
return {
|
return {
|
||||||
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
|
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
|
||||||
|
headers:
|
||||||
|
scraperConfiguration.headers as ScraperConfiguration['headers'],
|
||||||
selector: scraperConfiguration.selector as string,
|
selector: scraperConfiguration.selector as string,
|
||||||
url: scraperConfiguration.url as string
|
url: scraperConfiguration.url as string
|
||||||
};
|
};
|
||||||
|
@ -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}`,
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
"outDir": "../../dist/out-tsc",
|
"outDir": "../../dist/out-tsc",
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"target": "es2015"
|
"target": "es2021"
|
||||||
},
|
},
|
||||||
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
|
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
|
@ -11,60 +11,15 @@
|
|||||||
"prefix": "gf",
|
"prefix": "gf",
|
||||||
"targets": {
|
"targets": {
|
||||||
"build": {
|
"build": {
|
||||||
"executor": "@angular-devkit/build-angular:browser",
|
"executor": "@nx/angular:webpack-browser",
|
||||||
"options": {
|
"options": {
|
||||||
|
"localize": true,
|
||||||
"outputPath": "dist/apps/client",
|
"outputPath": "dist/apps/client",
|
||||||
"index": "apps/client/src/index.html",
|
"index": "apps/client/src/index.html",
|
||||||
"main": "apps/client/src/main.ts",
|
"main": "apps/client/src/main.ts",
|
||||||
"polyfills": "apps/client/src/polyfills.ts",
|
"polyfills": "apps/client/src/polyfills.ts",
|
||||||
"tsConfig": "apps/client/tsconfig.app.json",
|
"tsConfig": "apps/client/tsconfig.app.json",
|
||||||
"assets": [
|
"assets": [],
|
||||||
{
|
|
||||||
"glob": "assetlinks.json",
|
|
||||||
"input": "apps/client/src/assets",
|
|
||||||
"output": "./../.well-known"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "CHANGELOG.md",
|
|
||||||
"input": "",
|
|
||||||
"output": "./../assets"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "LICENSE",
|
|
||||||
"input": "",
|
|
||||||
"output": "./../assets"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "robots.txt",
|
|
||||||
"input": "apps/client/src/assets",
|
|
||||||
"output": "./../"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "sitemap.xml",
|
|
||||||
"input": "apps/client/src/assets",
|
|
||||||
"output": "./../"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "site.webmanifest",
|
|
||||||
"input": "apps/client/src/assets",
|
|
||||||
"output": "./../"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "node_modules/ionicons/dist/ionicons",
|
|
||||||
"output": "./../ionicons"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*.js",
|
|
||||||
"input": "node_modules/ionicons/dist/",
|
|
||||||
"output": "./../"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "apps/client/src/assets",
|
|
||||||
"output": "./../assets/"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"styles": [
|
"styles": [
|
||||||
"apps/client/src/styles/theme.scss",
|
"apps/client/src/styles/theme.scss",
|
||||||
"apps/client/src/styles.scss"
|
"apps/client/src/styles.scss"
|
||||||
@ -139,8 +94,51 @@
|
|||||||
"outputs": ["{options.outputPath}"],
|
"outputs": ["{options.outputPath}"],
|
||||||
"defaultConfiguration": ""
|
"defaultConfiguration": ""
|
||||||
},
|
},
|
||||||
|
"copy-assets": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "mkdir -p dist/apps/client"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cp -r apps/client/src/assets dist/apps/client"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cp -r apps/client/src/assets/.well-known 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/site.webmanifest dist/apps/client"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cp node_modules/ionicons/dist/index.js dist/apps/client"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cp CHANGELOG.md dist/apps/client/assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cp LICENSE dist/apps/client/assets"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@angular-devkit/build-angular:dev-server",
|
"executor": "@nx/angular:webpack-dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "client:build",
|
"browserTarget": "client:build",
|
||||||
"proxyConfig": "apps/client/proxy.conf.json"
|
"proxyConfig": "apps/client/proxy.conf.json"
|
||||||
|
@ -4,55 +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)
|
||||||
})),
|
},
|
||||||
...[
|
|
||||||
'about/changelog',
|
|
||||||
/////
|
|
||||||
'a-propos/changelog',
|
|
||||||
'informazioni-su/changelog',
|
|
||||||
'over/changelog',
|
|
||||||
'sobre/changelog',
|
|
||||||
'ueber-uns/changelog'
|
|
||||||
].map((path) => ({
|
|
||||||
path,
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/about/changelog/changelog-page.module').then(
|
|
||||||
(m) => m.ChangelogPageModule
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
...[
|
|
||||||
'about/privacy-policy',
|
|
||||||
/////
|
|
||||||
'a-propos/politique-de-confidentialite',
|
|
||||||
'informazioni-su/informativa-sulla-privacy',
|
|
||||||
'over/privacybeleid',
|
|
||||||
'sobre/politica-de-privacidad',
|
|
||||||
'ueber-uns/datenschutzbestimmungen'
|
|
||||||
].map((path) => ({
|
|
||||||
path,
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
|
|
||||||
(m) => m.PrivacyPolicyPageModule
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
{
|
{
|
||||||
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
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -72,154 +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: 'blog/2021/07/hallo-ghostfolio',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
|
|
||||||
).then((m) => m.HalloGhostfolioPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2021/07/hello-ghostfolio',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
|
||||||
).then((m) => m.HelloGhostfolioPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
|
||||||
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
|
|
||||||
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
|
|
||||||
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/08/500-stars-on-github',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
|
|
||||||
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/10/hacktoberfest-2022',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
|
|
||||||
).then((m) => m.Hacktoberfest2022PageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/11/black-friday-2022',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
|
|
||||||
).then((m) => m.BlackFriday2022PageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
|
|
||||||
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
|
|
||||||
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2023/02/ghostfolio-meets-umbrel',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
|
|
||||||
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2023/03/ghostfolio-reaches-1000-stars-on-github',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.module'
|
|
||||||
).then((m) => m.ThousandStarsOnGitHubPageModule)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'blog/2023/05/unlock-your-financial-potential-with-ghostfolio',
|
|
||||||
loadChildren: () =>
|
|
||||||
import(
|
|
||||||
'./pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.module'
|
|
||||||
).then((m) => m.UnlockYourFinancialPotentialWithGhostfolioPageModule)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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',
|
|
||||||
'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: () =>
|
||||||
@ -239,51 +99,27 @@ const routes: Routes = [
|
|||||||
(m) => m.PortfolioPageModule
|
(m) => m.PortfolioPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
...[
|
{
|
||||||
'pricing',
|
path: paths.pricing,
|
||||||
/////
|
|
||||||
'precios',
|
|
||||||
'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',
|
|
||||||
'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: () =>
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
<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 px-3 py-2"
|
||||||
@ -43,21 +43,7 @@
|
|||||||
<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 === '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">
|
||||||
@ -67,43 +53,49 @@
|
|||||||
<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="hasPermissionForSubscription">
|
<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>
|
||||||
<li *ngIf="hasPermissionForSubscription">
|
<li *ngIf="hasPermissionForSubscription">
|
||||||
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
|
<a
|
||||||
>Status</a
|
class="align-items-baseline d-flex"
|
||||||
>
|
href="https://status.ghostfol.io"
|
||||||
|
target="_blank"
|
||||||
|
title="Ghostfolio Status"
|
||||||
|
>Status<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||||
|
></a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -112,24 +104,30 @@
|
|||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
class="align-items-baseline d-flex"
|
||||||
href="https://github.com/ghostfolio/ghostfolio"
|
href="https://github.com/ghostfolio/ghostfolio"
|
||||||
|
target="_blank"
|
||||||
title="Find Ghostfolio on GitHub"
|
title="Find Ghostfolio on GitHub"
|
||||||
>GitHub</a
|
>GitHub<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||||
>
|
></a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
class="align-items-baseline d-flex"
|
||||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
|
target="_blank"
|
||||||
title="Join the Ghostfolio Slack community"
|
title="Join the Ghostfolio Slack community"
|
||||||
>Slack</a
|
>Slack<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||||
>
|
></a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
class="align-items-baseline d-flex"
|
||||||
href="https://twitter.com/ghostfolio_"
|
href="https://twitter.com/ghostfolio_"
|
||||||
|
target="_blank"
|
||||||
title="Follow Ghostfolio on Twitter"
|
title="Follow Ghostfolio on Twitter"
|
||||||
>Twitter</a
|
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||||
>
|
></a>
|
||||||
</li>
|
</li>
|
||||||
<li> </li>
|
<li> </li>
|
||||||
<li>
|
<li>
|
||||||
@ -150,6 +148,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,10 +33,25 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
public currentYear = new Date().getFullYear();
|
public currentYear = new Date().getFullYear();
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasPermissionForBlog: boolean;
|
public hasPermissionForBlog: boolean;
|
||||||
|
public hasPermissionForStatistics: boolean;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
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;
|
||||||
|
|
||||||
@ -70,6 +85,11 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
permissions.enableSubscription
|
permissions.enableSubscription
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionForStatistics = hasPermission(
|
||||||
|
this.info?.globalPermissions,
|
||||||
|
permissions.enableStatistics
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||||
this.info?.globalPermissions,
|
this.info?.globalPermissions,
|
||||||
permissions.enableFearAndGreedIndex
|
permissions.enableFearAndGreedIndex
|
||||||
@ -83,6 +103,19 @@ 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.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('–');
|
||||||
|
@ -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({
|
||||||
|
@ -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())) {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AfterViewInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
@ -7,23 +8,26 @@ import {
|
|||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatSort } from '@angular/material/sort';
|
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSort, Sort } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
|
||||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { AssetSubClass, DataSource } from '@prisma/client';
|
import { AssetSubClass, DataSource, Prisma } from '@prisma/client';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
||||||
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
||||||
|
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
|
||||||
|
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@ -31,7 +35,10 @@ import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/inte
|
|||||||
styleUrls: ['./admin-market-data.scss'],
|
styleUrls: ['./admin-market-data.scss'],
|
||||||
templateUrl: './admin-market-data.html'
|
templateUrl: './admin-market-data.html'
|
||||||
})
|
})
|
||||||
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
export class AdminMarketDataComponent
|
||||||
|
implements AfterViewInit, OnDestroy, OnInit
|
||||||
|
{
|
||||||
|
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||||
@ViewChild(MatSort) sort: MatSort;
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
public activeFilters: Filter[] = [];
|
public activeFilters: Filter[] = [];
|
||||||
@ -44,13 +51,31 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
AssetSubClass.PRECIOUS_METAL,
|
AssetSubClass.PRECIOUS_METAL,
|
||||||
AssetSubClass.PRIVATE_EQUITY,
|
AssetSubClass.PRIVATE_EQUITY,
|
||||||
AssetSubClass.STOCK
|
AssetSubClass.STOCK
|
||||||
].map((assetSubClass) => {
|
]
|
||||||
return {
|
.map((assetSubClass) => {
|
||||||
id: assetSubClass,
|
return {
|
||||||
label: translate(assetSubClass),
|
id: assetSubClass.toString(),
|
||||||
type: 'ASSET_SUB_CLASS'
|
label: translate(assetSubClass),
|
||||||
};
|
type: <Filter['type']>'ASSET_SUB_CLASS'
|
||||||
});
|
};
|
||||||
|
})
|
||||||
|
.concat([
|
||||||
|
{
|
||||||
|
id: 'CURRENCIES',
|
||||||
|
label: $localize`Currencies`,
|
||||||
|
type: <Filter['type']>'PRESET_ID'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ETF_WITHOUT_COUNTRIES',
|
||||||
|
label: $localize`ETFs without Countries`,
|
||||||
|
type: <Filter['type']>'PRESET_ID'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ETF_WITHOUT_SECTORS',
|
||||||
|
label: $localize`ETFs without Sectors`,
|
||||||
|
type: <Filter['type']>'PRESET_ID'
|
||||||
|
}
|
||||||
|
]);
|
||||||
public currentDataSource: DataSource;
|
public currentDataSource: DataSource;
|
||||||
public currentSymbol: string;
|
public currentSymbol: string;
|
||||||
public dataSource: MatTableDataSource<AdminMarketDataItem> =
|
public dataSource: MatTableDataSource<AdminMarketDataItem> =
|
||||||
@ -73,6 +98,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
public filters$ = new Subject<Filter[]>();
|
public filters$ = new Subject<Filter[]>();
|
||||||
public isLoading = false;
|
public isLoading = false;
|
||||||
public placeholder = '';
|
public placeholder = '';
|
||||||
|
public pageSize = DEFAULT_PAGE_SIZE;
|
||||||
|
public totalItems = 0;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -80,7 +107,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@ -99,6 +125,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
dataSource: params['dataSource'],
|
dataSource: params['dataSource'],
|
||||||
symbol: params['symbol']
|
symbol: params['symbol']
|
||||||
});
|
});
|
||||||
|
} else if (params['createAssetProfileDialog']) {
|
||||||
|
this.openCreateAssetProfileDialog();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,34 +141,40 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.filters$
|
||||||
|
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((filters) => {
|
||||||
|
this.activeFilters = filters;
|
||||||
|
|
||||||
|
this.loadData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngAfterViewInit() {
|
||||||
|
this.sort.sortChange.subscribe(
|
||||||
|
({ active: sortColumn, direction }: Sort) => {
|
||||||
|
this.paginator.pageIndex = 0;
|
||||||
|
|
||||||
|
this.loadData({
|
||||||
|
sortColumn,
|
||||||
|
sortDirection: <Prisma.SortOrder>direction,
|
||||||
|
pageIndex: this.paginator.pageIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
}
|
||||||
|
|
||||||
this.filters$
|
public onChangePage(page: PageEvent) {
|
||||||
.pipe(
|
this.loadData({
|
||||||
distinctUntilChanged(),
|
pageIndex: page.pageIndex,
|
||||||
switchMap((filters) => {
|
sortColumn: this.sort.active,
|
||||||
this.isLoading = true;
|
sortDirection: <Prisma.SortOrder>this.sort.direction
|
||||||
this.activeFilters = filters;
|
});
|
||||||
this.placeholder =
|
|
||||||
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
|
||||||
|
|
||||||
return this.dataService.fetchAdminMarketData({
|
|
||||||
filters: this.activeFilters
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
takeUntil(this.unsubscribeSubject)
|
|
||||||
)
|
|
||||||
.subscribe(({ marketData }) => {
|
|
||||||
this.dataSource = new MatTableDataSource(marketData);
|
|
||||||
this.dataSource.sort = this.sort;
|
|
||||||
|
|
||||||
this.isLoading = false;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
@ -208,6 +242,53 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadData(
|
||||||
|
{
|
||||||
|
pageIndex,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection
|
||||||
|
}: {
|
||||||
|
pageIndex: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
} = { pageIndex: 0 }
|
||||||
|
) {
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
this.pageSize =
|
||||||
|
this.activeFilters.length === 1 &&
|
||||||
|
this.activeFilters[0].type === 'PRESET_ID'
|
||||||
|
? undefined
|
||||||
|
: DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
|
if (pageIndex === 0 && this.paginator) {
|
||||||
|
this.paginator.pageIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.placeholder =
|
||||||
|
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||||
|
|
||||||
|
this.adminService
|
||||||
|
.fetchAdminMarketData({
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
filters: this.activeFilters,
|
||||||
|
skip: pageIndex * this.pageSize,
|
||||||
|
take: this.pageSize
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ count, marketData }) => {
|
||||||
|
this.totalItems = count;
|
||||||
|
|
||||||
|
this.dataSource = new MatTableDataSource(marketData);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private openAssetProfileDialog({
|
private openAssetProfileDialog({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
@ -241,4 +322,53 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openCreateAssetProfileDialog() {
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(CreateAssetProfileDialog, {
|
||||||
|
autoFocus: false,
|
||||||
|
data: <CreateAssetProfileDialogParams>{
|
||||||
|
deviceType: this.deviceType,
|
||||||
|
locale: this.user?.settings?.locale
|
||||||
|
},
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ dataSource, symbol }) => {
|
||||||
|
if (dataSource && symbol) {
|
||||||
|
this.adminService
|
||||||
|
.addAssetProfile({ dataSource, symbol })
|
||||||
|
.pipe(
|
||||||
|
switchMap(() => {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
return this.adminService.fetchAdminMarketData({
|
||||||
|
filters: this.activeFilters,
|
||||||
|
take: this.pageSize
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe(({ marketData }) => {
|
||||||
|
this.dataSource = new MatTableDataSource(marketData);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -56,7 +56,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="date">
|
<ng-container matColumnDef="date">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
<ng-container i18n>First Activity</ng-container>
|
<ng-container i18n>First Activity</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
@ -74,7 +74,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="marketDataItemCount">
|
<ng-container matColumnDef="marketDataItemCount">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
<ng-container i18n>Historical Data</ng-container>
|
<ng-container i18n>Historical Data</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
@ -83,7 +83,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="sectorsCount">
|
<ng-container matColumnDef="sectorsCount">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
<ng-container i18n>Sectors Count</ng-container>
|
<ng-container i18n>Sectors Count</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
@ -92,7 +92,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="countriesCount">
|
<ng-container matColumnDef="countriesCount">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||||
<ng-container i18n>Countries Count</ng-container>
|
<ng-container i18n>Countries Count</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
@ -162,6 +162,40 @@
|
|||||||
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
|
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
|
||||||
></tr>
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<mat-paginator
|
||||||
|
[length]="totalItems"
|
||||||
|
[ngClass]="{
|
||||||
|
'd-none':
|
||||||
|
(isLoading && totalItems === 0) ||
|
||||||
|
totalItems <= pageSize
|
||||||
|
}"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[showFirstLastButtons]="true"
|
||||||
|
(page)="onChangePage($event)"
|
||||||
|
></mat-paginator>
|
||||||
|
|
||||||
|
<ngx-skeleton-loader
|
||||||
|
*ngIf="isLoading && totalItems === 0"
|
||||||
|
animation="pulse"
|
||||||
|
class="px-4 py-3"
|
||||||
|
[theme]="{
|
||||||
|
height: '1.5rem',
|
||||||
|
width: '100%'
|
||||||
|
}"
|
||||||
|
></ngx-skeleton-loader>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="fab-container">
|
||||||
|
<a
|
||||||
|
class="align-items-center d-flex justify-content-center"
|
||||||
|
color="primary"
|
||||||
|
mat-fab
|
||||||
|
[queryParams]="{ createAssetProfileDialog: true }"
|
||||||
|
[routerLink]="[]"
|
||||||
|
>
|
||||||
|
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,12 +2,16 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
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 { MatPaginatorModule } from '@angular/material/paginator';
|
||||||
import { MatSortModule } from '@angular/material/sort';
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { AdminMarketDataComponent } from './admin-market-data.component';
|
import { AdminMarketDataComponent } from './admin-market-data.component';
|
||||||
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
|
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
|
||||||
|
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AdminMarketDataComponent],
|
declarations: [AdminMarketDataComponent],
|
||||||
@ -15,10 +19,14 @@ import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
GfActivitiesFilterModule,
|
GfActivitiesFilterModule,
|
||||||
GfAssetProfileDialogModule,
|
GfAssetProfileDialogModule,
|
||||||
|
GfCreateAssetProfileDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
|
MatPaginatorModule,
|
||||||
MatSortModule,
|
MatSortModule,
|
||||||
MatTableModule
|
MatTableModule,
|
||||||
|
NgxSkeletonLoaderModule,
|
||||||
|
RouterModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -2,4 +2,11 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
.fab-container {
|
||||||
|
bottom: 2rem;
|
||||||
|
position: fixed;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
|
|||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import {
|
import {
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
|
ScraperConfiguration,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
@ -34,6 +35,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
public assetProfile: AdminMarketDataDetails['assetProfile'];
|
public assetProfile: AdminMarketDataDetails['assetProfile'];
|
||||||
public assetProfileForm = this.formBuilder.group({
|
public assetProfileForm = this.formBuilder.group({
|
||||||
comment: '',
|
comment: '',
|
||||||
|
scraperConfiguration: '',
|
||||||
symbolMapping: ''
|
symbolMapping: ''
|
||||||
});
|
});
|
||||||
public assetSubClass: string;
|
public assetSubClass: string;
|
||||||
@ -103,6 +105,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
this.assetProfileForm.setValue({
|
this.assetProfileForm.setValue({
|
||||||
comment: this.assetProfile?.comment ?? '',
|
comment: this.assetProfile?.comment ?? '',
|
||||||
|
scraperConfiguration: JSON.stringify(
|
||||||
|
this.assetProfile?.scraperConfiguration ?? {}
|
||||||
|
),
|
||||||
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
|
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -148,8 +153,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onSubmit() {
|
public onSubmit() {
|
||||||
|
let scraperConfiguration = {};
|
||||||
let symbolMapping = {};
|
let symbolMapping = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
scraperConfiguration = JSON.parse(
|
||||||
|
this.assetProfileForm.controls['scraperConfiguration'].value
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
symbolMapping = JSON.parse(
|
symbolMapping = JSON.parse(
|
||||||
this.assetProfileForm.controls['symbolMapping'].value
|
this.assetProfileForm.controls['symbolMapping'].value
|
||||||
@ -157,6 +169,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const assetProfileData: UpdateAssetProfileDto = {
|
const assetProfileData: UpdateAssetProfileDto = {
|
||||||
|
scraperConfiguration,
|
||||||
symbolMapping,
|
symbolMapping,
|
||||||
comment: this.assetProfileForm.controls['comment'].value ?? null
|
comment: this.assetProfileForm.controls['comment'].value ?? null
|
||||||
};
|
};
|
||||||
|
@ -162,6 +162,17 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
<div *ngIf="assetProfile?.dataSource === 'MANUAL'">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Scraper Configuration</mat-label>
|
||||||
|
<textarea
|
||||||
|
cdkTextareaAutosize
|
||||||
|
formControlName="scraperConfiguration"
|
||||||
|
matInput
|
||||||
|
type="text"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Note</mat-label>
|
<mat-label i18n>Note</mat-label>
|
||||||
|
@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
FormBuilder,
|
||||||
|
FormControl,
|
||||||
|
FormGroup,
|
||||||
|
Validators
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'h-100' },
|
||||||
|
selector: 'gf-create-asset-profile-dialog',
|
||||||
|
templateUrl: 'create-asset-profile-dialog.html'
|
||||||
|
})
|
||||||
|
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
||||||
|
public createAssetProfileForm: FormGroup;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public readonly adminService: AdminService,
|
||||||
|
public readonly changeDetectorRef: ChangeDetectorRef,
|
||||||
|
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
|
||||||
|
public readonly formBuilder: FormBuilder
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.createAssetProfileForm = this.formBuilder.group({
|
||||||
|
searchSymbol: new FormControl(null, [Validators.required])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onCancel() {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSubmit() {
|
||||||
|
this.dialogRef.close({
|
||||||
|
dataSource:
|
||||||
|
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
|
||||||
|
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
<form
|
||||||
|
class="d-flex flex-column h-100"
|
||||||
|
[formGroup]="createAssetProfileForm"
|
||||||
|
(keyup.enter)="createAssetProfileForm.valid && onSubmit()"
|
||||||
|
(ngSubmit)="onSubmit()"
|
||||||
|
>
|
||||||
|
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
|
||||||
|
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||||
|
<gf-symbol-autocomplete
|
||||||
|
formControlName="searchSymbol"
|
||||||
|
[includeIndices]="true"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end" mat-dialog-actions>
|
||||||
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||||
|
<button
|
||||||
|
color="primary"
|
||||||
|
mat-flat-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="!createAssetProfileForm.valid"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Save</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,24 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, 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 { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
|
||||||
|
|
||||||
|
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [CreateAssetProfileDialog],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
GfSymbolAutocompleteModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfCreateAssetProfileDialogModule {}
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface CreateAssetProfileDialogParams {
|
||||||
|
deviceType: string;
|
||||||
|
locale: string;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
@ -45,6 +46,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -197,7 +199,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fetchAdminData() {
|
private fetchAdminData() {
|
||||||
this.dataService
|
this.adminService
|
||||||
.fetchAdminData()
|
.fetchAdminData()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user