Compare commits
280 Commits
Author | SHA1 | Date | |
---|---|---|---|
b404858904 | |||
7ec033577f | |||
c8ca82b803 | |||
5db2faa17d | |||
1605fb8d48 | |||
b6a7804a26 | |||
de31381fd9 | |||
0d92b8d8bb | |||
7c6ff776d9 | |||
e37a34ed6c | |||
c4d9c00f92 | |||
3af8be89e3 | |||
0f1db71604 | |||
fce9e7fb0c | |||
6301c0c21c | |||
30bb484d5a | |||
f88ee5e5a0 | |||
73b5030972 | |||
a69a3442ab | |||
d4dff744b5 | |||
62c93ad99d | |||
1e42d6bffa | |||
002ac29f2f | |||
20ccf389e9 | |||
a2f99ed4d2 | |||
cc6320acfd | |||
261a0fb0b9 | |||
cfc05cce41 | |||
1f15b70134 | |||
a5b49b286d | |||
f3333f24da | |||
cad8f0d0e2 | |||
edd3e75730 | |||
ab68c2c69a | |||
cbb95f21a3 | |||
74d3954335 | |||
92449b0369 | |||
65276483e0 | |||
dde0d1e465 | |||
3ad802c6f5 | |||
b81377a682 | |||
545180b88f | |||
a9819b9e25 | |||
897e941e7a | |||
aef840c2cc | |||
80d0638922 | |||
494ba36d44 | |||
dab9154092 | |||
cd4a85abbf | |||
e7977a9fbb | |||
684c1e55b0 | |||
1ffa831c5c | |||
40eed0016c | |||
b58631083b | |||
e0c0425d21 | |||
bf2de5d572 | |||
2b4a1dc480 | |||
ce022c024f | |||
0f4bf529d8 | |||
dad6bf7095 | |||
86ca9eaae6 | |||
9d9b805b0e | |||
851401be1e | |||
85052bc9bc | |||
bff09f529d | |||
f438458687 | |||
7125b12631 | |||
0cbf275a2e | |||
0ec50819f5 | |||
c9abe818bc | |||
bfa32537a8 | |||
cef15afab8 | |||
1b9587c454 | |||
de76b0d8c3 | |||
e62989c981 | |||
d6b71e6314 | |||
8c59bfd6d7 | |||
f32df73256 | |||
9d03a8002c | |||
3c36ca29af | |||
efed7e3c2b | |||
b09d3cea95 | |||
eabd2f3934 | |||
cc184c2827 | |||
436f791fa4 | |||
e935a57dec | |||
203909d917 | |||
eed4f57f30 | |||
7878036bac | |||
75d140b436 | |||
a79f31b006 | |||
45cfd61dbb | |||
7fcfca952e | |||
279f16cc67 | |||
e7b1d8a5d3 | |||
1b2f8e5586 | |||
e4468252c6 | |||
ad3ebd42bb | |||
55b03733f4 | |||
0000317041 | |||
e5f2a3865d | |||
c61561664f | |||
a7d8a63ab8 | |||
5c51c1e825 | |||
3a67bf9bb4 | |||
f7597c213d | |||
2e7f46ad78 | |||
cfffb99f52 | |||
69ac3408f1 | |||
e1806b4bd8 | |||
6aae0cc1e4 | |||
5d8a50a80d | |||
662231e830 | |||
4d84459b5b | |||
efba7429c1 | |||
9cae5a3e79 | |||
c2ed0a436f | |||
8486c02575 | |||
5122ef3456 | |||
579b86665e | |||
52b3ad6dc3 | |||
bf9b60aa74 | |||
6cd51fb044 | |||
271001f523 | |||
a7e513a6d1 | |||
b5f256be95 | |||
a834ef6b4c | |||
e5bd0d1bfa | |||
7fa6eda45d | |||
f47e4d3b04 | |||
0300c6f3b7 | |||
4865c45fd4 | |||
2beceb36cf | |||
cd64601482 | |||
efac39eb51 | |||
4da8a547ca | |||
9e8a9e4670 | |||
bb99141e9c | |||
d147c2313f | |||
0878941c4f | |||
69a9e77820 | |||
104cca069f | |||
7ad58b1a62 | |||
e88dbb0181 | |||
152fd4fdf8 | |||
6b022b8de8 | |||
7ab699e5fe | |||
a7e5a316be | |||
3f2d3a2da9 | |||
0208bd0923 | |||
aeba6e1f03 | |||
1b899da9ff | |||
90a7a84ac5 | |||
fc8e23a9c8 | |||
f3c8ec27cb | |||
38474f54b0 | |||
18d25fb6c2 | |||
a850e8ca22 | |||
b5f565c054 | |||
aa6d0a4533 | |||
25e9028a41 | |||
925d38703e | |||
158bb00b8a | |||
b17111e6f1 | |||
c4765e31cd | |||
d321d56dee | |||
07dd22f7fe | |||
eb4d088a80 | |||
0509f0101f | |||
8818e09be8 | |||
d97fe4da9c | |||
b20fa55b79 | |||
dd7a6f1562 | |||
15357bd5b5 | |||
52c7adc266 | |||
1ae8970045 | |||
7c4c047140 | |||
527f7e4faf | |||
50160eb9dc | |||
58dff8a1e0 | |||
2cd41615b2 | |||
66d5793528 | |||
e8d65e1c85 | |||
da827a08f5 | |||
d545e4877c | |||
1918dee9c5 | |||
a08610b603 | |||
c22733db56 | |||
ee4866eb7d | |||
327b1fa0d7 | |||
b155666d21 | |||
c5ee3237ed | |||
16118d635c | |||
49ce4803ce | |||
0b65d05013 | |||
8793284e75 | |||
1c5e4050a8 | |||
4f187e1a9f | |||
b56111ae85 | |||
61dfc1f819 | |||
6137f228a8 | |||
5293de14cd | |||
7340a674b5 | |||
42cb3e2c73 | |||
e8a4a53c9f | |||
629f002074 | |||
7c65cf6ddd | |||
c38ebec3be | |||
2b8ab26e7e | |||
60f52bb209 | |||
616d168a7c | |||
b13e4425d3 | |||
1424236c48 | |||
2a605f850d | |||
88ffbfead0 | |||
5f4a8d505b | |||
e87b93f19c | |||
49dcade964 | |||
7cd65eed39 | |||
a51b210f79 | |||
285f2220f3 | |||
d72123246d | |||
3a78d6c3f1 | |||
d5e3ff5717 | |||
2efb331370 | |||
f521fe99c5 | |||
42306530b8 | |||
68c9d1b266 | |||
1ce90a0c06 | |||
50f6d154e5 | |||
e4c44faee4 | |||
5209f82cca | |||
292d345ce0 | |||
d58400788a | |||
7ff61ae839 | |||
b5b7af7741 | |||
de3e0fad83 | |||
8c8273c4d4 | |||
b406bcd17d | |||
fb496431e8 | |||
441b251536 | |||
1dbb5db611 | |||
8567efcd89 | |||
1cda5dcc0a | |||
3fb01c6dcf | |||
6a764fe893 | |||
d2b75a244c | |||
3611684f17 | |||
4b74be50da | |||
0d338bb083 | |||
b0d708fb82 | |||
be14458437 | |||
5978ddb80f | |||
18638dd1b7 | |||
81db3852e6 | |||
af27781234 | |||
608e7a774d | |||
ed15eb76fd | |||
39905e5046 | |||
7cd3f235df | |||
3b4f8c69bb | |||
c9bdf46b2b | |||
4169de580b | |||
3317fe7c46 | |||
c8f6fdbaa3 | |||
d95fc82f95 | |||
31c949f9d2 | |||
f68f40fcc6 | |||
9623a363ed | |||
2d42549967 | |||
c934c5088b | |||
678b3cc57e | |||
cd5eb64a4c | |||
fc1507de4f | |||
d147a66dcd | |||
33fd1282e5 | |||
693ff9d3ea | |||
21e87a0055 | |||
43426c9b01 | |||
3b4da72ea3 |
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@ -41,7 +41,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.output.labels }}
|
labels: ${{ steps.meta.output.labels }}
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,6 +5,7 @@
|
|||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
/.yarn
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
@ -24,6 +25,7 @@
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
/.angular/cache
|
/.angular/cache
|
||||||
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
stories: [],
|
|
||||||
addons: ['@storybook/addon-essentials']
|
|
||||||
// uncomment the property below if you want to apply some webpack config globally
|
// uncomment the property below if you want to apply some webpack config globally
|
||||||
// webpackFinal: async (config, { configType }) => {
|
// webpackFinal: async (config, { configType }) => {
|
||||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||||
|
|
||||||
// // Return the altered config
|
// // Return the altered config
|
||||||
// return config;
|
// return config;
|
||||||
// },
|
// },
|
||||||
|
553
CHANGELOG.md
553
CHANGELOG.md
@ -5,6 +5,551 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.246.0 - 2023-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for asset and asset sub class to the `EOD_HISTORICAL_DATA` data source type
|
||||||
|
- Added `isin` to the asset profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the _Trackinsight_ data enhancer for asset profile data by `isin`
|
||||||
|
- Improved the language localization for _Gather Data_
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the border color in the _FIRE_ calculator (dark mode)
|
||||||
|
|
||||||
|
## 1.245.0 - 2023-03-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the search functionality for the `EOD_HISTORICAL_DATA` data source type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the _FIRE_ calculator
|
||||||
|
- Improved the exchange rate service for a specific date used in activities with a manual currency
|
||||||
|
- Upgraded `ngx-device-detector` from version `3.0.0` to `5.0.1`
|
||||||
|
|
||||||
|
## 1.244.0 - 2023-03-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the _FIRE_ calculator by a retirement date setting
|
||||||
|
|
||||||
|
## 1.243.0 - 2023-03-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `COINGECKO` as a default to `DATA_SOURCES`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the validation of the manual currency for the activity fee and unit price
|
||||||
|
- Harmonized the axis style of charts
|
||||||
|
- Made setting `NODE_ENV: production` optional (to avoid `ENOENT: no such file or directory` errors on startup)
|
||||||
|
- Removed the environment variable `ENABLE_FEATURE_CUSTOM_SYMBOLS`
|
||||||
|
|
||||||
|
## 1.242.0 - 2023-03-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified the database seeding
|
||||||
|
- Upgraded `ngx-skeleton-loader` from version `5.0.0` to `7.0.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Downgraded `Node.js` from version `18` to `16` (Dockerfile) to resolve `SIGSEGV` (segmentation fault) during the `prisma` database migrations (see https://github.com/prisma/prisma/issues/10649)
|
||||||
|
|
||||||
|
## 1.241.0 - 2023-03-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Filtered activities with type `ITEM` from search results
|
||||||
|
- Considered the user's language in the _Stripe_ checkout
|
||||||
|
- Upgraded the _Stripe_ dependencies
|
||||||
|
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
|
||||||
|
|
||||||
|
## 1.240.0 - 2023-02-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported a manual currency for the activity unit price
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the feature graphic of the _Ghostfolio meets Umbrel_ blog post
|
||||||
|
|
||||||
|
## 1.239.0 - 2023-02-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Ghostfolio meets Umbrel_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the dependency `rimraf`
|
||||||
|
|
||||||
|
## 1.238.0 - 2023-02-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `COINGECKO` as a new data source type
|
||||||
|
- Added support for data provider information to the position detail dialog
|
||||||
|
- Added the configuration to publish a `linux/arm/v7` docker image
|
||||||
|
- Added _Reddit_ to the _As seen in_ section on the landing page
|
||||||
|
- Added _Umbrel_ to the _As seen in_ section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed the example environment variable file from `.env` to `.env.example`
|
||||||
|
- Upgraded `zone.js` from version `0.11.8` to `0.12.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `RangeError: Maximum call stack size exceeded` for values of type `Big` in the value redaction interceptor for the impersonation mode
|
||||||
|
- Reset the letter spacing in buttons
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Ensure that you still have a `.env` file in your project
|
||||||
|
|
||||||
|
## 1.237.0 - 2023-02-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the support details to the pricing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Increased the file size limit for the activities import
|
||||||
|
- Improved the style of the search results for symbols
|
||||||
|
- Migrated the style of `GfHeaderModule` to `@angular/material` `15` (mdc)
|
||||||
|
- Upgraded `angular` from version `15.1.2` to `15.1.5`
|
||||||
|
- Upgraded `Nx` from version `15.6.3` to `15.7.2`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with exact matches in the activities table filter (`VT` vs. `VTI`)
|
||||||
|
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
|
||||||
|
|
||||||
|
## 1.236.0 - 2023-02-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Beautified the ETF names in the asset profile
|
||||||
|
- Removed the data source type `GHOSTFOLIO`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
|
||||||
|
- Fixed the buying power calculation if no emergency fund is set but an activity is tagged as _Emergency Fund_
|
||||||
|
- Fixed the url on logout during the local development
|
||||||
|
|
||||||
|
## 1.235.0 - 2023-02-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the styles on the about page
|
||||||
|
- Eliminated the `GhostfolioScraperApiService`
|
||||||
|
|
||||||
|
## 1.234.0 - 2023-02-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the data import and export feature to the pricing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Copy the logic of `GhostfolioScraperApiService` to `ManualService`
|
||||||
|
- Improved the content of the landing page
|
||||||
|
- Improved the content of the Frequently Asked Questions (FAQ) page
|
||||||
|
- Improved the usability of the _Import Activities..._ action
|
||||||
|
- Eliminated the permission `enableImport`
|
||||||
|
- Set the exposed port as an environment variable (`PORT`) in `Dockerfile`
|
||||||
|
- Migrated the style of `AboutPageModule` to `@angular/material` `15` (mdc)
|
||||||
|
- Migrated the style of `BlogPageModule` to `@angular/material` `15` (mdc)
|
||||||
|
- Migrated the style of `ChangelogPageModule` to `@angular/material` `15` (mdc)
|
||||||
|
- Migrated the style of `ResourcesPageModule` to `@angular/material` `15` (mdc)
|
||||||
|
- Upgraded `chart.js` from version `4.0.1` to `4.2.0`
|
||||||
|
- Upgraded `ionicons` from version `6.0.4` to `6.1.2`
|
||||||
|
- Upgraded `prettier` from version `2.8.1` to `2.8.4`
|
||||||
|
- Upgraded `prisma` from version `4.9.0` to `4.10.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue on the landing page caused by the global heat map of subscribers
|
||||||
|
- Fixed the links in the interstitial for the subscription
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Remove the environment variable `ENABLE_FEATURE_IMPORT`
|
||||||
|
- Rename the `dataSource` from `GHOSTFOLIO` to `MANUAL`
|
||||||
|
- Eliminate `GhostfolioScraperApiService`
|
||||||
|
|
||||||
|
## 1.233.0 - 2023-02-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to export accounts
|
||||||
|
- Added suport to import accounts
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the styling in the admin control panel
|
||||||
|
- Removed the _Google Play_ badge from the landing page
|
||||||
|
- Upgraded `eslint` dependencies
|
||||||
|
|
||||||
|
## 1.232.0 - 2023-02-05
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Migrated the style of `ActivitiesPageModule` to `@angular/material` `15` (mdc)
|
||||||
|
- Migrated the style of `GfCreateOrUpdateActivityDialogModule` to `@angular/material` `15` (mdc)
|
||||||
|
- Migrated the style of `GfMarketDataDetailDialogModule` to `@angular/material` `15` (mdc)
|
||||||
|
- Upgraded `ng-extract-i18n-merge` from version `2.1.2` to `2.5.0`
|
||||||
|
- Upgraded `ngx-markdown` from version `14.0.1` to `15.1.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the `Upgrade Plan` button of the interstitial for the subscription
|
||||||
|
|
||||||
|
## 1.231.0 - 2023-02-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the dividend and fees to the position detail dialog
|
||||||
|
- Added support to link a (wealth) item to an account
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Relaxed the validation rule of the _Redis_ host environment variable (`REDIS_HOST`)
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Eliminated `angular-material-css-vars`
|
||||||
|
- Upgraded `angular` from version `14.2.0` to `15.1.2`
|
||||||
|
- Upgraded `Nx` from version `15.0.13` to `15.6.3`
|
||||||
|
|
||||||
|
## 1.230.0 - 2023-01-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an interstitial for the subscription
|
||||||
|
- Added _SourceForge_ to the _As seen in_ section on the landing page
|
||||||
|
- Added a quote to the blog post _Ghostfolio auf Sackgeld.com vorgestellt_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the unit format (`%`) in the global heat map component of the public page
|
||||||
|
- Improved the pricing page
|
||||||
|
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
|
||||||
|
- Upgraded `prisma` from version `4.8.0` to `4.9.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the click of unknown accounts in the portfolio proportion chart component
|
||||||
|
- Fixed an issue with `value` in the value redaction interceptor for the impersonation mode
|
||||||
|
|
||||||
|
## 1.229.0 - 2023-01-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Ghostfolio auf Sackgeld.com vorgestellt_
|
||||||
|
- Added _Sackgeld.com_ to the _As seen in_ section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the toggle _Original Shares_ vs. _Current Shares_ on the allocations page
|
||||||
|
- Hid error messages related to no current investment in the client
|
||||||
|
- Refactored the value redaction interceptor for the impersonation mode
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the value of the active (emergency fund) filter in percentage on the allocations page
|
||||||
|
|
||||||
|
## 1.228.1 - 2023-01-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the hints in user settings
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the date formatting in the tooltip of the dividend timeline grouped by month / year
|
||||||
|
- Improved the date formatting in the tooltip of the investment timeline grouped by month / year
|
||||||
|
- Reduced the execution interval of the data gathering to every 4 hours
|
||||||
|
- Removed emergency fund as an asset class
|
||||||
|
|
||||||
|
## 1.227.1 - 2023-01-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the create or edit activity dialog
|
||||||
|
|
||||||
|
## 1.227.0 - 2023-01-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for assets other than cash in emergency fund (affecting buying power)
|
||||||
|
- Added support for translated tags
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the logo alignment
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the grouping by month / year of the dividend and investment timeline
|
||||||
|
|
||||||
|
## 1.226.0 - 2023-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the language localization for Français (`fr`)
|
||||||
|
- Extended the landing page by a global heat map of subscribers
|
||||||
|
- Added support for the thousand separator in the global heat map component
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the form of the import dividends dialog (disable while loading)
|
||||||
|
- Removed the deprecated `~` in _Sass_ imports
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an exception in the _X-ray_ section
|
||||||
|
|
||||||
|
## 1.225.0 - 2023-01-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for importing dividends from a data provider
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the Frequently Asked Questions (FAQ) page
|
||||||
|
|
||||||
|
## 1.224.0 - 2023-01-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the dividend timeline grouped by year
|
||||||
|
- Added support for the investment timeline grouped by year
|
||||||
|
- Set up the language localization for Français (`fr`)
|
||||||
|
- Set up the language localization for Português (`pt`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for Dutch (`nl`)
|
||||||
|
|
||||||
|
## 1.223.0 - 2023-01-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a student discount to the pricing page
|
||||||
|
- Added a prefix to the codes of the coupon system
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the page titles in the header for mobile
|
||||||
|
- Extended the asset profile details dialog in the admin control panel
|
||||||
|
|
||||||
|
## 1.222.0 - 2022-12-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for filtering on the analysis page
|
||||||
|
- Added the price to the `Subscription` database schema
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the execution time of the asset profile data gathering to every Sunday at lunch time
|
||||||
|
- Improved the activities import by providing asset profile details
|
||||||
|
- Upgraded `@codewithdan/observable-store` from version `2.2.11` to `2.2.15`
|
||||||
|
- Upgraded `bull` from version `4.8.5` to `4.10.2`
|
||||||
|
- Upgraded `countup.js` from version `2.0.7` to `2.3.2`
|
||||||
|
- Upgraded the _Internet Identity_ dependencies from version `0.12.1` to `0.15.1`
|
||||||
|
- Upgraded `prisma` from version `4.7.1` to `4.8.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the language localization of the account type
|
||||||
|
|
||||||
|
## 1.221.0 - 2022-12-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to manage the tags in the create or edit activity dialog
|
||||||
|
- Added the tags to the admin control panel
|
||||||
|
- Added a blog post: _The importance of tracking your personal finances_
|
||||||
|
- Resolved the title of the blog post
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the activities import by a preview step
|
||||||
|
- Improved the labels based on the type in the create or edit activity dialog
|
||||||
|
- Refreshed the cryptocurrencies list
|
||||||
|
- Removed the data source type `RAKUTEN`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the date conversion for years with only two digits
|
||||||
|
|
||||||
|
## 1.220.0 - 2022-12-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the position detail dialog to the _Top 3_ and _Bottom 3_ performers of the analysis page
|
||||||
|
- Added the `dryRun` option to the import activities endpoint
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 365 days
|
||||||
|
- Upgraded `color` from version `4.0.1` to `4.2.3`
|
||||||
|
- Upgraded `prettier` from version `2.7.1` to `2.8.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the rounding of the y-axis ticks in the benchmark comparator
|
||||||
|
|
||||||
|
## 1.219.0 - 2022-12-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to disable user sign up in the admin control panel
|
||||||
|
- Extended the glossary of the resources page by _Deflation_, _Inflation_ and _Stagflation_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added the name to the symbol column in the activities table
|
||||||
|
- Combined the name and symbol column in the holdings table (former positions table)
|
||||||
|
|
||||||
|
## 1.218.0 - 2022-12-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the date of the first activity to the positions table
|
||||||
|
- Added an endpoint to fetch the logo of an asset or a platform
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the asset profile details dialog in the admin control panel
|
||||||
|
- Upgraded `chart.js` from version `3.8.0` to `4.0.1`
|
||||||
|
|
||||||
|
## 1.217.0 - 2022-12-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the dividend timeline grouped by month
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the value redaction interceptor (including `comment`)
|
||||||
|
- Improved the language localization for Español (`es`)
|
||||||
|
- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12`
|
||||||
|
- Upgraded `prisma` from version `4.6.1` to `4.7.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the activities sorting in the account detail dialog
|
||||||
|
|
||||||
|
## 1.216.0 - 2022-12-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported a note for asset profiles
|
||||||
|
- Supported a manual currency for the activity fee
|
||||||
|
- Extended the support for column sorting in the accounts table (name, platform, transactions)
|
||||||
|
- Extended the support for column sorting in the activities table (name, symbol)
|
||||||
|
- Extended the support for column sorting in the positions table (performance)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `big.js` from version `6.1.1` to `6.2.1`
|
||||||
|
- Upgraded `date-fns` from version `2.28.0` to `2.29.3`
|
||||||
|
- Upgraded `replace-in-file` from version `6.2.0` to `6.3.5`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the filter by asset sub class for the asset profiles in the admin control
|
||||||
|
|
||||||
|
## 1.215.0 - 2022-11-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language selector on the account page
|
||||||
|
- Improved the wording in the _X-ray_ section (net worth instead of investment)
|
||||||
|
- Extended the asset profile details dialog in the admin control panel
|
||||||
|
- Updated the browserslist database
|
||||||
|
- Upgraded `ionicons` from version `5.5.1` to `6.0.4`
|
||||||
|
- Upgraded `uuid` from version `8.3.2` to `9.0.0`
|
||||||
|
|
||||||
|
## 1.214.0 - 19.11.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for sorting in the accounts table
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the support for the `MANUAL` data source
|
||||||
|
- Improved the _Activities_ tab icon
|
||||||
|
- Improved the _Activities_ icons for `BUY`, `DIVIDEND` and `SELL`
|
||||||
|
- Upgraded `prisma` from version `4.4.0` to `4.6.1`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.3.6` to `2.3.10`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the activities sorting in the position detail dialog
|
||||||
|
- Fixed the dynamic number of decimal places for cryptocurrencies in the position detail dialog
|
||||||
|
- Fixed a division by zero error in the cash positions calculation
|
||||||
|
|
||||||
|
## 1.213.0 - 14.11.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an indicator for excluded accounts in the accounts table
|
||||||
|
- Added a blog post: _Black Friday 2022_
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ZAc` to `ZAR`)
|
||||||
|
|
||||||
|
## 1.212.0 - 11.11.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the view mode selector to a slide toggle
|
||||||
|
- Upgraded `Nx` from version `15.0.0` to `15.0.13`
|
||||||
|
|
||||||
|
## 1.211.0 - 11.11.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Converted the client into a _Progressive Web App_ (PWA) with `@angular/pwa`
|
||||||
|
- Removed the bottom margin from the body element
|
||||||
|
- Improved the pricing page
|
||||||
|
|
||||||
|
## 1.210.0 - 08.11.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added tabs to the portfolio page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Merged the _FIRE_ calculator and the _X-ray_ section to a single page
|
||||||
|
- Tightened the validation rule of the base currency environment variable (`BASE_CURRENCY`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the cash positions calculation
|
||||||
|
|
||||||
## 1.209.0 - 05.11.2022
|
## 1.209.0 - 05.11.2022
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -323,7 +868,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Added the alias to the `Access` database schema
|
- Added the alias to the `Access` database schema
|
||||||
- Added support for translated time distances
|
- Added support for translated time distances
|
||||||
- Added a _GitHub Action_ to create an `arm64` docker image
|
- Added a _GitHub Action_ to create an `linux/arm64` docker image
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -466,7 +1011,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Support a note for activities
|
- Supported a note for activities
|
||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
@ -936,7 +1481,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Beautified the ETF names in the symbol profile
|
- Beautified the ETF names in the asset profile
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@ -1361,7 +1906,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Extended the historical data view in the admin control panel
|
- Extended the historical data view in the admin control panel
|
||||||
- Upgraded _Stripe_ dependencies
|
- Upgraded the _Stripe_ dependencies
|
||||||
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
25
DEVELOPMENT.md
Normal file
25
DEVELOPMENT.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Ghostfolio Development Guide
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
### Rebase
|
||||||
|
|
||||||
|
`git rebase -i --autosquash main`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Nx
|
||||||
|
|
||||||
|
#### Upgrade
|
||||||
|
|
||||||
|
1. Run `yarn nx migrate latest`
|
||||||
|
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||||
|
1. Run `yarn nx migrate --run-migrations`
|
||||||
|
|
||||||
|
### Prisma
|
||||||
|
|
||||||
|
#### Create schema migration (local)
|
||||||
|
|
||||||
|
Run `yarn prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
@ -57,5 +57,5 @@ 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 3333
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "yarn", "start:prod" ]
|
CMD [ "yarn", "start:prod" ]
|
||||||
|
108
README.md
108
README.md
@ -1,32 +1,26 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://ghostfol.io">
|
|
||||||
<img
|
|
||||||
alt="Ghostfolio Logo"
|
|
||||||
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
|
||||||
width="100"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<h1>Ghostfolio</h1>
|
[<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io)
|
||||||
<p>
|
|
||||||
<strong>Open Source Wealth Management Software</strong>
|
# Ghostfolio
|
||||||
</p>
|
|
||||||
<p>
|
**Open Source Wealth Management Software**
|
||||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
|
||||||
</p>
|
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||||
<p>
|
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||||
<a href="#contributing">
|
|
||||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
[](#contributing)
|
||||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[<img src="./apps/client/src/assets/images/video-preview.jpg" width="600" alt="Preview image of the Ghostfolio video trailer">](https://www.youtube.com/watch?v=yY6ObSQVJZk)
|
||||||
|
|
||||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
|
||||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
|
||||||
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
@ -46,23 +40,25 @@ Ghostfolio is for you if you are...
|
|||||||
- 🧘 into minimalism
|
- 🧘 into minimalism
|
||||||
- 🧺 caring about diversifying your financial resources
|
- 🧺 caring about diversifying your financial resources
|
||||||
- 🆓 interested in financial independence
|
- 🆓 interested in financial independence
|
||||||
- 🙅 saying no to spreadsheets in 2022
|
- 🙅 saying no to spreadsheets
|
||||||
- 😎 still reading this list
|
- 😎 still reading this list
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ Multi account management
|
||||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||||
- ✅ Various charts
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Import and export transactions
|
- ✅ Import and export transactions
|
||||||
- ✅ Dark Mode
|
- ✅ Dark Mode
|
||||||
- ✅ Zen Mode
|
- ✅ Zen Mode
|
||||||
- ✅ Mobile-first design
|
- ✅ Progressive Web App (PWA) with a mobile-first design
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
|
||||||
|
|
||||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
|
||||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
@ -79,14 +75,19 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Default Value | Description |
|
| Name | Default Value | Description |
|
||||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
|
|
||||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
@ -104,7 +105,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
- Basic knowledge of Docker
|
- Basic knowledge of Docker
|
||||||
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- Local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
|
||||||
#### a. Run environment
|
#### a. Run environment
|
||||||
|
|
||||||
@ -123,13 +125,10 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Fetch Historical Data
|
#### Setup
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
|
||||||
|
|
||||||
|
1. Open http://localhost:3333 in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
|
||||||
|
|
||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
@ -146,31 +145,34 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- A local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `yarn build:dev` to build the source code including the assets
|
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 and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
|
1. Open http://localhost:4200/en in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
|
||||||
|
|
||||||
### Start Server
|
### Start Server
|
||||||
|
|
||||||
<ol type="a">
|
#### Debug
|
||||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
|
||||||
<li>Serve: Run <code>yarn start:server</code></li>
|
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
</ol>
|
|
||||||
|
#### Serve
|
||||||
|
|
||||||
|
Run `yarn start:server`
|
||||||
|
|
||||||
### Start Client
|
### Start Client
|
||||||
|
|
||||||
Run `yarn start:client`
|
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
||||||
|
|
||||||
### Start _Storybook_
|
### Start _Storybook_
|
||||||
|
|
||||||
@ -217,7 +219,7 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
|||||||
"date": "2021-09-15T00:00:00.000Z",
|
"date": "2021-09-15T00:00:00.000Z",
|
||||||
"fee": 19,
|
"fee": 19,
|
||||||
"quantity": 5,
|
"quantity": 5,
|
||||||
"symbol": "MSFT"
|
"symbol": "MSFT",
|
||||||
"type": "BUY",
|
"type": "BUY",
|
||||||
"unitPrice": 298.58
|
"unitPrice": 298.58
|
||||||
}
|
}
|
||||||
@ -256,16 +258,20 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Community Projects
|
||||||
|
|
||||||
|
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
|
|
||||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
© 2023 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"name": "api",
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "apps/api/src",
|
"sourceRoot": "apps/api/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import {
|
|
||||||
nullifyValuesInObject,
|
|
||||||
nullifyValuesInObjects
|
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
@ -22,7 +19,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
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';
|
||||||
@ -39,8 +37,7 @@ export class AccountController {
|
|||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ -85,8 +82,9 @@ export class AccountController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||||
): Promise<Accounts> {
|
): Promise<Accounts> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
@ -94,41 +92,17 @@ export class AccountController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations =
|
return this.portfolioService.getAccountsWithAggregations({
|
||||||
await this.portfolioService.getAccountsWithAggregations({
|
userId: impersonationUserId || this.request.user.id,
|
||||||
userId: impersonationUserId || this.request.user.id,
|
withExcludedAccounts: true
|
||||||
withExcludedAccounts: true
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationUserId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
accountsWithAggregations = {
|
|
||||||
...nullifyValuesInObject(accountsWithAggregations, [
|
|
||||||
'totalBalanceInBaseCurrency',
|
|
||||||
'totalValueInBaseCurrency'
|
|
||||||
]),
|
|
||||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
|
||||||
'balance',
|
|
||||||
'balanceInBaseCurrency',
|
|
||||||
'convertedBalance',
|
|
||||||
'fee',
|
|
||||||
'quantity',
|
|
||||||
'unitPrice',
|
|
||||||
'value',
|
|
||||||
'valueInBaseCurrency'
|
|
||||||
])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountsWithAggregations;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountById(
|
public async getAccountById(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountWithValue> {
|
): Promise<AccountWithValue> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
@ -137,35 +111,13 @@ export class AccountController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations =
|
const accountsWithAggregations =
|
||||||
await this.portfolioService.getAccountsWithAggregations({
|
await this.portfolioService.getAccountsWithAggregations({
|
||||||
filters: [{ id, type: 'ACCOUNT' }],
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationUserId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
accountsWithAggregations = {
|
|
||||||
...nullifyValuesInObject(accountsWithAggregations, [
|
|
||||||
'totalBalanceInBaseCurrency',
|
|
||||||
'totalValueInBaseCurrency'
|
|
||||||
]),
|
|
||||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
|
||||||
'balance',
|
|
||||||
'balanceInBaseCurrency',
|
|
||||||
'convertedBalance',
|
|
||||||
'fee',
|
|
||||||
'quantity',
|
|
||||||
'unitPrice',
|
|
||||||
'value',
|
|
||||||
'valueInBaseCurrency'
|
|
||||||
])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountsWithAggregations.accounts[0];
|
return accountsWithAggregations.accounts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,6 +17,10 @@ export class CreateAccountDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
id?: string;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isExcluded?: boolean;
|
isExcluded?: boolean;
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
|
EnhancedSymbolProfile,
|
||||||
Filter
|
Filter
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -21,6 +22,7 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
@ -33,6 +35,7 @@ import { isDate } 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';
|
||||||
|
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@ -332,6 +335,32 @@ export class AdminController {
|
|||||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async patchAssetProfileData(
|
||||||
|
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<EnhancedSymbolProfile> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.patchAssetProfileData({
|
||||||
|
...assetProfileData,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
|
@ -116,6 +116,7 @@ export class AdminService {
|
|||||||
},
|
},
|
||||||
assetClass: true,
|
assetClass: true,
|
||||||
assetSubClass: true,
|
assetSubClass: true,
|
||||||
|
comment: true,
|
||||||
countries: true,
|
countries: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
Order: {
|
Order: {
|
||||||
@ -147,9 +148,10 @@ export class AdminService {
|
|||||||
countriesCount,
|
countriesCount,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
sectorsCount,
|
sectorsCount,
|
||||||
activityCount: symbolProfile._count.Order,
|
activitiesCount: symbolProfile._count.Order,
|
||||||
assetClass: symbolProfile.assetClass,
|
assetClass: symbolProfile.assetClass,
|
||||||
assetSubClass: symbolProfile.assetSubClass,
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
|
comment: symbolProfile.comment,
|
||||||
dataSource: symbolProfile.dataSource,
|
dataSource: symbolProfile.dataSource,
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
date: symbolProfile.Order?.[0]?.date,
|
||||||
symbol: symbolProfile.symbol
|
symbol: symbolProfile.symbol
|
||||||
@ -165,8 +167,14 @@ export class AdminService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||||
return {
|
const [[assetProfile], marketData] = await Promise.all([
|
||||||
marketData: await this.marketDataService.marketDataItems({
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
this.marketDataService.marketDataItems({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'asc'
|
date: 'asc'
|
||||||
},
|
},
|
||||||
@ -175,9 +183,37 @@ export class AdminService {
|
|||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetProfile,
|
||||||
|
marketData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async patchAssetProfileData({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
});
|
||||||
|
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
return symbolProfile;
|
||||||
|
}
|
||||||
|
|
||||||
public async putSetting(key: string, value: string) {
|
public async putSetting(key: string, value: string) {
|
||||||
let response: Property;
|
let response: Property;
|
||||||
|
|
||||||
@ -195,12 +231,27 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
let orderBy: any = {
|
||||||
orderBy: {
|
createdAt: 'desc'
|
||||||
|
};
|
||||||
|
let where;
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
orderBy = {
|
||||||
Analytics: {
|
Analytics: {
|
||||||
updatedAt: 'desc'
|
updatedAt: 'desc'
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
where = {
|
||||||
|
NOT: {
|
||||||
|
Analytics: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
|
orderBy,
|
||||||
|
where,
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Account: true, Order: true }
|
select: { Account: true, Order: true }
|
||||||
@ -208,6 +259,7 @@ export class AdminService {
|
|||||||
Analytics: {
|
Analytics: {
|
||||||
select: {
|
select: {
|
||||||
activityCount: true,
|
activityCount: true,
|
||||||
|
country: true,
|
||||||
updatedAt: true
|
updatedAt: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -215,19 +267,16 @@ export class AdminService {
|
|||||||
id: true,
|
id: true,
|
||||||
Subscription: true
|
Subscription: true
|
||||||
},
|
},
|
||||||
take: 30,
|
take: 30
|
||||||
where: {
|
|
||||||
NOT: {
|
|
||||||
Analytics: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
const engagement = Analytics
|
||||||
|
? Analytics.activityCount / daysSinceRegistration
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const subscription = this.configurationService.get(
|
const subscription = this.configurationService.get(
|
||||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
@ -241,7 +290,8 @@ export class AdminService {
|
|||||||
id,
|
id,
|
||||||
subscription,
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
lastActivity: Analytics.updatedAt,
|
country: Analytics?.country,
|
||||||
|
lastActivity: Analytics?.updatedAt,
|
||||||
transactionCount: _count.Order || 0
|
transactionCount: _count.Order || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
13
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
13
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateAssetProfileDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
symbolMapping?: {
|
||||||
|
[dataProvider: string]: string;
|
||||||
|
};
|
||||||
|
}
|
@ -1,33 +1,35 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
|
||||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, RequestMethod } 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 { ConfigurationModule } from '../services/configuration.module';
|
||||||
|
import { CronService } from '../services/cron.service';
|
||||||
|
import { DataGatheringModule } from '../services/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '../services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '../services/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '../services/prisma.module';
|
||||||
|
import { TwitterBotModule } from '../services/twitter-bot/twitter-bot.module';
|
||||||
import { AccessModule } from './access/access.module';
|
import { AccessModule } from './access/access.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
|
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
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 { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { FrontendMiddleware } from './frontend.middleware';
|
import { FrontendMiddleware } from './frontend.middleware';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
|
import { LogoModule } from './logo/logo.module';
|
||||||
import { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||||
|
import { RedisCacheModule } from './redis-cache/redis-cache.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';
|
||||||
@ -43,7 +45,7 @@ import { UserModule } from './user/user.module';
|
|||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
redis: {
|
redis: {
|
||||||
host: process.env.REDIS_HOST,
|
host: process.env.REDIS_HOST,
|
||||||
port: parseInt(process.env.REDIS_PORT, 10),
|
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||||
password: process.env.REDIS_PASSWORD
|
password: process.env.REDIS_PASSWORD
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@ -52,10 +54,12 @@ import { UserModule } from './user/user.module';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
|
LogoModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
Version
|
Version
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@ -58,18 +59,21 @@ export class AuthController {
|
|||||||
@Get('google/callback')
|
@Get('google/callback')
|
||||||
@UseGuards(AuthGuard('google'))
|
@UseGuards(AuthGuard('google'))
|
||||||
@Version(VERSION_NEUTRAL)
|
@Version(VERSION_NEUTRAL)
|
||||||
public googleLoginCallback(@Req() req, @Res() res) {
|
public googleLoginCallback(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
// Handles the Google OAuth2 callback
|
// Handles the Google OAuth2 callback
|
||||||
const jwt: string = req.user.jwt;
|
const jwt: string = (<any>request.user).jwt;
|
||||||
|
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
res.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
res.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||||
|
@ -4,6 +4,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
|
|||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
PropertyModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
@ -11,6 +12,7 @@ export class AuthService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -50,10 +52,19 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (!isUserSignupEnabled) {
|
||||||
|
throw new Error('Sign up forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Create new user if not found
|
// Create new user if not found
|
||||||
user = await this.userService.createUser({
|
user = await this.userService.createUser({
|
||||||
provider,
|
data: {
|
||||||
thirdPartyId: principalId
|
provider,
|
||||||
|
thirdPartyId: principalId
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,10 +89,19 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (!isUserSignupEnabled) {
|
||||||
|
throw new Error('Sign up forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Create new user if not found
|
// Create new user if not found
|
||||||
user = await this.userService.createUser({
|
user = await this.userService.createUser({
|
||||||
provider,
|
data: {
|
||||||
thirdPartyId
|
provider,
|
||||||
|
thirdPartyId
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,33 +1,46 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
public constructor(
|
public constructor(
|
||||||
readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
passReqToCallback: true,
|
||||||
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validate({ id }: { id: string }) {
|
public async validate(request: Request, { id }: { id: string }) {
|
||||||
try {
|
try {
|
||||||
|
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
|
||||||
const user = await this.userService.user({ id });
|
const user = await this.userService.user({ id });
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
await this.prismaService.analytics.upsert({
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
create: { User: { connect: { id: user.id } } },
|
const country =
|
||||||
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||||
where: { userId: user.id }
|
|
||||||
});
|
await this.prismaService.analytics.upsert({
|
||||||
|
create: { country, User: { connect: { id: user.id } } },
|
||||||
|
update: {
|
||||||
|
country,
|
||||||
|
activityCount: { increment: 1 },
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
where: { userId: user.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
} else {
|
} else {
|
||||||
|
@ -30,8 +30,8 @@ export class BenchmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:startDateString')
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('startDateString') startDateString: string,
|
@Param('startDateString') startDateString: string,
|
||||||
|
42
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
42
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Param,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { ExchangeRateService } from './exchange-rate.service';
|
||||||
|
|
||||||
|
@Controller('exchange-rate')
|
||||||
|
export class ExchangeRateController {
|
||||||
|
public constructor(
|
||||||
|
private readonly exchangeRateService: ExchangeRateService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':symbol/:dateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getExchangeRate(
|
||||||
|
@Param('dateString') dateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<IDataProviderHistoricalResponse> {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exchangeRate) {
|
||||||
|
return { marketPrice: exchangeRate };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/exchange-rate/exchange-rate.module.ts
Normal file
13
apps/api/src/app/exchange-rate/exchange-rate.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ExchangeRateController } from './exchange-rate.controller';
|
||||||
|
import { ExchangeRateService } from './exchange-rate.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ExchangeRateController],
|
||||||
|
exports: [ExchangeRateService],
|
||||||
|
imports: [ExchangeRateDataModule],
|
||||||
|
providers: [ExchangeRateService]
|
||||||
|
})
|
||||||
|
export class ExchangeRateModule {}
|
26
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
26
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExchangeRateService {
|
||||||
|
public constructor(
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getExchangeRate({
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
date: Date;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<number> {
|
||||||
|
const [currency1, currency2] = symbol.split('-');
|
||||||
|
|
||||||
|
return this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
1,
|
||||||
|
currency1,
|
||||||
|
currency2,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -14,6 +14,22 @@ export class ExportService {
|
|||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
|
const accounts = await this.prismaService.account.findMany({
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc'
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
accountType: true,
|
||||||
|
balance: true,
|
||||||
|
currency: true,
|
||||||
|
id: true,
|
||||||
|
isExcluded: true,
|
||||||
|
name: true,
|
||||||
|
platformId: true
|
||||||
|
},
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
let activities = await this.prismaService.order.findMany({
|
let activities = await this.prismaService.order.findMany({
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
@ -38,6 +54,7 @@ export class ExportService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
meta: { date: new Date().toISOString(), version: environment.version },
|
meta: { date: new Date().toISOString(), version: environment.version },
|
||||||
|
accounts,
|
||||||
activities: activities.map(
|
activities: activities.map(
|
||||||
({
|
({
|
||||||
accountId,
|
accountId,
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { format } from 'date-fns';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -12,20 +14,14 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
public indexHtmlDe = '';
|
public indexHtmlDe = '';
|
||||||
public indexHtmlEn = '';
|
public indexHtmlEn = '';
|
||||||
public indexHtmlEs = '';
|
public indexHtmlEs = '';
|
||||||
|
public indexHtmlFr = '';
|
||||||
public indexHtmlIt = '';
|
public indexHtmlIt = '';
|
||||||
public indexHtmlNl = '';
|
public indexHtmlNl = '';
|
||||||
public isProduction: boolean;
|
public indexHtmlPt = '';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
) {
|
) {
|
||||||
const NODE_ENV =
|
|
||||||
this.configService.get<'development' | 'production'>('NODE_ENV') ??
|
|
||||||
'development';
|
|
||||||
|
|
||||||
this.isProduction = NODE_ENV === 'production';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.indexHtmlDe = fs.readFileSync(
|
this.indexHtmlDe = fs.readFileSync(
|
||||||
this.getPathOfIndexHtmlFile('de'),
|
this.getPathOfIndexHtmlFile('de'),
|
||||||
@ -39,6 +35,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
this.getPathOfIndexHtmlFile('es'),
|
this.getPathOfIndexHtmlFile('es'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
this.indexHtmlFr = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('fr'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
this.indexHtmlIt = fs.readFileSync(
|
this.indexHtmlIt = fs.readFileSync(
|
||||||
this.getPathOfIndexHtmlFile('it'),
|
this.getPathOfIndexHtmlFile('it'),
|
||||||
'utf8'
|
'utf8'
|
||||||
@ -47,73 +47,125 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
this.getPathOfIndexHtmlFile('nl'),
|
this.getPathOfIndexHtmlFile('nl'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
this.indexHtmlPt = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('pt'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
public use(req: Request, res: Response, next: NextFunction) {
|
public use(request: Request, response: Response, next: NextFunction) {
|
||||||
|
const currentDate = format(new Date(), DATE_FORMAT);
|
||||||
let featureGraphicPath = 'assets/cover.png';
|
let featureGraphicPath = 'assets/cover.png';
|
||||||
|
let title = 'Ghostfolio – Open Source Wealth Management Software';
|
||||||
|
|
||||||
if (
|
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||||
req.path === '/en/blog/2022/08/500-stars-on-github' ||
|
|
||||||
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||||
} else if (
|
title = `500 Stars - ${title}`;
|
||||||
req.path === '/en/blog/2022/10/hacktoberfest-2022' ||
|
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||||
req.path === '/en/blog/2022/10/hacktoberfest-2022/'
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.path.startsWith('/api/') ||
|
request.path.startsWith('/api/') ||
|
||||||
this.isFileRequest(req.url) ||
|
this.isFileRequest(request.url) ||
|
||||||
!this.isProduction
|
!environment.production
|
||||||
) {
|
) {
|
||||||
// Skip
|
// Skip
|
||||||
next();
|
next();
|
||||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||||
res.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlDe, {
|
this.interpolate(this.indexHtmlDe, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
languageCode: 'de',
|
languageCode: 'de',
|
||||||
path: req.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (req.path === '/es' || req.path.startsWith('/es/')) {
|
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
|
||||||
res.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlEs, {
|
this.interpolate(this.indexHtmlEs, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
languageCode: 'es',
|
languageCode: 'es',
|
||||||
path: req.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (req.path === '/it' || req.path.startsWith('/it/')) {
|
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||||
res.send(
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlFr, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'fr',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
||||||
|
response.send(
|
||||||
this.interpolate(this.indexHtmlIt, {
|
this.interpolate(this.indexHtmlIt, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
languageCode: 'it',
|
languageCode: 'it',
|
||||||
path: req.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) {
|
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
|
||||||
res.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlNl, {
|
this.interpolate(this.indexHtmlNl, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
languageCode: 'nl',
|
languageCode: 'nl',
|
||||||
path: req.path,
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlPt, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'pt',
|
||||||
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
res.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlEn, {
|
this.interpolate(this.indexHtmlEn, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||||
path: req.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1,8 +1,15 @@
|
|||||||
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, ValidateNested } from 'class-validator';
|
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
export class ImportDataDto {
|
export class ImportDataDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@Type(() => CreateAccountDto)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
accounts: CreateAccountDto[];
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@Type(() => CreateOrderDto)
|
@Type(() => CreateOrderDto)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
@ -1,16 +1,25 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
UseGuards
|
Query,
|
||||||
|
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 } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { ImportDataDto } from './import-data.dto';
|
import { ImportDataDto } from './import-data.dto';
|
||||||
@ -26,8 +35,17 @@ export class ImportController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
public async import(
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
@Body() importData: ImportDataDto,
|
||||||
|
@Query('dryRun') isDryRun?: boolean
|
||||||
|
): Promise<ImportResponse> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.createAccount
|
||||||
|
) ||
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||||
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -45,12 +63,19 @@ export class ImportController {
|
|||||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.importService.import({
|
const activities = await this.importService.import({
|
||||||
|
isDryRun,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
activities: importData.activities,
|
userCurrency,
|
||||||
|
accountsDto: importData.accounts ?? [],
|
||||||
|
activitiesDto: importData.activities,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { activities };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, ImportController);
|
Logger.error(error, ImportController);
|
||||||
|
|
||||||
@ -63,4 +88,23 @@ export class ImportController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dividends/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async gatherDividends(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<ImportResponse> {
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
|
const activities = await this.importService.getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
});
|
||||||
|
|
||||||
|
return { activities };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
@ -19,9 +22,12 @@ import { ImportService } from './import.service';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [ImportService]
|
providers: [ImportService]
|
||||||
})
|
})
|
||||||
|
@ -1,30 +1,176 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
AccountWithPlatform,
|
||||||
|
OrderWithAccount
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isSameDay, parseISO } from 'date-fns';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly orderService: OrderService
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly orderService: OrderService,
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
||||||
|
try {
|
||||||
|
const { firstBuyDate, historicalData, orders } =
|
||||||
|
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||||
|
|
||||||
|
const [[assetProfile], dividends] = await Promise.all([
|
||||||
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
await this.dataProviderService.getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
from: parseDate(firstBuyDate),
|
||||||
|
granularity: 'day',
|
||||||
|
to: new Date()
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const accounts = orders.map((order) => {
|
||||||
|
return order.Account;
|
||||||
|
});
|
||||||
|
|
||||||
|
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||||
|
|
||||||
|
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
|
||||||
|
const quantity =
|
||||||
|
historicalData.find((historicalDataItem) => {
|
||||||
|
return historicalDataItem.date === dateString;
|
||||||
|
})?.quantity ?? 0;
|
||||||
|
|
||||||
|
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
Account,
|
||||||
|
quantity,
|
||||||
|
value,
|
||||||
|
accountId: Account?.id,
|
||||||
|
accountUserId: undefined,
|
||||||
|
comment: undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
date: parseDate(dateString),
|
||||||
|
fee: 0,
|
||||||
|
feeInBaseCurrency: 0,
|
||||||
|
id: assetProfile.id,
|
||||||
|
isDraft: false,
|
||||||
|
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||||
|
symbolProfileId: assetProfile.id,
|
||||||
|
type: 'DIVIDEND',
|
||||||
|
unitPrice: marketPrice,
|
||||||
|
updatedAt: undefined,
|
||||||
|
userId: Account?.userId,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
assetProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async import({
|
public async import({
|
||||||
activities,
|
accountsDto,
|
||||||
|
activitiesDto,
|
||||||
|
isDryRun = false,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
accountsDto: Partial<CreateAccountDto>[];
|
||||||
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
isDryRun?: boolean;
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<Activity[]> {
|
||||||
for (const activity of activities) {
|
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||||
|
|
||||||
|
if (!isDryRun && accountsDto?.length) {
|
||||||
|
const existingAccounts = await this.accountService.accounts({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: accountsDto.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const account of accountsDto) {
|
||||||
|
// Check if there is any existing account with the same ID
|
||||||
|
const accountWithSameId = existingAccounts.find(
|
||||||
|
(existingAccount) => existingAccount.id === account.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there is no account or if the account belongs to a different user then create a new account
|
||||||
|
if (!accountWithSameId || accountWithSameId.userId !== userId) {
|
||||||
|
let oldAccountId: string;
|
||||||
|
const platformId = account.platformId;
|
||||||
|
|
||||||
|
delete account.platformId;
|
||||||
|
|
||||||
|
if (accountWithSameId) {
|
||||||
|
oldAccountId = account.id;
|
||||||
|
delete account.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccountObject = {
|
||||||
|
...account,
|
||||||
|
User: { connect: { id: userId } }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (platformId) {
|
||||||
|
Object.assign(newAccountObject, {
|
||||||
|
Platform: { connect: { id: platformId } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccount = await this.accountService.createAccount(
|
||||||
|
newAccountObject,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the new to old account ID mappings for updating activities
|
||||||
|
if (accountWithSameId && oldAccountId) {
|
||||||
|
accountIdMapping[oldAccountId] = newAccount.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const activity of activitiesDto) {
|
||||||
if (!activity.dataSource) {
|
if (!activity.dataSource) {
|
||||||
if (activity.type === 'ITEM') {
|
if (activity.type === 'ITEM') {
|
||||||
activity.dataSource = 'MANUAL';
|
activity.dataSource = 'MANUAL';
|
||||||
@ -32,74 +178,172 @@ export class ImportService {
|
|||||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a new account is created, then update the accountId in all activities
|
||||||
|
if (!isDryRun) {
|
||||||
|
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
|
||||||
|
activity.accountId = accountIdMapping[activity.accountId];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.validateActivities({
|
const assetProfiles = await this.validateActivities({
|
||||||
activities,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||||
(account) => {
|
(account) => {
|
||||||
return account.id;
|
return { id: account.id, name: account.name };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isDryRun) {
|
||||||
|
accountsDto.forEach(({ id, name }) => {
|
||||||
|
accounts.push({ id, name });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities: Activity[] = [];
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
accountId,
|
accountId,
|
||||||
comment,
|
comment,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date: dateString,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
symbol,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
} of activities) {
|
} of activitiesDto) {
|
||||||
await this.orderService.createOrder({
|
const date = parseISO(<string>(<unknown>dateString));
|
||||||
comment,
|
const validatedAccount = accounts.find(({ id }) => {
|
||||||
fee,
|
return id === accountId;
|
||||||
quantity,
|
});
|
||||||
type,
|
|
||||||
unitPrice,
|
let order:
|
||||||
userId,
|
| OrderWithAccount
|
||||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
| (Omit<OrderWithAccount, 'Account'> & {
|
||||||
date: parseISO(<string>(<unknown>date)),
|
Account?: { id: string; name: string };
|
||||||
SymbolProfile: {
|
});
|
||||||
connectOrCreate: {
|
|
||||||
create: {
|
if (isDryRun) {
|
||||||
currency,
|
order = {
|
||||||
dataSource,
|
comment,
|
||||||
symbol
|
date,
|
||||||
},
|
fee,
|
||||||
where: {
|
quantity,
|
||||||
dataSource_symbol: {
|
type,
|
||||||
|
unitPrice,
|
||||||
|
userId,
|
||||||
|
accountId: validatedAccount?.id,
|
||||||
|
accountUserId: undefined,
|
||||||
|
createdAt: new Date(),
|
||||||
|
id: uuidv4(),
|
||||||
|
isDraft: isAfter(date, endOfToday()),
|
||||||
|
SymbolProfile: {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
assetClass: null,
|
||||||
|
assetSubClass: null,
|
||||||
|
comment: null,
|
||||||
|
countries: null,
|
||||||
|
createdAt: undefined,
|
||||||
|
id: undefined,
|
||||||
|
isin: null,
|
||||||
|
name: null,
|
||||||
|
scraperConfiguration: null,
|
||||||
|
sectors: null,
|
||||||
|
symbolMapping: null,
|
||||||
|
updatedAt: undefined,
|
||||||
|
url: null,
|
||||||
|
...assetProfiles[symbol]
|
||||||
|
},
|
||||||
|
Account: validatedAccount,
|
||||||
|
symbolProfileId: undefined,
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
order = await this.orderService.createOrder({
|
||||||
|
comment,
|
||||||
|
date,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
userId,
|
||||||
|
accountId: validatedAccount?.id,
|
||||||
|
SymbolProfile: {
|
||||||
|
connectOrCreate: {
|
||||||
|
create: {
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource_symbol: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
User: { connect: { id: userId } }
|
||||||
User: { connect: { id: userId } }
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
activities.push({
|
||||||
|
...order,
|
||||||
|
value,
|
||||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
fee,
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
||||||
|
const uniqueAccountIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
uniqueAccountIds.add(account.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueAccountIds.size === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activities,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
if (activities?.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: {
|
||||||
|
[symbol: string]: Partial<SymbolProfile>;
|
||||||
|
} = {};
|
||||||
const existingActivities = await this.orderService.orders({
|
const existingActivities = await this.orderService.orders({
|
||||||
include: { SymbolProfile: true },
|
include: { SymbolProfile: true },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
@ -109,7 +353,7 @@ export class ImportService {
|
|||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||||
] of activities.entries()) {
|
] of activitiesDto.entries()) {
|
||||||
const duplicateActivity = existingActivities.find((activity) => {
|
const duplicateActivity = existingActivities.find((activity) => {
|
||||||
return (
|
return (
|
||||||
activity.SymbolProfile.currency === currency &&
|
activity.SymbolProfile.currency === currency &&
|
||||||
@ -128,22 +372,28 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dataSource !== 'MANUAL') {
|
if (dataSource !== 'MANUAL') {
|
||||||
const quotes = await this.dataProviderService.getQuotes([
|
const assetProfile = (
|
||||||
{ dataSource, symbol }
|
await this.dataProviderService.getAssetProfiles([
|
||||||
]);
|
{ dataSource, symbol }
|
||||||
|
])
|
||||||
|
)?.[symbol];
|
||||||
|
|
||||||
if (quotes[symbol] === undefined) {
|
if (assetProfile === undefined) {
|
||||||
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 (quotes[symbol].currency !== currency) {
|
if (assetProfile.currency !== currency) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assetProfiles[symbol] = assetProfile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return assetProfiles;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,8 @@ import { PrismaService } from '@ghostfolio/api/services/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 {
|
||||||
DEMO_USER_ID,
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
|
PROPERTY_DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
@ -58,9 +59,7 @@ export class InfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
if (
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
|
||||||
) {
|
|
||||||
info.fearAndGreedDataSource = encodeDataSource(
|
info.fearAndGreedDataSource = encodeDataSource(
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
);
|
);
|
||||||
@ -71,10 +70,6 @@ export class InfoService {
|
|||||||
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
|
||||||
globalPermissions.push(permissions.enableImport);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||||
PROPERTY_IS_READ_ONLY_MODE
|
PROPERTY_IS_READ_ONLY_MODE
|
||||||
@ -92,6 +87,10 @@ export class InfoService {
|
|||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
globalPermissions.push(permissions.enableSubscription);
|
globalPermissions.push(permissions.enableSubscription);
|
||||||
|
|
||||||
|
info.countriesOfSubscribers =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
|
||||||
|
)) as string[]) ?? [];
|
||||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,6 +102,13 @@ export class InfoService {
|
|||||||
)) as string;
|
)) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (isUserSignupEnabled) {
|
||||||
|
globalPermissions.push(permissions.createUserAccount);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...info,
|
...info,
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
@ -112,7 +118,7 @@ export class InfoService {
|
|||||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: await this.getDemoAuthToken(),
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions(),
|
subscriptions: await this.getSubscriptions(),
|
||||||
tags: await this.tagService.get()
|
tags: await this.tagService.get()
|
||||||
@ -240,10 +246,18 @@ export class InfoService {
|
|||||||
)) as string;
|
)) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private async getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
const demoUserId = (await this.propertyService.getByKey(
|
||||||
id: DEMO_USER_ID
|
PROPERTY_DEMO_USER_ID
|
||||||
});
|
)) as string;
|
||||||
|
|
||||||
|
if (demoUserId) {
|
||||||
|
return this.jwtService.sign({
|
||||||
|
id: demoUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getStatistics() {
|
private async getStatistics() {
|
||||||
@ -295,14 +309,14 @@ export class InfoService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeConfig = await this.prismaService.property.findUnique({
|
let subscriptions: Subscription[] = [];
|
||||||
|
|
||||||
|
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||||
});
|
})) ?? { value: '{}' };
|
||||||
|
|
||||||
if (stripeConfig) {
|
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||||
return [JSON.parse(stripeConfig.value)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
return subscriptions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
54
apps/api/src/app/logo/logo.controller.ts
Normal file
54
apps/api/src/app/logo/logo.controller.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
import { LogoService } from './logo.service';
|
||||||
|
|
||||||
|
@Controller('logo')
|
||||||
|
export class LogoController {
|
||||||
|
public constructor(private readonly logoService: LogoService) {}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getLogoByDataSourceAndSymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
response.contentType('image/png');
|
||||||
|
response.send(buffer);
|
||||||
|
} catch {
|
||||||
|
response.status(HttpStatus.NOT_FOUND).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
public async getLogoByUrl(
|
||||||
|
@Query('url') url: string,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const buffer = await this.logoService.getLogoByUrl(url);
|
||||||
|
|
||||||
|
response.contentType('image/png');
|
||||||
|
response.send(buffer);
|
||||||
|
} catch {
|
||||||
|
response.status(HttpStatus.NOT_FOUND).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/logo/logo.module.ts
Normal file
13
apps/api/src/app/logo/logo.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { LogoController } from './logo.controller';
|
||||||
|
import { LogoService } from './logo.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [LogoController],
|
||||||
|
imports: [ConfigurationModule, SymbolProfileModule],
|
||||||
|
providers: [LogoService]
|
||||||
|
})
|
||||||
|
export class LogoModule {}
|
55
apps/api/src/app/logo/logo.service.ts
Normal file
55
apps/api/src/app/logo/logo.service.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import * as bent from 'bent';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LogoService {
|
||||||
|
public constructor(
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getLogoByDataSourceAndSymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset) {
|
||||||
|
if (!DataSource[dataSource]) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getBuffer(assetProfile.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLogoByUrl(aUrl: string) {
|
||||||
|
return this.getBuffer(aUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBuffer(aUrl: string) {
|
||||||
|
const get = bent(
|
||||||
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
|
'GET',
|
||||||
|
'buffer',
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
'User-Agent': 'request'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return get();
|
||||||
|
}
|
||||||
|
}
|
@ -6,5 +6,6 @@ export interface Activities {
|
|||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends OrderWithAccount {
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -39,8 +38,7 @@ export class OrderController {
|
|||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
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
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ -69,7 +67,7 @@ export class OrderController {
|
|||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
@ -87,7 +85,7 @@ export class OrderController {
|
|||||||
);
|
);
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
let activities = await this.orderService.getOrders({
|
const activities = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
@ -95,20 +93,6 @@ export class OrderController {
|
|||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationUserId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
activities = nullifyValuesInObjects(activities, [
|
|
||||||
'fee',
|
|
||||||
'feeInBaseCurrency',
|
|
||||||
'quantity',
|
|
||||||
'unitPrice',
|
|
||||||
'value',
|
|
||||||
'valueInBaseCurrency'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { activities };
|
return { activities };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,23 +76,21 @@ export class OrderService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
const defaultAccount = (
|
let Account;
|
||||||
await this.accountService.getAccounts(data.userId)
|
|
||||||
).find((account) => {
|
if (data.accountId) {
|
||||||
return account.isDefault === true;
|
Account = {
|
||||||
});
|
connect: {
|
||||||
|
id_userId: {
|
||||||
|
userId: data.userId,
|
||||||
|
id: data.accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const tags = data.tags ?? [];
|
const tags = data.tags ?? [];
|
||||||
|
|
||||||
let Account = {
|
|
||||||
connect: {
|
|
||||||
id_userId: {
|
|
||||||
userId: data.userId,
|
|
||||||
id: data.accountId ?? defaultAccount?.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM') {
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
@ -101,7 +99,6 @@ export class OrderService {
|
|||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||||
|
|
||||||
Account = undefined;
|
|
||||||
data.id = id;
|
data.id = id;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||||
@ -113,9 +110,6 @@ export class OrderService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol: id
|
symbol: id
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol =
|
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
await this.dataGatheringService.addJobToQueue(
|
||||||
@ -362,6 +356,12 @@ export class OrderService {
|
|||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
|
|
||||||
|
// Remove existing tags
|
||||||
|
await this.prismaService.order.update({
|
||||||
|
data: { tags: { set: [] } },
|
||||||
|
where
|
||||||
|
});
|
||||||
|
|
||||||
return this.prismaService.order.update({
|
return this.prismaService.order.update({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
@ -48,8 +49,11 @@ export const CurrentRateServiceMock = {
|
|||||||
getValues: ({
|
getValues: ({
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery
|
dateQuery
|
||||||
}: GetValuesParams): Promise<GetValueObject[]> => {
|
}: GetValuesParams): Promise<{
|
||||||
const result: GetValueObject[] = [];
|
dataProviderInfos: DataProviderInfo[];
|
||||||
|
values: GetValueObject[];
|
||||||
|
}> => {
|
||||||
|
const values: GetValueObject[] = [];
|
||||||
if (dateQuery.lt) {
|
if (dateQuery.lt) {
|
||||||
for (
|
for (
|
||||||
let date = resetHours(dateQuery.gte);
|
let date = resetHours(dateQuery.gte);
|
||||||
@ -57,7 +61,7 @@ export const CurrentRateServiceMock = {
|
|||||||
date = addDays(date, 1)
|
date = addDays(date, 1)
|
||||||
) {
|
) {
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
dataGatheringItem.symbol,
|
dataGatheringItem.symbol,
|
||||||
@ -70,7 +74,7 @@ export const CurrentRateServiceMock = {
|
|||||||
} else {
|
} else {
|
||||||
for (const date of dateQuery.in) {
|
for (const date of dateQuery.in) {
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
dataGatheringItem.symbol,
|
dataGatheringItem.symbol,
|
||||||
@ -81,6 +85,6 @@ export const CurrentRateServiceMock = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.resolve(result);
|
return Promise.resolve({ values, dataProviderInfos: [] });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
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.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
|
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
@ -78,6 +79,7 @@ describe('CurrentRateService', () => {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
marketDataService = new MarketDataService(null);
|
marketDataService = new MarketDataService(null);
|
||||||
@ -102,17 +104,23 @@ describe('CurrentRateService', () => {
|
|||||||
},
|
},
|
||||||
userCurrency: 'CHF'
|
userCurrency: 'CHF'
|
||||||
})
|
})
|
||||||
).toMatchObject<GetValueObject[]>([
|
).toMatchObject<{
|
||||||
{
|
dataProviderInfos: DataProviderInfo[];
|
||||||
date: undefined,
|
values: GetValueObject[];
|
||||||
marketPriceInBaseCurrency: 1841.823902,
|
}>({
|
||||||
symbol: 'AMZN'
|
dataProviderInfos: [],
|
||||||
},
|
values: [
|
||||||
{
|
{
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPriceInBaseCurrency: 1847.839966,
|
marketPriceInBaseCurrency: 1841.823902,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
}
|
},
|
||||||
]);
|
{
|
||||||
|
date: undefined,
|
||||||
|
marketPriceInBaseCurrency: 1847.839966,
|
||||||
|
symbol: 'AMZN'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
import { flatten } from 'lodash';
|
import { flatten } from 'lodash';
|
||||||
@ -22,7 +23,11 @@ export class CurrentRateService {
|
|||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery,
|
dateQuery,
|
||||||
userCurrency
|
userCurrency
|
||||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
}: GetValuesParams): Promise<{
|
||||||
|
dataProviderInfos: DataProviderInfo[];
|
||||||
|
values: GetValueObject[];
|
||||||
|
}> {
|
||||||
|
const dataProviderInfos: DataProviderInfo[] = [];
|
||||||
const includeToday =
|
const includeToday =
|
||||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
@ -38,6 +43,14 @@ export class CurrentRateService {
|
|||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result: GetValueObject[] = [];
|
const result: GetValueObject[] = [];
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
|
if (
|
||||||
|
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||||
|
) {
|
||||||
|
dataProviderInfos.push(
|
||||||
|
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
date: today,
|
date: today,
|
||||||
marketPriceInBaseCurrency:
|
marketPriceInBaseCurrency:
|
||||||
@ -81,7 +94,10 @@ export class CurrentRateService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return flatten(await Promise.all(promises));
|
return {
|
||||||
|
dataProviderInfos,
|
||||||
|
values: flatten(await Promise.all(promises))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private containsToday(dates: Date[]): boolean {
|
private containsToday(dates: Date[]): boolean {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
HistoricalDataItem
|
HistoricalDataItem
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -7,6 +8,9 @@ import { Tag } from '@prisma/client';
|
|||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
|
dataProviderInfo: DataProviderInfo;
|
||||||
|
dividendInBaseCurrency: number;
|
||||||
|
feeInBaseCurrency: number;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
grossPerformancePercent: number;
|
grossPerformancePercent: number;
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('0'),
|
averagePrice: new Big('0'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('3.2'),
|
||||||
firstBuyDate: '2021-11-22',
|
firstBuyDate: '2021-11-22',
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -70,6 +71,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('136.6'),
|
averagePrice: new Big('136.6'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('1.55'),
|
||||||
firstBuyDate: '2021-11-30',
|
firstBuyDate: '2021-11-30',
|
||||||
grossPerformance: new Big('24.6'),
|
grossPerformance: new Big('24.6'),
|
||||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('320.43'),
|
averagePrice: new Big('320.43'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('0'),
|
||||||
firstBuyDate: '2015-01-01',
|
firstBuyDate: '2015-01-01',
|
||||||
grossPerformance: new Big('27172.74'),
|
grossPerformance: new Big('27172.74'),
|
||||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||||
|
@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('75.80'),
|
averagePrice: new Big('75.80'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('4.25'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||||
|
@ -68,7 +68,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -101,6 +102,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('0'),
|
averagePrice: new Big('0'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('0'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('19.86'),
|
grossPerformance: new Big('19.86'),
|
||||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
@ -1,7 +1,12 @@
|
|||||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
|
ResponseError,
|
||||||
|
TimelinePosition
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { GroupBy } from '@ghostfolio/common/types';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Type as TypeOfOrder } from '@prisma/client';
|
import { Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -44,6 +49,7 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
private currency: string;
|
private currency: string;
|
||||||
private currentRateService: CurrentRateService;
|
private currentRateService: CurrentRateService;
|
||||||
|
private dataProviderInfos: DataProviderInfo[];
|
||||||
private orders: PortfolioOrder[];
|
private orders: PortfolioOrder[];
|
||||||
private transactionPoints: TransactionPoint[];
|
private transactionPoints: TransactionPoint[];
|
||||||
|
|
||||||
@ -201,14 +207,17 @@ export class PortfolioCalculator {
|
|||||||
symbols[item.symbol] = true;
|
symbols[item.symbol] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
const { dataProviderInfos, values: marketSymbols } =
|
||||||
currencies,
|
await this.currentRateService.getValues({
|
||||||
dataGatheringItems,
|
currencies,
|
||||||
dateQuery: {
|
dataGatheringItems,
|
||||||
in: dates
|
dateQuery: {
|
||||||
},
|
in: dates
|
||||||
userCurrency: this.currency
|
},
|
||||||
});
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
const marketSymbolMap: {
|
const marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
@ -367,14 +376,17 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
dates.push(resetHours(end));
|
dates.push(resetHours(end));
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
const { dataProviderInfos, values: marketSymbols } =
|
||||||
currencies,
|
await this.currentRateService.getValues({
|
||||||
dataGatheringItems,
|
currencies,
|
||||||
dateQuery: {
|
dataGatheringItems,
|
||||||
in: dates
|
dateQuery: {
|
||||||
},
|
in: dates
|
||||||
userCurrency: this.currency
|
},
|
||||||
});
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
const marketSymbolMap: {
|
const marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
@ -430,6 +442,7 @@ export class PortfolioCalculator {
|
|||||||
: item.investment.div(item.quantity),
|
: item.investment.div(item.quantity),
|
||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
dataSource: item.dataSource,
|
dataSource: item.dataSource,
|
||||||
|
fee: item.fee,
|
||||||
firstBuyDate: item.firstBuyDate,
|
firstBuyDate: item.firstBuyDate,
|
||||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||||
grossPerformancePercentage: !hasErrors
|
grossPerformancePercentage: !hasErrors
|
||||||
@ -446,7 +459,7 @@ export class PortfolioCalculator {
|
|||||||
transactionCount: item.transactionCount
|
transactionCount: item.transactionCount
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasErrors) {
|
if (hasErrors && item.investment.gt(0)) {
|
||||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -461,6 +474,10 @@ export class PortfolioCalculator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDataProviderInfos() {
|
||||||
|
return this.dataProviderInfos;
|
||||||
|
}
|
||||||
|
|
||||||
public getInvestments(): { date: string; investment: Big }[] {
|
public getInvestments(): { date: string; investment: Big }[] {
|
||||||
if (this.transactionPoints.length === 0) {
|
if (this.transactionPoints.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@ -478,46 +495,60 @@ export class PortfolioCalculator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
public getInvestmentsByGroup(
|
||||||
|
groupBy: GroupBy
|
||||||
|
): { date: string; investment: Big }[] {
|
||||||
if (this.orders.length === 0) {
|
if (this.orders.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const investments = [];
|
const investments = [];
|
||||||
let currentDate: Date;
|
let currentDate: Date;
|
||||||
let investmentByMonth = new Big(0);
|
let investmentByGroup = new Big(0);
|
||||||
|
|
||||||
for (const [index, order] of this.orders.entries()) {
|
for (const [index, order] of this.orders.entries()) {
|
||||||
if (
|
if (
|
||||||
isSameMonth(parseDate(order.date), currentDate) &&
|
isSameYear(parseDate(order.date), currentDate) &&
|
||||||
isSameYear(parseDate(order.date), currentDate)
|
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||||
) {
|
) {
|
||||||
// Same month: Add up investments
|
// Same group: Add up investments
|
||||||
|
|
||||||
investmentByMonth = investmentByMonth.plus(
|
investmentByGroup = investmentByGroup.plus(
|
||||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// New month: Store previous month and reset
|
// New group: Store previous group and reset
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDate = parseDate(order.date);
|
currentDate = parseDate(order.date);
|
||||||
investmentByMonth = order.quantity
|
investmentByGroup = order.quantity
|
||||||
.mul(order.unitPrice)
|
.mul(order.unitPrice)
|
||||||
.mul(this.getFactor(order.type));
|
.mul(this.getFactor(order.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === this.orders.length - 1) {
|
if (index === this.orders.length - 1) {
|
||||||
// Store current month (latest order)
|
// Store current group (latest order)
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -732,7 +763,7 @@ export class PortfolioCalculator {
|
|||||||
let marketSymbols: GetValueObject[] = [];
|
let marketSymbols: GetValueObject[] = [];
|
||||||
if (dataGatheringItems.length > 0) {
|
if (dataGatheringItems.length > 0) {
|
||||||
try {
|
try {
|
||||||
marketSymbols = await this.currentRateService.getValues({
|
const { values } = await this.currentRateService.getValues({
|
||||||
currencies,
|
currencies,
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery: {
|
dateQuery: {
|
||||||
@ -741,6 +772,7 @@ export class PortfolioCalculator {
|
|||||||
},
|
},
|
||||||
userCurrency: this.currency
|
userCurrency: this.currency
|
||||||
});
|
});
|
||||||
|
marketSymbols = values;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
`Failed to fetch info for date ${startDate} with exception`,
|
||||||
@ -917,12 +949,8 @@ export class PortfolioCalculator {
|
|||||||
const investmentValues: { [date: string]: Big } = {};
|
const investmentValues: { [date: string]: Big } = {};
|
||||||
const maxInvestmentValues: { [date: string]: Big } = {};
|
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||||
let lastAveragePrice = new Big(0);
|
let lastAveragePrice = new Big(0);
|
||||||
// let lastTransactionInvestment = new Big(0);
|
|
||||||
// let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
|
||||||
let maxTotalInvestment = new Big(0);
|
let maxTotalInvestment = new Big(0);
|
||||||
const netPerformanceValues: { [date: string]: Big } = {};
|
const netPerformanceValues: { [date: string]: Big } = {};
|
||||||
// let timeWeightedGrossPerformancePercentage = new Big(1);
|
|
||||||
// let timeWeightedNetPerformancePercentage = new Big(1);
|
|
||||||
let totalInvestment = new Big(0);
|
let totalInvestment = new Big(0);
|
||||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||||
let totalUnits = new Big(0);
|
let totalUnits = new Big(0);
|
||||||
@ -1128,55 +1156,8 @@ export class PortfolioCalculator {
|
|||||||
.minus(totalInvestment)
|
.minus(totalInvestment)
|
||||||
.plus(grossPerformanceFromSells);
|
.plus(grossPerformanceFromSells);
|
||||||
|
|
||||||
// if (
|
|
||||||
// i > indexOfStartOrder &&
|
|
||||||
// !lastValueOfInvestmentBeforeTransaction
|
|
||||||
// .plus(lastTransactionInvestment)
|
|
||||||
// .eq(0)
|
|
||||||
// ) {
|
|
||||||
// const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
|
||||||
// .minus(
|
|
||||||
// lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
// lastTransactionInvestment
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// .div(
|
|
||||||
// lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
// lastTransactionInvestment
|
|
||||||
// )
|
|
||||||
// );
|
|
||||||
|
|
||||||
// timeWeightedGrossPerformancePercentage =
|
|
||||||
// timeWeightedGrossPerformancePercentage.mul(
|
|
||||||
// new Big(1).plus(grossHoldingPeriodReturn)
|
|
||||||
// );
|
|
||||||
|
|
||||||
// const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
|
||||||
// .minus(fees.minus(feesAtStartDate))
|
|
||||||
// .minus(
|
|
||||||
// lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
// lastTransactionInvestment
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// .div(
|
|
||||||
// lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
// lastTransactionInvestment
|
|
||||||
// )
|
|
||||||
// );
|
|
||||||
|
|
||||||
// timeWeightedNetPerformancePercentage =
|
|
||||||
// timeWeightedNetPerformancePercentage.mul(
|
|
||||||
// new Big(1).plus(netHoldingPeriodReturn)
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
grossPerformance = newGrossPerformance;
|
grossPerformance = newGrossPerformance;
|
||||||
|
|
||||||
// lastTransactionInvestment = transactionInvestment;
|
|
||||||
|
|
||||||
// lastValueOfInvestmentBeforeTransaction =
|
|
||||||
// valueOfInvestmentBeforeTransaction;
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
if (order.itemType === 'start') {
|
||||||
feesAtStartDate = fees;
|
feesAtStartDate = fees;
|
||||||
grossPerformanceAtStartDate = grossPerformance;
|
grossPerformanceAtStartDate = grossPerformance;
|
||||||
@ -1204,12 +1185,6 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeWeightedGrossPerformancePercentage =
|
|
||||||
// timeWeightedGrossPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
// timeWeightedNetPerformancePercentage =
|
|
||||||
// timeWeightedNetPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
const totalGrossPerformance = grossPerformance.minus(
|
const totalGrossPerformance = grossPerformance.minus(
|
||||||
grossPerformanceAtStartDate
|
grossPerformanceAtStartDate
|
||||||
);
|
);
|
||||||
|
@ -10,14 +10,15 @@ 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.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioDividends,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport
|
PortfolioReport
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
|
||||||
import type {
|
import type {
|
||||||
DateRange,
|
DateRange,
|
||||||
GroupBy,
|
GroupBy,
|
||||||
@ -65,7 +66,7 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@ -131,7 +132,8 @@ export class PortfolioController {
|
|||||||
portfolioPosition.investment / totalInvestment;
|
portfolioPosition.investment / totalInvestment;
|
||||||
portfolioPosition.netPerformance = null;
|
portfolioPosition.netPerformance = null;
|
||||||
portfolioPosition.quantity = null;
|
portfolioPosition.quantity = null;
|
||||||
portfolioPosition.value = portfolioPosition.value / totalValue;
|
portfolioPosition.valueInPercentage =
|
||||||
|
portfolioPosition.value / totalValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||||
@ -185,27 +187,78 @@ export class PortfolioController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dividends')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getDividends(
|
||||||
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
|
): Promise<PortfolioDividends> {
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
|
let dividends = await this.portfolioService.getDividends({
|
||||||
|
dateRange,
|
||||||
|
filters,
|
||||||
|
groupBy,
|
||||||
|
impersonationId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
const maxDividend = dividends.reduce(
|
||||||
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
dividends = dividends.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
investment: item.investment / maxDividend
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
dividends = dividends.map((item) => {
|
||||||
|
return nullifyValuesInObject(item, ['investment']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dividends };
|
||||||
|
}
|
||||||
|
|
||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('groupBy') groupBy?: GroupBy
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioInvestments> {
|
): Promise<PortfolioInvestments> {
|
||||||
let investments: InvestmentItem[];
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
if (groupBy === 'month') {
|
let investments = await this.portfolioService.getInvestments({
|
||||||
investments = await this.portfolioService.getInvestments({
|
dateRange,
|
||||||
dateRange,
|
filters,
|
||||||
impersonationId,
|
groupBy,
|
||||||
groupBy: 'month'
|
impersonationId
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
investments = await this.portfolioService.getInvestments({
|
|
||||||
dateRange,
|
|
||||||
impersonationId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -239,11 +292,21 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@Version('2')
|
@Version('2')
|
||||||
public async getPerformanceV2(
|
public async getPerformanceV2(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('range') dateRange: DateRange = 'max'
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPerformanceResponse> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
const performanceInformation = await this.portfolioService.getPerformance({
|
const performanceInformation = await this.portfolioService.getPerformance({
|
||||||
dateRange,
|
dateRange,
|
||||||
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
@ -261,7 +324,7 @@ export class PortfolioController {
|
|||||||
totalInvestment: new Big(totalInvestment)
|
totalInvestment: new Big(totalInvestment)
|
||||||
.div(performanceInformation.performance.totalInvestment)
|
.div(performanceInformation.performance.totalInvestment)
|
||||||
.toNumber(),
|
.toNumber(),
|
||||||
value: new Big(value)
|
valueInPercentage: new Big(value)
|
||||||
.div(performanceInformation.performance.currentValue)
|
.div(performanceInformation.performance.currentValue)
|
||||||
.toNumber()
|
.toNumber()
|
||||||
};
|
};
|
||||||
@ -295,34 +358,30 @@ export class PortfolioController {
|
|||||||
|
|
||||||
@Get('positions')
|
@Get('positions')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('range') dateRange: DateRange = 'max'
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const result = await this.portfolioService.getPositions(
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
impersonationId,
|
filterByAccounts,
|
||||||
dateRange
|
filterByAssetClasses,
|
||||||
);
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
return this.portfolioService.getPositions({
|
||||||
impersonationId ||
|
dateRange,
|
||||||
this.userService.isRestrictedView(this.request.user)
|
filters,
|
||||||
) {
|
impersonationId
|
||||||
result.positions = result.positions.map((position) => {
|
});
|
||||||
return nullifyValuesInObject(position, [
|
|
||||||
'grossPerformance',
|
|
||||||
'investment',
|
|
||||||
'netPerformance',
|
|
||||||
'quantity'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('public/:accessId')
|
@Get('public/:accessId')
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPublic(
|
public async getPublic(
|
||||||
@Param('accessId') accessId
|
@Param('accessId') accessId
|
||||||
): Promise<PortfolioPublicDetails> {
|
): Promise<PortfolioPublicDetails> {
|
||||||
@ -369,16 +428,18 @@ export class PortfolioController {
|
|||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
allocationCurrent: portfolioPosition.value / totalValue,
|
allocationInPercentage: portfolioPosition.value / totalValue,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
dataSource: portfolioPosition.dataSource,
|
||||||
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
name: portfolioPosition.name,
|
name: portfolioPosition.name,
|
||||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
symbol: portfolioPosition.symbol,
|
symbol: portfolioPosition.symbol,
|
||||||
url: portfolioPosition.url,
|
url: portfolioPosition.url,
|
||||||
value: portfolioPosition.value / totalValue
|
valueInPercentage: portfolioPosition.value / totalValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -386,35 +447,22 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('position/:dataSource/:symbol')
|
@Get('position/:dataSource/:symbol')
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioService.getPosition(
|
const position = await this.portfolioService.getPosition(
|
||||||
dataSource,
|
dataSource,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
symbol
|
symbol
|
||||||
);
|
);
|
||||||
|
|
||||||
if (position) {
|
if (position) {
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
position = nullifyValuesInObject(position, [
|
|
||||||
'grossPerformance',
|
|
||||||
'investment',
|
|
||||||
'netPerformance',
|
|
||||||
'orders',
|
|
||||||
'quantity',
|
|
||||||
'value'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -427,18 +475,21 @@ export class PortfolioController {
|
|||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId: string
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
|
const report = await this.portfolioService.getReport(impersonationId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.subscription.type === 'Basic'
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
for (const rule in report.rules) {
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
if (report.rules[rule]) {
|
||||||
StatusCodes.FORBIDDEN
|
report.rules[rule] = [];
|
||||||
);
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.portfolioService.getReport(impersonationId);
|
return report;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,7 @@ import {
|
|||||||
} 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 { Request, Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -62,6 +63,7 @@ export class SubscriptionController {
|
|||||||
|
|
||||||
await this.subscriptionService.createSubscription({
|
await this.subscriptionService.createSubscription({
|
||||||
duration: coupon.duration,
|
duration: coupon.duration,
|
||||||
|
price: 0,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -86,9 +88,12 @@ export class SubscriptionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('stripe/callback')
|
@Get('stripe/callback')
|
||||||
public async stripeCallback(@Req() req, @Res() res) {
|
public async stripeCallback(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||||
req.query.checkoutSessionId
|
<string>request.query.checkoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
Logger.log(
|
Logger.log(
|
||||||
@ -96,7 +101,7 @@ export class SubscriptionController {
|
|||||||
'SubscriptionController'
|
'SubscriptionController'
|
||||||
);
|
);
|
||||||
|
|
||||||
res.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||||
@ -112,7 +117,7 @@ export class SubscriptionController {
|
|||||||
return await this.subscriptionService.createCheckoutSession({
|
return await this.subscriptionService.createCheckoutSession({
|
||||||
couponId,
|
couponId,
|
||||||
priceId,
|
priceId,
|
||||||
userId: this.request.user.id
|
user: this.request.user
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'SubscriptionController');
|
Logger.error(error, 'SubscriptionController');
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
PROPERTY_STRIPE_CONFIG
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
@ -19,7 +24,7 @@ export class SubscriptionService {
|
|||||||
this.stripe = new Stripe(
|
this.stripe = new Stripe(
|
||||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||||
{
|
{
|
||||||
apiVersion: '2020-08-27'
|
apiVersion: '2022-11-15'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -27,17 +32,17 @@ export class SubscriptionService {
|
|||||||
public async createCheckoutSession({
|
public async createCheckoutSession({
|
||||||
couponId,
|
couponId,
|
||||||
priceId,
|
priceId,
|
||||||
userId
|
user
|
||||||
}: {
|
}: {
|
||||||
couponId?: string;
|
couponId?: string;
|
||||||
priceId: string;
|
priceId: string;
|
||||||
userId: string;
|
user: UserWithSettings;
|
||||||
}) {
|
}) {
|
||||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||||
cancel_url: `${this.configurationService.get(
|
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
|
||||||
'ROOT_URL'
|
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/account`,
|
}/account`,
|
||||||
client_reference_id: userId,
|
client_reference_id: user.id,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: priceId,
|
price: priceId,
|
||||||
@ -70,13 +75,16 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
public async createSubscription({
|
public async createSubscription({
|
||||||
duration = '1 year',
|
duration = '1 year',
|
||||||
|
price,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
duration?: StringValue;
|
duration?: StringValue;
|
||||||
|
price: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
await this.prismaService.subscription.create({
|
await this.prismaService.subscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
price,
|
||||||
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||||
User: {
|
User: {
|
||||||
connect: {
|
connect: {
|
||||||
@ -93,10 +101,20 @@ export class SubscriptionService {
|
|||||||
aCheckoutSessionId
|
aCheckoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.createSubscription({ userId: session.client_reference_id });
|
let subscriptions: SubscriptionInterface[] = [];
|
||||||
|
|
||||||
await this.stripe.customers.update(session.customer as string, {
|
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||||
description: session.client_reference_id
|
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||||
|
})) ?? { value: '{}' };
|
||||||
|
|
||||||
|
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||||
|
|
||||||
|
const coupon = subscriptions[0]?.coupon ?? 0;
|
||||||
|
const price = subscriptions[0]?.price ?? 0;
|
||||||
|
|
||||||
|
await this.createSubscription({
|
||||||
|
price: price - coupon,
|
||||||
|
userId: session.client_reference_id
|
||||||
});
|
});
|
||||||
|
|
||||||
return session.client_reference_id;
|
return session.client_reference_id;
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
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 { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -21,7 +24,10 @@ import { SymbolService } from './symbol.service';
|
|||||||
|
|
||||||
@Controller('symbol')
|
@Controller('symbol')
|
||||||
export class SymbolController {
|
export class SymbolController {
|
||||||
public constructor(private readonly symbolService: SymbolService) {}
|
public constructor(
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly symbolService: SymbolService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be before /:symbol
|
* Must be before /:symbol
|
||||||
@ -33,7 +39,10 @@ export class SymbolController {
|
|||||||
@Query() { query = '' }
|
@Query() { query = '' }
|
||||||
): Promise<{ items: LookupItem[] }> {
|
): Promise<{ items: LookupItem[] }> {
|
||||||
try {
|
try {
|
||||||
return this.symbolService.lookup(query.toLowerCase());
|
return this.symbolService.lookup({
|
||||||
|
query: query.toLowerCase(),
|
||||||
|
user: this.request.user
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
@ -91,10 +100,19 @@ export class SymbolController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.symbolService.getForDate({
|
const result = await this.symbolService.getForDate({
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date,
|
||||||
symbol
|
symbol
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result || isEmpty(result)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,9 +5,11 @@ import {
|
|||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
HistoricalDataItem,
|
||||||
|
UserWithSettings
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
@ -32,7 +34,7 @@ export class SymbolService {
|
|||||||
]);
|
]);
|
||||||
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
||||||
|
|
||||||
if (dataGatheringItem.dataSource && marketPrice) {
|
if (dataGatheringItem.dataSource && marketPrice >= 0) {
|
||||||
let historicalData: HistoricalDataItem[] = [];
|
let historicalData: HistoricalDataItem[] = [];
|
||||||
|
|
||||||
if (includeHistoricalData > 0) {
|
if (includeHistoricalData > 0) {
|
||||||
@ -65,13 +67,9 @@ export class SymbolService {
|
|||||||
|
|
||||||
public async getForDate({
|
public async getForDate({
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date = new Date(),
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
|
||||||
dataSource: DataSource;
|
|
||||||
date: Date;
|
|
||||||
symbol: string;
|
|
||||||
}): Promise<IDataProviderHistoricalResponse> {
|
|
||||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
[{ dataSource, symbol }],
|
[{ dataSource, symbol }],
|
||||||
date,
|
date,
|
||||||
@ -84,15 +82,24 @@ export class SymbolService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async lookup({
|
||||||
|
query,
|
||||||
|
user
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
user: UserWithSettings;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
const results: { items: LookupItem[] } = { items: [] };
|
const results: { items: LookupItem[] } = { items: [] };
|
||||||
|
|
||||||
if (!aQuery) {
|
if (!query) {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { items } = await this.dataProviderService.search(aQuery);
|
const { items } = await this.dataProviderService.search({
|
||||||
|
query,
|
||||||
|
user
|
||||||
|
});
|
||||||
results.items = items;
|
results.items = items;
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -6,12 +6,17 @@ import type {
|
|||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingDto {
|
export class UpdateUserSettingDto {
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
annualInterestRate?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
baseCurrency?: string;
|
baseCurrency?: string;
|
||||||
@ -48,6 +53,14 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
projectedTotalAmount?: number;
|
||||||
|
|
||||||
|
@IsISO8601()
|
||||||
|
@IsOptional()
|
||||||
|
retirementDate?: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
savingsRate?: number;
|
savingsRate?: number;
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
|
||||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
import { User, UserSettings } 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 { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -31,7 +29,6 @@ import { UserService } from './user.service';
|
|||||||
@Controller('user')
|
@Controller('user')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
@ -69,23 +66,20 @@ export class UserController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
public async signupUser(): Promise<UserItem> {
|
public async signupUser(): Promise<UserItem> {
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
const isUserSignupEnabled =
|
||||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
await this.propertyService.isUserSignupEnabled();
|
||||||
PROPERTY_IS_READ_ONLY_MODE
|
|
||||||
)) as boolean;
|
|
||||||
|
|
||||||
if (isReadOnlyMode) {
|
if (!isUserSignupEnabled) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAdmin = await this.userService.hasAdmin();
|
const hasAdmin = await this.userService.hasAdmin();
|
||||||
|
|
||||||
const { accessToken, id, role } = await this.userService.createUser({
|
const { accessToken, id, role } = await this.userService.createUser({
|
||||||
role: hasAdmin ? 'USER' : 'ADMIN'
|
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -97,6 +97,7 @@ export class UserService {
|
|||||||
const {
|
const {
|
||||||
accessToken,
|
accessToken,
|
||||||
Account,
|
Account,
|
||||||
|
Analytics,
|
||||||
authChallenge,
|
authChallenge,
|
||||||
createdAt,
|
createdAt,
|
||||||
id,
|
id,
|
||||||
@ -107,7 +108,12 @@ export class UserService {
|
|||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
updatedAt
|
updatedAt
|
||||||
} = await this.prismaService.user.findUnique({
|
} = await this.prismaService.user.findUnique({
|
||||||
include: { Account: true, Settings: true, Subscription: true },
|
include: {
|
||||||
|
Account: true,
|
||||||
|
Analytics: true,
|
||||||
|
Settings: true,
|
||||||
|
Subscription: true
|
||||||
|
},
|
||||||
where: userWhereUniqueInput
|
where: userWhereUniqueInput
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -121,7 +127,8 @@ export class UserService {
|
|||||||
role,
|
role,
|
||||||
Settings,
|
Settings,
|
||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
updatedAt
|
updatedAt,
|
||||||
|
activityCount: Analytics?.activityCount
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user?.Settings) {
|
if (user?.Settings) {
|
||||||
@ -154,15 +161,22 @@ export class UserService {
|
|||||||
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
|
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
user.subscription =
|
user.subscription =
|
||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
}
|
|
||||||
|
|
||||||
let currentPermissions = getPermissions(user.role);
|
if (
|
||||||
|
Analytics?.activityCount % 25 === 0 &&
|
||||||
|
user.subscription?.type === 'Basic'
|
||||||
|
) {
|
||||||
|
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||||
|
}
|
||||||
|
|
||||||
if (user.subscription?.type === 'Premium') {
|
if (user.subscription?.type === 'Premium') {
|
||||||
currentPermissions.push(permissions.reportDataGlitch);
|
currentPermissions.push(permissions.reportDataGlitch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
@ -217,7 +231,11 @@ export class UserService {
|
|||||||
return hash.digest('hex');
|
return hash.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
|
public async createUser({
|
||||||
|
data
|
||||||
|
}: {
|
||||||
|
data: Prisma.UserCreateInput;
|
||||||
|
}): Promise<User> {
|
||||||
if (!data?.provider) {
|
if (!data?.provider) {
|
||||||
data.provider = 'ANONYMOUS';
|
data.provider = 'ANONYMOUS';
|
||||||
}
|
}
|
||||||
@ -242,6 +260,14 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
await this.prismaService.analytics.create({
|
||||||
|
data: {
|
||||||
|
User: { connect: { id: user.id } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (data.provider === 'ANONYMOUS') {
|
if (data.provider === 'ANONYMOUS') {
|
||||||
const accessToken = this.createAccessToken(
|
const accessToken = this.createAccessToken(
|
||||||
user.id,
|
user.id,
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
|||||||
import { cloneDeep, isObject } from 'lodash';
|
import Big from 'big.js';
|
||||||
|
import { cloneDeep, isArray, isObject } from 'lodash';
|
||||||
|
|
||||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||||
for (const key in aObject) {
|
for (const key in aObject) {
|
||||||
@ -27,3 +28,51 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
|
|||||||
return nullifyValuesInObject(object, keys);
|
return nullifyValuesInObject(object, keys);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function redactAttributes({
|
||||||
|
object,
|
||||||
|
options
|
||||||
|
}: {
|
||||||
|
object: any;
|
||||||
|
options: { attribute: string; valueMap: { [key: string]: any } }[];
|
||||||
|
}): any {
|
||||||
|
if (!object || !options || !options.length) {
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redactedObject = cloneDeep(object);
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
if (redactedObject.hasOwnProperty(option.attribute)) {
|
||||||
|
if (option.valueMap['*'] || option.valueMap['*'] === null) {
|
||||||
|
redactedObject[option.attribute] = option.valueMap['*'];
|
||||||
|
} else if (option.valueMap[redactedObject[option.attribute]]) {
|
||||||
|
redactedObject[option.attribute] =
|
||||||
|
option.valueMap[redactedObject[option.attribute]];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the attribute is not present on the current object,
|
||||||
|
// check if it exists on any nested objects
|
||||||
|
for (const property in redactedObject) {
|
||||||
|
if (isArray(redactedObject[property])) {
|
||||||
|
redactedObject[property] = redactedObject[property].map(
|
||||||
|
(currentObject) => {
|
||||||
|
return redactAttributes({ options, object: currentObject });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
isObject(redactedObject[property]) &&
|
||||||
|
!(redactedObject[property] instanceof Big)
|
||||||
|
) {
|
||||||
|
// Recursively call the function on the nested object
|
||||||
|
redactedObject[property] = redactAttributes({
|
||||||
|
options,
|
||||||
|
object: redactedObject[property]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redactedObject;
|
||||||
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
|
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
@ -12,7 +14,7 @@ import { map } from 'rxjs/operators';
|
|||||||
export class RedactValuesInResponseInterceptor<T>
|
export class RedactValuesInResponseInterceptor<T>
|
||||||
implements NestInterceptor<T, any>
|
implements NestInterceptor<T, any>
|
||||||
{
|
{
|
||||||
public constructor() {}
|
public constructor(private userService: UserService) {}
|
||||||
|
|
||||||
public intercept(
|
public intercept(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
@ -21,34 +23,43 @@ export class RedactValuesInResponseInterceptor<T>
|
|||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data: any) => {
|
map((data: any) => {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
const hasImpersonationId =
|
||||||
|
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
|
||||||
|
|
||||||
if (hasImpersonationId) {
|
if (
|
||||||
if (data.accounts) {
|
hasImpersonationId ||
|
||||||
for (const accountId of Object.keys(data.accounts)) {
|
this.userService.isRestrictedView(request.user)
|
||||||
if (data.accounts[accountId]?.balance !== undefined) {
|
) {
|
||||||
data.accounts[accountId].balance = null;
|
data = redactAttributes({
|
||||||
}
|
object: data,
|
||||||
}
|
options: [
|
||||||
}
|
'balance',
|
||||||
|
'balanceInBaseCurrency',
|
||||||
if (data.activities) {
|
'comment',
|
||||||
data.activities = data.activities.map((activity: Activity) => {
|
'convertedBalance',
|
||||||
if (activity.Account?.balance !== undefined) {
|
'dividendInBaseCurrency',
|
||||||
activity.Account.balance = null;
|
'fee',
|
||||||
}
|
'feeInBaseCurrency',
|
||||||
|
'filteredValueInBaseCurrency',
|
||||||
return activity;
|
'grossPerformance',
|
||||||
});
|
'investment',
|
||||||
}
|
'netPerformance',
|
||||||
|
'quantity',
|
||||||
if (data.filteredValueInBaseCurrency) {
|
'symbolMapping',
|
||||||
data.filteredValueInBaseCurrency = null;
|
'totalBalanceInBaseCurrency',
|
||||||
}
|
'totalValueInBaseCurrency',
|
||||||
|
'unitPrice',
|
||||||
if (data.totalValueInBaseCurrency) {
|
'value',
|
||||||
data.totalValueInBaseCurrency = null;
|
'valueInBaseCurrency'
|
||||||
}
|
].map((attribute) => {
|
||||||
|
return {
|
||||||
|
attribute,
|
||||||
|
valueMap: {
|
||||||
|
'*': null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
@ -24,7 +24,7 @@ export class TransformDataSourceInRequestInterceptor<T>
|
|||||||
const http = context.switchToHttp();
|
const http = context.switchToHttp();
|
||||||
const request = http.getRequest();
|
const request = http.getRequest();
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
if (request.body.dataSource) {
|
if (request.body.dataSource) {
|
||||||
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
@ -5,7 +6,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor
|
NestInterceptor
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { isArray } from 'lodash';
|
import { DataSource } from '@prisma/client';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -25,66 +26,24 @@ export class TransformDataSourceInResponseInterceptor<T>
|
|||||||
): Observable<any> {
|
): Observable<any> {
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data: any) => {
|
map((data: any) => {
|
||||||
if (
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
data = redactAttributes({
|
||||||
) {
|
options: [
|
||||||
if (data.activities) {
|
{
|
||||||
data.activities.map((activity) => {
|
attribute: 'dataSource',
|
||||||
activity.SymbolProfile.dataSource = encodeDataSource(
|
valueMap: Object.keys(DataSource).reduce(
|
||||||
activity.SymbolProfile.dataSource
|
(valueMap, dataSource) => {
|
||||||
);
|
valueMap[dataSource] = encodeDataSource(
|
||||||
return activity;
|
DataSource[dataSource]
|
||||||
});
|
);
|
||||||
}
|
return valueMap;
|
||||||
|
},
|
||||||
if (isArray(data.benchmarks)) {
|
{}
|
||||||
data.benchmarks.map((benchmark) => {
|
)
|
||||||
benchmark.dataSource = encodeDataSource(benchmark.dataSource);
|
|
||||||
return benchmark;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.dataSource) {
|
|
||||||
data.dataSource = encodeDataSource(data.dataSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.errors) {
|
|
||||||
for (const error of data.errors) {
|
|
||||||
if (error.dataSource) {
|
|
||||||
error.dataSource = encodeDataSource(error.dataSource);
|
|
||||||
}
|
}
|
||||||
}
|
],
|
||||||
}
|
object: data
|
||||||
|
});
|
||||||
if (data.holdings) {
|
|
||||||
for (const symbol of Object.keys(data.holdings)) {
|
|
||||||
if (data.holdings[symbol].dataSource) {
|
|
||||||
data.holdings[symbol].dataSource = encodeDataSource(
|
|
||||||
data.holdings[symbol].dataSource
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.items) {
|
|
||||||
data.items.map((item) => {
|
|
||||||
item.dataSource = encodeDataSource(item.dataSource);
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.positions) {
|
|
||||||
data.positions.map((position) => {
|
|
||||||
position.dataSource = encodeDataSource(position.dataSource);
|
|
||||||
return position;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.SymbolProfile) {
|
|
||||||
data.SymbolProfile.dataSource = encodeDataSource(
|
|
||||||
data.SymbolProfile.dataSource
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
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 * as bodyParser from 'body-parser';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
import { environment } from './environments/environment';
|
import { environment } from './environments/environment';
|
||||||
@ -9,15 +10,10 @@ 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 NODE_ENV =
|
|
||||||
configService.get<'development' | 'production'>('NODE_ENV') ??
|
|
||||||
'development';
|
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger:
|
logger: environment.production
|
||||||
NODE_ENV === '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({
|
||||||
@ -33,6 +29,9 @@ async function bootstrap() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Support 10mb csv/json files for importing activities
|
||||||
|
app.use(bodyParser.json({ limit: '10mb' }));
|
||||||
|
|
||||||
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||||
const PORT = configService.get<number>('PORT') || 3333;
|
const PORT = configService.get<number>('PORT') || 3333;
|
||||||
await app.listen(PORT, HOST, () => {
|
await app.listen(PORT, HOST, () => {
|
||||||
|
@ -19,13 +19,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
|||||||
|
|
||||||
if (accounts.length === 1) {
|
if (accounts.length === 1) {
|
||||||
return {
|
return {
|
||||||
evaluation: `All your investment is managed by a single account`,
|
evaluation: `Your net worth is managed by a single account`,
|
||||||
value: false
|
value: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
evaluation: `Your investment is managed by ${accounts.length} accounts`,
|
evaluation: `Your net worth is managed by ${accounts.length} accounts`,
|
||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Current Investment: Base Currency'
|
name: 'Current Investment: Base Currency'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Initial Investment: Base Currency'
|
name: 'Initial Investment: Base Currency'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
public exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Current Investment'
|
name: 'Current Investment'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
|
||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private currentPositions: CurrentPositions
|
private positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Initial Investment'
|
name: 'Initial Investment'
|
||||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
|||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||||
this.currentPositions.positions,
|
this.positions,
|
||||||
'currency',
|
'currency',
|
||||||
ruleSettings.baseCurrency
|
ruleSettings.baseCurrency
|
||||||
);
|
);
|
||||||
|
@ -12,16 +12,17 @@ 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({ default: 'USD' }),
|
BASE_CURRENCY: str({
|
||||||
|
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
|
||||||
|
default: 'USD'
|
||||||
|
}),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCES: json({
|
DATA_SOURCES: json({
|
||||||
default: [DataSource.GHOSTFOLIO, DataSource.YAHOO]
|
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
|
||||||
}),
|
}),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||||
ENABLE_FEATURE_IMPORT: bool({ default: true }),
|
|
||||||
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
|
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
|
||||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||||
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
||||||
@ -39,7 +40,7 @@ export class ConfigurationService {
|
|||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
PORT: port({ default: 3333 }),
|
PORT: port({ default: 3333 }),
|
||||||
RAPID_API_API_KEY: str({ default: '' }),
|
RAPID_API_API_KEY: str({ default: '' }),
|
||||||
REDIS_HOST: host({ 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: 'http://localhost:4200' }),
|
||||||
|
@ -11,14 +11,16 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService {
|
export class CronService {
|
||||||
|
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly twitterBotService: TwitterBotService
|
private readonly twitterBotService: TwitterBotService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_HOUR)
|
@Cron(CronExpression.EVERY_4_HOURS)
|
||||||
public async runEveryHour() {
|
public async runEveryFourHours() {
|
||||||
await this.dataGatheringService.gather7Days();
|
await this.dataGatheringService.gather7Days();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,12 +30,12 @@ export class CronService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_5PM)
|
@Cron(CronExpression.EVERY_DAY_AT_5PM)
|
||||||
public async runEveryDayAtFivePM() {
|
public async runEveryDayAtFivePm() {
|
||||||
this.twitterBotService.tweetFearAndGreedIndex();
|
this.twitterBotService.tweetFearAndGreedIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_WEEKEND)
|
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
|
||||||
public async runEveryWeekend() {
|
public async runEverySundayAtTwelvePm() {
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
|
@ -152,10 +152,11 @@ export class DataGatheringService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
isin,
|
||||||
name,
|
name,
|
||||||
sectors,
|
sectors,
|
||||||
url
|
url
|
||||||
} = assetProfiles[symbol];
|
} = assetProfile;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.prismaService.symbolProfile.upsert({
|
await this.prismaService.symbolProfile.upsert({
|
||||||
@ -165,6 +166,7 @@ export class DataGatheringService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
isin,
|
||||||
name,
|
name,
|
||||||
sectors,
|
sectors,
|
||||||
symbol,
|
symbol,
|
||||||
@ -175,6 +177,7 @@ export class DataGatheringService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
|
isin,
|
||||||
name,
|
name,
|
||||||
sectors,
|
sectors,
|
||||||
url
|
url
|
||||||
@ -207,10 +210,6 @@ export class DataGatheringService {
|
|||||||
|
|
||||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||||
if (dataSource === 'MANUAL') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.addJobToQueue(
|
await this.addJobToQueue(
|
||||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||||
{
|
{
|
||||||
@ -253,11 +252,6 @@ export class DataGatheringService {
|
|||||||
},
|
},
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
},
|
|
||||||
where: {
|
|
||||||
dataSource: {
|
|
||||||
not: 'MANUAL'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
).map((symbolProfile) => {
|
||||||
@ -278,7 +272,6 @@ export class DataGatheringService {
|
|||||||
return symbolProfiles
|
return symbolProfiles
|
||||||
.filter(({ dataSource }) => {
|
.filter(({ dataSource }) => {
|
||||||
return (
|
return (
|
||||||
dataSource !== DataSource.GHOSTFOLIO &&
|
|
||||||
dataSource !== DataSource.MANUAL &&
|
dataSource !== DataSource.MANUAL &&
|
||||||
dataSource !== DataSource.RAPID_API
|
dataSource !== DataSource.RAPID_API
|
||||||
);
|
);
|
||||||
@ -300,11 +293,6 @@ export class DataGatheringService {
|
|||||||
dataSource: true,
|
dataSource: true,
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
},
|
|
||||||
where: {
|
|
||||||
dataSource: {
|
|
||||||
not: 'MANUAL'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -37,6 +37,20 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -0,0 +1,196 @@
|
|||||||
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
import {
|
||||||
|
IDataProviderHistoricalResponse,
|
||||||
|
IDataProviderResponse
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
SymbolProfile
|
||||||
|
} from '@prisma/client';
|
||||||
|
import bent from 'bent';
|
||||||
|
import { format, fromUnixTime, getUnixTime } from 'date-fns';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CoinGeckoService implements DataProviderInterface {
|
||||||
|
private baseCurrency: string;
|
||||||
|
private readonly URL = 'https://api.coingecko.com/api/v3';
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
|
public canHandle(symbol: string) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAssetProfile(
|
||||||
|
aSymbol: string
|
||||||
|
): Promise<Partial<SymbolProfile>> {
|
||||||
|
const response: Partial<SymbolProfile> = {
|
||||||
|
assetClass: AssetClass.CASH,
|
||||||
|
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||||
|
currency: this.baseCurrency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: aSymbol
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200);
|
||||||
|
const { name } = await get();
|
||||||
|
|
||||||
|
response.name = name;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'CoinGeckoService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getHistorical(
|
||||||
|
aSymbol: string,
|
||||||
|
aGranularity: Granularity = 'day',
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<{
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`${
|
||||||
|
this.URL
|
||||||
|
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime(
|
||||||
|
from
|
||||||
|
)}&to=${getUnixTime(to)}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
const { prices } = await get();
|
||||||
|
|
||||||
|
const result: {
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
} = {
|
||||||
|
[aSymbol]: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [timestamp, marketPrice] of prices) {
|
||||||
|
result[aSymbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = {
|
||||||
|
marketPrice
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMaxNumberOfSymbolsPerRequest() {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.COINGECKO;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getQuotes(
|
||||||
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
const results: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`${this.URL}/simple/price?ids=${aSymbols.join(
|
||||||
|
','
|
||||||
|
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
const response = await get();
|
||||||
|
|
||||||
|
for (const symbol in response) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
|
||||||
|
results[symbol] = {
|
||||||
|
currency: this.baseCurrency,
|
||||||
|
dataProviderInfo: this.getDataProviderInfo(),
|
||||||
|
dataSource: DataSource.COINGECKO,
|
||||||
|
marketPrice: response[symbol][this.baseCurrency.toLowerCase()],
|
||||||
|
marketState: 'open'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'CoinGeckoService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
let items: LookupItem[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`${this.URL}/search?query=${aQuery}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
const { coins } = await get();
|
||||||
|
|
||||||
|
items = coins.map(({ id: symbol, name }) => {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
symbol,
|
||||||
|
currency: this.baseCurrency,
|
||||||
|
dataSource: this.getName()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'CoinGeckoService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDataProviderInfo(): DataProviderInfo {
|
||||||
|
return {
|
||||||
|
name: 'CoinGecko',
|
||||||
|
url: 'https://coingecko.com'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -7,7 +7,7 @@ import bent from 'bent';
|
|||||||
const getJSON = bent('json');
|
const getJSON = bent('json');
|
||||||
|
|
||||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||||
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
private static baseUrl = 'https://data.trackinsight.com';
|
||||||
private static countries = require('countries-list/dist/countries.json');
|
private static countries = require('countries-list/dist/countries.json');
|
||||||
private static countriesMapping = {
|
private static countriesMapping = {
|
||||||
'Russian Federation': 'Russia'
|
'Russian Federation': 'Russia'
|
||||||
@ -32,17 +32,29 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getJSON(
|
const profile = await getJSON(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
|
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json`
|
||||||
|
).catch(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const isin = profile.isin?.split(';')?.[0];
|
||||||
|
|
||||||
|
if (isin) {
|
||||||
|
response.isin = isin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const holdings = await getJSON(
|
||||||
|
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
|
||||||
).catch(() => {
|
).catch(() => {
|
||||||
return getJSON(
|
return getJSON(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/${
|
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
|
||||||
symbol.split('.')[0]
|
symbol.split('.')?.[0]
|
||||||
}.json`
|
}.json`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.weight < 0.95) {
|
if (holdings?.weight < 0.95) {
|
||||||
// Skip if data is inaccurate
|
// Skip if data is inaccurate
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -52,7 +64,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
(response.countries as unknown as Country[]).length === 0
|
(response.countries as unknown as Country[]).length === 0
|
||||||
) {
|
) {
|
||||||
response.countries = [];
|
response.countries = [];
|
||||||
for (const [name, value] of Object.entries<any>(result.countries)) {
|
for (const [name, value] of Object.entries<any>(
|
||||||
|
holdings?.countries ?? {}
|
||||||
|
)) {
|
||||||
let countryCode: string;
|
let countryCode: string;
|
||||||
|
|
||||||
for (const [key, country] of Object.entries<any>(
|
for (const [key, country] of Object.entries<any>(
|
||||||
@ -80,7 +94,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
(response.sectors as unknown as Sector[]).length === 0
|
(response.sectors as unknown as Sector[]).length === 0
|
||||||
) {
|
) {
|
||||||
response.sectors = [];
|
response.sectors = [];
|
||||||
for (const [name, value] of Object.entries<any>(result.sectors)) {
|
for (const [name, value] of Object.entries<any>(
|
||||||
|
holdings?.sectors ?? {}
|
||||||
|
)) {
|
||||||
response.sectors.push({
|
response.sectors.push({
|
||||||
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
||||||
weight: value.weight
|
weight: value.weight
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/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';
|
||||||
|
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
|
||||||
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
|
||||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
||||||
@ -22,9 +22,9 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
|
CoinGeckoService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
EodHistoricalDataService,
|
EodHistoricalDataService,
|
||||||
GhostfolioScraperApiService,
|
|
||||||
GoogleSheetsService,
|
GoogleSheetsService,
|
||||||
ManualService,
|
ManualService,
|
||||||
RapidApiService,
|
RapidApiService,
|
||||||
@ -32,8 +32,8 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
{
|
{
|
||||||
inject: [
|
inject: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
|
CoinGeckoService,
|
||||||
EodHistoricalDataService,
|
EodHistoricalDataService,
|
||||||
GhostfolioScraperApiService,
|
|
||||||
GoogleSheetsService,
|
GoogleSheetsService,
|
||||||
ManualService,
|
ManualService,
|
||||||
RapidApiService,
|
RapidApiService,
|
||||||
@ -42,16 +42,16 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
provide: 'DataProviderInterfaces',
|
provide: 'DataProviderInterfaces',
|
||||||
useFactory: (
|
useFactory: (
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
|
coinGeckoService,
|
||||||
eodHistoricalDataService,
|
eodHistoricalDataService,
|
||||||
ghostfolioScraperApiService,
|
|
||||||
googleSheetsService,
|
googleSheetsService,
|
||||||
manualService,
|
manualService,
|
||||||
rapidApiService,
|
rapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
) => [
|
) => [
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
|
coinGeckoService,
|
||||||
eodHistoricalDataService,
|
eodHistoricalDataService,
|
||||||
ghostfolioScraperApiService,
|
|
||||||
googleSheetsService,
|
googleSheetsService,
|
||||||
manualService,
|
manualService,
|
||||||
rapidApiService,
|
rapidApiService,
|
||||||
@ -59,6 +59,6 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
exports: [DataProviderService, YahooFinanceService]
|
||||||
})
|
})
|
||||||
export class DataProviderModule {}
|
export class DataProviderModule {}
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } 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';
|
||||||
@ -23,6 +24,27 @@ export class DataProviderService {
|
|||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
dataSource,
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return this.getDataProvider(DataSource[dataSource]).getDividends({
|
||||||
|
from,
|
||||||
|
granularity,
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aItems: IDataGatheringItem[],
|
aItems: IDataGatheringItem[],
|
||||||
aGranularity: Granularity = 'month',
|
aGranularity: Granularity = 'month',
|
||||||
@ -114,9 +136,13 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allData = await Promise.all(promises);
|
try {
|
||||||
for (const { data, symbol } of allData) {
|
const allData = await Promise.all(promises);
|
||||||
result[symbol] = data;
|
for (const { data, symbol } of allData) {
|
||||||
|
result[symbol] = data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'DataProviderService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -209,7 +235,9 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(
|
Logger.debug(
|
||||||
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
|
`Fetched ${symbolsChunk.length} quote${
|
||||||
|
symbolsChunk.length > 1 ? 's' : ''
|
||||||
|
} from ${dataSource} in ${(
|
||||||
(performance.now() - startTimeDataSource) /
|
(performance.now() - startTimeDataSource) /
|
||||||
1000
|
1000
|
||||||
).toFixed(3)} seconds`
|
).toFixed(3)} seconds`
|
||||||
@ -223,7 +251,7 @@ export class DataProviderService {
|
|||||||
|
|
||||||
Logger.debug('------------------------------------------------');
|
Logger.debug('------------------------------------------------');
|
||||||
Logger.debug(
|
Logger.debug(
|
||||||
`Fetched ${items.length} quotes in ${(
|
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
|
||||||
(performance.now() - startTimeTotal) /
|
(performance.now() - startTimeTotal) /
|
||||||
1000
|
1000
|
||||||
).toFixed(3)} seconds`
|
).toFixed(3)} seconds`
|
||||||
@ -233,26 +261,51 @@ export class DataProviderService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
query,
|
||||||
|
user
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
user: UserWithSettings;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||||
let lookupItems: LookupItem[] = [];
|
let lookupItems: LookupItem[] = [];
|
||||||
|
|
||||||
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
if (query?.length < 2) {
|
||||||
promises.push(
|
return { items: lookupItems };
|
||||||
this.getDataProvider(DataSource[dataSource]).search(aQuery)
|
}
|
||||||
);
|
|
||||||
|
let dataSources = this.configurationService.get('DATA_SOURCES');
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
dataSources = dataSources.filter((dataSource) => {
|
||||||
|
return !this.isPremiumDataSource(DataSource[dataSource]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dataSource of dataSources) {
|
||||||
|
promises.push(this.getDataProvider(DataSource[dataSource]).search(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResults = await Promise.all(promises);
|
const searchResults = await Promise.all(promises);
|
||||||
|
|
||||||
searchResults.forEach((searchResult) => {
|
searchResults.forEach(({ items }) => {
|
||||||
lookupItems = lookupItems.concat(searchResult.items);
|
if (items?.length > 0) {
|
||||||
|
lookupItems = lookupItems.concat(items);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredItems = lookupItems.filter((lookupItem) => {
|
const filteredItems = lookupItems
|
||||||
// Only allow symbols with supported currency
|
.filter((lookupItem) => {
|
||||||
return lookupItem.currency ? true : false;
|
// Only allow symbols with supported currency
|
||||||
});
|
return lookupItem.currency ? true : false;
|
||||||
|
})
|
||||||
|
.sort(({ name: name1 }, { name: name2 }) => {
|
||||||
|
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: filteredItems
|
items: filteredItems
|
||||||
@ -268,4 +321,9 @@ export class DataProviderService {
|
|||||||
|
|
||||||
throw new Error('No data provider has been found.');
|
throw new Error('No data provider has been found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isPremiumDataSource(aDataSource: DataSource) {
|
||||||
|
const premiumDataSources: DataSource[] = [DataSource.EOD_HISTORICAL_DATA];
|
||||||
|
return premiumDataSources.includes(aDataSource);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,13 +5,17 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
|
||||||
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';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
SymbolProfile
|
||||||
|
} from '@prisma/client';
|
||||||
import bent from 'bent';
|
import bent from 'bent';
|
||||||
import { format } from 'date-fns';
|
import { format, isToday } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EodHistoricalDataService implements DataProviderInterface {
|
export class EodHistoricalDataService implements DataProviderInterface {
|
||||||
@ -19,8 +23,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
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
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
|
||||||
) {
|
) {
|
||||||
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
|
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
|
||||||
}
|
}
|
||||||
@ -32,11 +35,32 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
public async getAssetProfile(
|
public async getAssetProfile(
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<Partial<SymbolProfile>> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
|
const [searchResult] = await this.getSearchResult(aSymbol);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dataSource: this.getName()
|
assetClass: searchResult?.assetClass,
|
||||||
|
assetSubClass: searchResult?.assetSubClass,
|
||||||
|
currency: searchResult?.currency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
isin: searchResult?.isin,
|
||||||
|
name: searchResult?.name
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
@ -108,32 +132,30 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
200
|
200
|
||||||
);
|
);
|
||||||
|
|
||||||
const [response, symbolProfiles] = await Promise.all([
|
const [realTimeResponse, searchResponse] = await Promise.all([
|
||||||
get(),
|
get(),
|
||||||
this.symbolProfileService.getSymbolProfiles(
|
this.search(aSymbols[0])
|
||||||
aSymbols.map((symbol) => {
|
|
||||||
return {
|
|
||||||
symbol,
|
|
||||||
dataSource: DataSource.EOD_HISTORICAL_DATA
|
|
||||||
};
|
|
||||||
})
|
|
||||||
)
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const quotes = aSymbols.length === 1 ? [response] : response;
|
const quotes =
|
||||||
|
aSymbols.length === 1 ? [realTimeResponse] : realTimeResponse;
|
||||||
|
|
||||||
return quotes.reduce((result, item, index, array) => {
|
return quotes.reduce(
|
||||||
result[item.code] = {
|
(
|
||||||
currency: symbolProfiles.find((symbolProfile) => {
|
result: { [symbol: string]: IDataProviderResponse },
|
||||||
return symbolProfile.symbol === item.code;
|
{ close, code, timestamp }
|
||||||
})?.currency,
|
) => {
|
||||||
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
result[code] = {
|
||||||
marketPrice: item.close,
|
currency: searchResponse?.items[0]?.currency,
|
||||||
marketState: 'delayed'
|
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
||||||
};
|
marketPrice: close,
|
||||||
|
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||||
|
};
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, {});
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'EodHistoricalDataService');
|
Logger.error(error, 'EodHistoricalDataService');
|
||||||
}
|
}
|
||||||
@ -142,6 +164,101 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
const searchResult = await this.getSearchResult(aQuery);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: searchResult
|
||||||
|
.filter(({ symbol }) => {
|
||||||
|
return !symbol.toLowerCase().endsWith('forex');
|
||||||
|
})
|
||||||
|
.map(({ currency, dataSource, name, symbol }) => {
|
||||||
|
return { currency, dataSource, name, symbol };
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSearchResult(aQuery: string): Promise<
|
||||||
|
(LookupItem & {
|
||||||
|
assetClass: AssetClass;
|
||||||
|
assetSubClass: AssetSubClass;
|
||||||
|
isin: string;
|
||||||
|
})[]
|
||||||
|
> {
|
||||||
|
let searchResult = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
const response = await get();
|
||||||
|
|
||||||
|
searchResult = response.map(
|
||||||
|
({
|
||||||
|
Code,
|
||||||
|
Currency: currency,
|
||||||
|
Exchange,
|
||||||
|
ISIN: isin,
|
||||||
|
Name: name,
|
||||||
|
Type
|
||||||
|
}) => {
|
||||||
|
const { assetClass, assetSubClass } = this.parseAssetClass({
|
||||||
|
Exchange,
|
||||||
|
Type
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
currency,
|
||||||
|
isin,
|
||||||
|
name,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: `${Code}.${Exchange}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'EodHistoricalDataService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAssetClass({
|
||||||
|
Exchange,
|
||||||
|
Type
|
||||||
|
}: {
|
||||||
|
Exchange: string;
|
||||||
|
Type: string;
|
||||||
|
}): {
|
||||||
|
assetClass: AssetClass;
|
||||||
|
assetSubClass: AssetSubClass;
|
||||||
|
} {
|
||||||
|
let assetClass: AssetClass;
|
||||||
|
let assetSubClass: AssetSubClass;
|
||||||
|
|
||||||
|
switch (Type?.toLowerCase()) {
|
||||||
|
case 'common stock':
|
||||||
|
assetClass = AssetClass.EQUITY;
|
||||||
|
assetSubClass = AssetSubClass.STOCK;
|
||||||
|
break;
|
||||||
|
case 'currency':
|
||||||
|
assetClass = AssetClass.CASH;
|
||||||
|
|
||||||
|
if (Exchange?.toLowerCase() === 'cc') {
|
||||||
|
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'etf':
|
||||||
|
assetClass = AssetClass.EQUITY;
|
||||||
|
assetSubClass = AssetSubClass.ETF;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { assetClass, assetSubClass };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,180 +0,0 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
|
||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
|
||||||
import {
|
|
||||||
IDataProviderHistoricalResponse,
|
|
||||||
IDataProviderResponse
|
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
|
||||||
import {
|
|
||||||
DATE_FORMAT,
|
|
||||||
extractNumberFromString,
|
|
||||||
getYesterday
|
|
||||||
} from '@ghostfolio/common/helper';
|
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
|
||||||
import bent from 'bent';
|
|
||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { addDays, format, isBefore } from 'date-fns';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
|
||||||
public constructor(
|
|
||||||
private readonly prismaService: PrismaService,
|
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getAssetProfile(
|
|
||||||
aSymbol: string
|
|
||||||
): Promise<Partial<SymbolProfile>> {
|
|
||||||
return {
|
|
||||||
dataSource: this.getName()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getHistorical(
|
|
||||||
aSymbol: string,
|
|
||||||
aGranularity: Granularity = 'day',
|
|
||||||
from: Date,
|
|
||||||
to: Date
|
|
||||||
): Promise<{
|
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const symbol = aSymbol;
|
|
||||||
|
|
||||||
const [symbolProfile] =
|
|
||||||
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
|
|
||||||
const { defaultMarketPrice, selector, url } =
|
|
||||||
symbolProfile.scraperConfiguration;
|
|
||||||
|
|
||||||
if (defaultMarketPrice) {
|
|
||||||
const historical: {
|
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
|
||||||
} = {
|
|
||||||
[symbol]: {}
|
|
||||||
};
|
|
||||||
let date = from;
|
|
||||||
|
|
||||||
while (isBefore(date, to)) {
|
|
||||||
historical[symbol][format(date, DATE_FORMAT)] = {
|
|
||||||
marketPrice: defaultMarketPrice
|
|
||||||
};
|
|
||||||
|
|
||||||
date = addDays(date, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return historical;
|
|
||||||
} else if (selector === undefined || url === undefined) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const get = bent(url, 'GET', 'string', 200, {});
|
|
||||||
|
|
||||||
const html = await get();
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
|
|
||||||
const value = extractNumberFromString($(selector).text());
|
|
||||||
|
|
||||||
return {
|
|
||||||
[symbol]: {
|
|
||||||
[format(getYesterday(), DATE_FORMAT)]: {
|
|
||||||
marketPrice: value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
|
||||||
from,
|
|
||||||
DATE_FORMAT
|
|
||||||
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getName(): DataSource {
|
|
||||||
return DataSource.GHOSTFOLIO;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getQuotes(
|
|
||||||
aSymbols: string[]
|
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
|
||||||
|
|
||||||
if (aSymbols.length <= 0) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const symbolProfiles =
|
|
||||||
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
|
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.findMany({
|
|
||||||
distinct: ['symbol'],
|
|
||||||
orderBy: {
|
|
||||||
date: 'desc'
|
|
||||||
},
|
|
||||||
take: aSymbols.length,
|
|
||||||
where: {
|
|
||||||
symbol: {
|
|
||||||
in: aSymbols
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const symbolProfile of symbolProfiles) {
|
|
||||||
response[symbolProfile.symbol] = {
|
|
||||||
currency: symbolProfile.currency,
|
|
||||||
dataSource: this.getName(),
|
|
||||||
marketPrice: marketData.find((marketDataItem) => {
|
|
||||||
return marketDataItem.symbol === symbolProfile.symbol;
|
|
||||||
}).marketPrice,
|
|
||||||
marketState: 'delayed'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'GhostfolioScraperApiService');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
|
||||||
const items = await this.prismaService.symbolProfile.findMany({
|
|
||||||
select: {
|
|
||||||
currency: true,
|
|
||||||
dataSource: true,
|
|
||||||
name: true,
|
|
||||||
symbol: true
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
dataSource: this.getName(),
|
|
||||||
name: {
|
|
||||||
mode: 'insensitive',
|
|
||||||
startsWith: aQuery
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dataSource: this.getName(),
|
|
||||||
symbol: {
|
|
||||||
mode: 'insensitive',
|
|
||||||
startsWith: aQuery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { items };
|
|
||||||
}
|
|
||||||
}
|
|
@ -34,6 +34,20 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
|
@ -11,6 +11,18 @@ export interface DataProviderInterface {
|
|||||||
|
|
||||||
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
||||||
|
|
||||||
|
getDividends({
|
||||||
|
from,
|
||||||
|
granularity,
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}): Promise<{ [date: string]: IDataProviderHistoricalResponse }>;
|
||||||
|
|
||||||
getHistorical(
|
getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity,
|
aGranularity: Granularity,
|
||||||
|
@ -4,16 +4,30 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
extractNumberFromString,
|
||||||
|
getYesterday
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } 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 { isUUID } from 'class-validator';
|
||||||
|
import { addDays, format, isBefore } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ManualService implements DataProviderInterface {
|
export class ManualService implements DataProviderInterface {
|
||||||
public constructor() {}
|
public constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile(
|
||||||
@ -24,6 +38,20 @@ export class ManualService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
@ -32,7 +60,57 @@ export class ManualService implements DataProviderInterface {
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}> {
|
}> {
|
||||||
return {};
|
try {
|
||||||
|
const symbol = aSymbol;
|
||||||
|
|
||||||
|
const [symbolProfile] =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
|
||||||
|
const { defaultMarketPrice, selector, url } =
|
||||||
|
symbolProfile.scraperConfiguration ?? {};
|
||||||
|
|
||||||
|
if (defaultMarketPrice) {
|
||||||
|
const historical: {
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
} = {
|
||||||
|
[symbol]: {}
|
||||||
|
};
|
||||||
|
let date = from;
|
||||||
|
|
||||||
|
while (isBefore(date, to)) {
|
||||||
|
historical[symbol][format(date, DATE_FORMAT)] = {
|
||||||
|
marketPrice: defaultMarketPrice
|
||||||
|
};
|
||||||
|
|
||||||
|
date = addDays(date, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return historical;
|
||||||
|
} else if (selector === undefined || url === undefined) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = bent(url, 'GET', 'string', 200, {});
|
||||||
|
|
||||||
|
const html = await get();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
const value = extractNumberFromString($(selector).text());
|
||||||
|
|
||||||
|
return {
|
||||||
|
[symbol]: {
|
||||||
|
[format(getYesterday(), DATE_FORMAT)]: {
|
||||||
|
marketPrice: value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
@ -42,10 +120,81 @@ export class ManualService implements DataProviderInterface {
|
|||||||
public async getQuotes(
|
public async getQuotes(
|
||||||
aSymbols: string[]
|
aSymbols: string[]
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const symbolProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
|
||||||
|
|
||||||
|
const marketData = await this.prismaService.marketData.findMany({
|
||||||
|
distinct: ['symbol'],
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
take: aSymbols.length,
|
||||||
|
where: {
|
||||||
|
symbol: {
|
||||||
|
in: aSymbols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const symbolProfile of symbolProfiles) {
|
||||||
|
response[symbolProfile.symbol] = {
|
||||||
|
currency: symbolProfile.currency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
marketPrice: marketData.find((marketDataItem) => {
|
||||||
|
return marketDataItem.symbol === symbolProfile.symbol;
|
||||||
|
})?.marketPrice,
|
||||||
|
marketState: 'delayed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'ManualService');
|
||||||
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
let items = await this.prismaService.symbolProfile.findMany({
|
||||||
|
select: {
|
||||||
|
currency: true,
|
||||||
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
|
symbol: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
name: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
items = items.filter(({ symbol }) => {
|
||||||
|
// Remove UUID symbols (activities of type ITEM)
|
||||||
|
return !isUUID(symbol);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,20 +5,18 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
import bent from 'bent';
|
import bent from 'bent';
|
||||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RapidApiService implements DataProviderInterface {
|
export class RapidApiService implements DataProviderInterface {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService
|
||||||
private readonly prismaService: PrismaService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
@ -33,6 +31,20 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
@ -47,41 +59,6 @@ export class RapidApiService implements DataProviderInterface {
|
|||||||
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||||
const fgi = await this.getFearAndGreedIndex();
|
const fgi = await this.getFearAndGreedIndex();
|
||||||
|
|
||||||
try {
|
|
||||||
// Rebuild historical data
|
|
||||||
// TODO: can be removed after all data from the last year has been gathered
|
|
||||||
// (introduced on 27.03.2021)
|
|
||||||
|
|
||||||
await this.prismaService.marketData.create({
|
|
||||||
data: {
|
|
||||||
symbol,
|
|
||||||
dataSource: this.getName(),
|
|
||||||
date: subWeeks(getToday(), 1),
|
|
||||||
marketPrice: fgi.oneWeekAgo.value
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prismaService.marketData.create({
|
|
||||||
data: {
|
|
||||||
symbol,
|
|
||||||
dataSource: this.getName(),
|
|
||||||
date: subMonths(getToday(), 1),
|
|
||||||
marketPrice: fgi.oneMonthAgo.value
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prismaService.marketData.create({
|
|
||||||
data: {
|
|
||||||
symbol,
|
|
||||||
dataSource: this.getName(),
|
|
||||||
date: subYears(getToday(), 1),
|
|
||||||
marketPrice: fgi.oneYearAgo.value
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||||
[format(getYesterday(), DATE_FORMAT)]: {
|
[format(getYesterday(), DATE_FORMAT)]: {
|
||||||
|
@ -154,16 +154,65 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
response.url = url;
|
response.url = url;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
Logger.error(error, 'YahooFinanceService');
|
||||||
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
|
|
||||||
error.name
|
|
||||||
}] ${error.message}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
if (isSameDay(from, to)) {
|
||||||
|
to = addDays(to, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const historicalResult = await yahooFinance.historical(
|
||||||
|
this.convertToYahooFinanceSymbol(symbol),
|
||||||
|
{
|
||||||
|
events: 'dividends',
|
||||||
|
interval: granularity === 'month' ? '1mo' : '1d',
|
||||||
|
period1: format(from, DATE_FORMAT),
|
||||||
|
period2: format(to, DATE_FORMAT)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: {
|
||||||
|
[date: string]: IDataProviderHistoricalResponse;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const historicalItem of historicalResult) {
|
||||||
|
response[format(historicalItem.date, DATE_FORMAT)] = {
|
||||||
|
marketPrice: this.getConvertedValue({
|
||||||
|
symbol,
|
||||||
|
value: historicalItem.dividends
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
`Could not get dividends for ${symbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`,
|
||||||
|
'YahooFinanceService'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbol: string,
|
aSymbol: string,
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
@ -176,11 +225,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
to = addDays(to, 1);
|
to = addDays(to, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const historicalResult = await yahooFinance.historical(
|
const historicalResult = await yahooFinance.historical(
|
||||||
yahooFinanceSymbol,
|
this.convertToYahooFinanceSymbol(aSymbol),
|
||||||
{
|
{
|
||||||
interval: '1d',
|
interval: '1d',
|
||||||
period1: format(from, DATE_FORMAT),
|
period1: format(from, DATE_FORMAT),
|
||||||
@ -192,24 +239,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
// Convert symbol back
|
response[aSymbol] = {};
|
||||||
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
|
||||||
|
|
||||||
response[symbol] = {};
|
|
||||||
|
|
||||||
for (const historicalItem of historicalResult) {
|
for (const historicalItem of historicalResult) {
|
||||||
let marketPrice = historicalItem.close;
|
response[aSymbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||||
|
marketPrice: this.getConvertedValue({
|
||||||
if (symbol === `${this.baseCurrency}GBp`) {
|
symbol: aSymbol,
|
||||||
// Convert GPB to GBp (pence)
|
value: historicalItem.close
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
}),
|
||||||
} else if (symbol === `${this.baseCurrency}ILA`) {
|
|
||||||
// Convert ILS to ILA
|
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
|
||||||
}
|
|
||||||
|
|
||||||
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
|
||||||
marketPrice,
|
|
||||||
performance: historicalItem.open - historicalItem.close
|
performance: historicalItem.open - historicalItem.close
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -287,6 +324,18 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
.mul(100)
|
.mul(100)
|
||||||
.toNumber()
|
.toNumber()
|
||||||
};
|
};
|
||||||
|
} else if (
|
||||||
|
symbol === `${this.baseCurrency}ZAR` &&
|
||||||
|
yahooFinanceSymbols.includes(`${this.baseCurrency}ZAc=X`)
|
||||||
|
) {
|
||||||
|
// Convert ZAR to ZAc (cents)
|
||||||
|
response[`${this.baseCurrency}ZAc`] = {
|
||||||
|
...response[symbol],
|
||||||
|
currency: 'ZAc',
|
||||||
|
marketPrice: new Big(response[symbol].marketPrice)
|
||||||
|
.mul(100)
|
||||||
|
.toNumber()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -392,8 +441,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
let name = longName;
|
let name = longName;
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
|
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 - ', '');
|
||||||
|
name = name.replace('iShares V PLC - ', '');
|
||||||
name = name.replace('iShares VI Public Limited Company - ', '');
|
name = name.replace('iShares VI Public Limited Company - ', '');
|
||||||
name = name.replace('iShares VII PLC - ', '');
|
name = name.replace('iShares VII PLC - ', '');
|
||||||
name = name.replace('Multi Units Luxembourg - ', '');
|
name = name.replace('Multi Units Luxembourg - ', '');
|
||||||
@ -412,6 +463,27 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return name || shortName || symbol;
|
return name || shortName || symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getConvertedValue({
|
||||||
|
symbol,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
symbol: string;
|
||||||
|
value: number;
|
||||||
|
}) {
|
||||||
|
if (symbol === `${this.baseCurrency}GBp`) {
|
||||||
|
// Convert GPB to GBp (pence)
|
||||||
|
return new Big(value).mul(100).toNumber();
|
||||||
|
} else if (symbol === `${this.baseCurrency}ILA`) {
|
||||||
|
// Convert ILS to ILA
|
||||||
|
return new Big(value).mul(100).toNumber();
|
||||||
|
} else if (symbol === `${this.baseCurrency}ZAc`) {
|
||||||
|
// Convert ZAR to ZAc (cents)
|
||||||
|
return new Big(value).mul(100).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
private parseAssetClass(aPrice: Price): {
|
private parseAssetClass(aPrice: Price): {
|
||||||
assetClass: AssetClass;
|
assetClass: AssetClass;
|
||||||
assetSubClass: AssetSubClass;
|
assetSubClass: AssetSubClass;
|
||||||
|
@ -4,16 +4,18 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
|||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MarketDataModule } from './market-data.module';
|
||||||
import { PrismaModule } from './prisma.module';
|
import { PrismaModule } from './prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
exports: [ExchangeRateDataService],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule
|
PropertyModule
|
||||||
],
|
],
|
||||||
providers: [ExchangeRateDataService],
|
providers: [ExchangeRateDataService]
|
||||||
exports: [ExchangeRateDataService]
|
|
||||||
})
|
})
|
||||||
export class ExchangeRateDataModule {}
|
export class ExchangeRateDataModule {}
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
import { 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 } from 'date-fns';
|
import { format, isToday } from 'date-fns';
|
||||||
import { isNumber, uniq } from 'lodash';
|
import { isNumber, uniq } from 'lodash';
|
||||||
|
|
||||||
import { ConfigurationService } from './configuration.service';
|
import { ConfigurationService } from './configuration.service';
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
|
import { MarketDataService } from './market-data.service';
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from './prisma.service';
|
||||||
import { PropertyService } from './property/property.service';
|
import { PropertyService } from './property/property.service';
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ export class ExchangeRateDataService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService
|
private readonly propertyService: PropertyService
|
||||||
) {}
|
) {}
|
||||||
@ -152,6 +154,76 @@ export class ExchangeRateDataService {
|
|||||||
return aValue;
|
return aValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async toCurrencyAtDate(
|
||||||
|
aValue: number,
|
||||||
|
aFromCurrency: string,
|
||||||
|
aToCurrency: string,
|
||||||
|
aDate: Date
|
||||||
|
) {
|
||||||
|
if (aValue === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToday(aDate)) {
|
||||||
|
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
let factor: number;
|
||||||
|
|
||||||
|
if (aFromCurrency !== aToCurrency) {
|
||||||
|
const dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||||
|
const symbol = `${aFromCurrency}${aToCurrency}`;
|
||||||
|
|
||||||
|
const marketData = await this.marketDataService.get({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: aDate
|
||||||
|
});
|
||||||
|
|
||||||
|
if (marketData?.marketPrice) {
|
||||||
|
factor = marketData?.marketPrice;
|
||||||
|
} else {
|
||||||
|
// Calculate indirectly via base currency
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
{ marketPrice: marketPriceBaseCurrencyFromCurrency },
|
||||||
|
{ marketPrice: marketPriceBaseCurrencyToCurrency }
|
||||||
|
] = await Promise.all([
|
||||||
|
this.marketDataService.get({
|
||||||
|
dataSource,
|
||||||
|
date: aDate,
|
||||||
|
symbol: `${this.baseCurrency}${aFromCurrency}`
|
||||||
|
}),
|
||||||
|
this.marketDataService.get({
|
||||||
|
dataSource,
|
||||||
|
date: aDate,
|
||||||
|
symbol: `${this.baseCurrency}${aToCurrency}`
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate the opposite direction
|
||||||
|
factor =
|
||||||
|
(1 / marketPriceBaseCurrencyFromCurrency) *
|
||||||
|
marketPriceBaseCurrencyToCurrency;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNumber(factor) && !isNaN(factor)) {
|
||||||
|
return factor * aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(
|
||||||
|
`No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format(
|
||||||
|
aDate,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}`,
|
||||||
|
'ExchangeRateDataService'
|
||||||
|
);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private async prepareCurrencies(): Promise<string[]> {
|
private async prepareCurrencies(): Promise<string[]> {
|
||||||
let currencies: string[] = [];
|
let currencies: string[] = [];
|
||||||
|
|
||||||
|
@ -8,9 +8,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
DATA_SOURCE_PRIMARY: string;
|
DATA_SOURCE_PRIMARY: string;
|
||||||
DATA_SOURCES: string[];
|
DATA_SOURCES: string[];
|
||||||
ENABLE_FEATURE_BLOG: boolean;
|
ENABLE_FEATURE_BLOG: boolean;
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||||
ENABLE_FEATURE_IMPORT: boolean;
|
|
||||||
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
|
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
|
||||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||||
ENABLE_FEATURE_STATISTICS: boolean;
|
ENABLE_FEATURE_STATISTICS: boolean;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { MarketState } from '@ghostfolio/common/types';
|
import { MarketState } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
@ -28,6 +28,7 @@ export interface IDataProviderHistoricalResponse {
|
|||||||
|
|
||||||
export interface IDataProviderResponse {
|
export interface IDataProviderResponse {
|
||||||
currency: string;
|
currency: string;
|
||||||
|
dataProviderInfo?: DataProviderInfo;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
marketState: MarketState;
|
marketState: MarketState;
|
||||||
|
@ -6,6 +6,8 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MarketDataService {
|
export class MarketDataService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
@ -20,14 +22,13 @@ export class MarketDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async get({
|
public async get({
|
||||||
date,
|
dataSource,
|
||||||
|
date = new Date(),
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: IDataGatheringItem): Promise<MarketData> {
|
||||||
date: Date;
|
|
||||||
symbol: string;
|
|
||||||
}): Promise<MarketData> {
|
|
||||||
return await this.prismaService.marketData.findFirst({
|
return await this.prismaService.marketData.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: resetHours(date)
|
date: resetHours(date)
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
import {
|
||||||
|
PROPERTY_CURRENCIES,
|
||||||
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -39,6 +42,13 @@ export class PropertyService {
|
|||||||
return properties?.[aKey];
|
return properties?.[aKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async isUserSignupEnabled() {
|
||||||
|
return (
|
||||||
|
((await this.getByKey(PROPERTY_IS_USER_SIGNUP_ENABLED)) as boolean) ??
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async put({ key, value }: { key: string; value: string }) {
|
public async put({ key, value }: { key: string; value: string }) {
|
||||||
return this.prismaService.property.upsert({
|
return this.prismaService.property.upsert({
|
||||||
create: { key, value },
|
create: { key, value },
|
||||||
|
@ -8,25 +8,14 @@ import {
|
|||||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client';
|
||||||
DataSource,
|
|
||||||
Prisma,
|
|
||||||
SymbolProfile,
|
|
||||||
SymbolProfileOverrides
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { continents, countries } from 'countries-list';
|
import { continents, countries } from 'countries-list';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SymbolProfileService {
|
export class SymbolProfileService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
public async delete({
|
public async delete({ dataSource, symbol }: UniqueAsset) {
|
||||||
dataSource,
|
|
||||||
symbol
|
|
||||||
}: {
|
|
||||||
dataSource: DataSource;
|
|
||||||
symbol: string;
|
|
||||||
}) {
|
|
||||||
return this.prismaService.symbolProfile.delete({
|
return this.prismaService.symbolProfile.delete({
|
||||||
where: { dataSource_symbol: { dataSource, symbol } }
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
});
|
});
|
||||||
@ -43,7 +32,19 @@ export class SymbolProfileService {
|
|||||||
): Promise<EnhancedSymbolProfile[]> {
|
): Promise<EnhancedSymbolProfile[]> {
|
||||||
return this.prismaService.symbolProfile
|
return this.prismaService.symbolProfile
|
||||||
.findMany({
|
.findMany({
|
||||||
include: { SymbolProfileOverrides: true },
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { Order: true }
|
||||||
|
},
|
||||||
|
Order: {
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
select: { date: true },
|
||||||
|
take: 1
|
||||||
|
},
|
||||||
|
SymbolProfileOverrides: true
|
||||||
|
},
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
@ -69,7 +70,12 @@ export class SymbolProfileService {
|
|||||||
): Promise<EnhancedSymbolProfile[]> {
|
): Promise<EnhancedSymbolProfile[]> {
|
||||||
return this.prismaService.symbolProfile
|
return this.prismaService.symbolProfile
|
||||||
.findMany({
|
.findMany({
|
||||||
include: { SymbolProfileOverrides: true },
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { Order: true }
|
||||||
|
},
|
||||||
|
SymbolProfileOverrides: true
|
||||||
|
},
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
in: symbolProfileIds.map((symbolProfileId) => {
|
in: symbolProfileIds.map((symbolProfileId) => {
|
||||||
@ -89,7 +95,12 @@ export class SymbolProfileService {
|
|||||||
): Promise<EnhancedSymbolProfile[]> {
|
): Promise<EnhancedSymbolProfile[]> {
|
||||||
return this.prismaService.symbolProfile
|
return this.prismaService.symbolProfile
|
||||||
.findMany({
|
.findMany({
|
||||||
include: { SymbolProfileOverrides: true },
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { Order: true }
|
||||||
|
},
|
||||||
|
SymbolProfileOverrides: true
|
||||||
|
},
|
||||||
where: {
|
where: {
|
||||||
symbol: {
|
symbol: {
|
||||||
in: symbols
|
in: symbols
|
||||||
@ -99,22 +110,46 @@ export class SymbolProfileService {
|
|||||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public updateSymbolProfile({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
|
return this.prismaService.symbolProfile.update({
|
||||||
|
data: { comment, symbolMapping },
|
||||||
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getSymbols(
|
private getSymbols(
|
||||||
symbolProfiles: (SymbolProfile & {
|
symbolProfiles: (SymbolProfile & {
|
||||||
|
_count: { Order: number };
|
||||||
|
Order?: {
|
||||||
|
date: Date;
|
||||||
|
}[];
|
||||||
SymbolProfileOverrides: SymbolProfileOverrides;
|
SymbolProfileOverrides: SymbolProfileOverrides;
|
||||||
})[]
|
})[]
|
||||||
): EnhancedSymbolProfile[] {
|
): EnhancedSymbolProfile[] {
|
||||||
return symbolProfiles.map((symbolProfile) => {
|
return symbolProfiles.map((symbolProfile) => {
|
||||||
const item = {
|
const item = {
|
||||||
...symbolProfile,
|
...symbolProfile,
|
||||||
|
activitiesCount: 0,
|
||||||
countries: this.getCountries(
|
countries: this.getCountries(
|
||||||
symbolProfile?.countries as unknown as Prisma.JsonArray
|
symbolProfile?.countries as unknown as Prisma.JsonArray
|
||||||
),
|
),
|
||||||
|
dateOfFirstActivity: <Date>undefined,
|
||||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||||
sectors: this.getSectors(symbolProfile),
|
sectors: this.getSectors(symbolProfile),
|
||||||
symbolMapping: this.getSymbolMapping(symbolProfile)
|
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
item.activitiesCount = symbolProfile._count.Order;
|
||||||
|
delete item._count;
|
||||||
|
|
||||||
|
item.dateOfFirstActivity = symbolProfile.Order?.[0]?.date;
|
||||||
|
delete item.Order;
|
||||||
|
|
||||||
if (item.SymbolProfileOverrides) {
|
if (item.SymbolProfileOverrides) {
|
||||||
item.assetClass =
|
item.assetClass =
|
||||||
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"name": "client-e2e",
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "apps/client-e2e/src",
|
"sourceRoot": "apps/client-e2e/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
|
||||||
# For additional information regarding the format and rule options, please see:
|
|
||||||
# https://github.com/browserslist/browserslist#queries
|
|
||||||
|
|
||||||
# For the full list of supported browsers by the Angular framework, please see:
|
|
||||||
# https://angular.io/guide/browser-support
|
|
||||||
|
|
||||||
# You can see what browsers were selected by your queries by running:
|
|
||||||
# npx browserslist
|
|
||||||
|
|
||||||
last 1 Chrome version
|
|
||||||
last 1 Firefox version
|
|
||||||
last 2 Edge major versions
|
|
||||||
last 2 Safari major versions
|
|
||||||
last 2 iOS major versions
|
|
||||||
Firefox ESR
|
|
||||||
not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line.
|
|
||||||
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.
|
|
30
apps/client/ngsw-config.json
Normal file
30
apps/client/ngsw-config.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../../node_modules/@angular/service-worker/config/schema.json",
|
||||||
|
"index": "/index.html",
|
||||||
|
"assetGroups": [
|
||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"installMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/favicon.ico",
|
||||||
|
"/index.html",
|
||||||
|
"/assets/site.webmanifest",
|
||||||
|
"/*.css",
|
||||||
|
"/*.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assets",
|
||||||
|
"installMode": "lazy",
|
||||||
|
"updateMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/assets/**",
|
||||||
|
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"name": "client",
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"generators": {
|
"generators": {
|
||||||
@ -43,6 +44,11 @@
|
|||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
"output": "./../"
|
"output": "./../"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"glob": "site.webmanifest",
|
||||||
|
"input": "apps/client/src/assets",
|
||||||
|
"output": "./../"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "node_modules/ionicons/dist/ionicons",
|
"input": "node_modules/ionicons/dist/ionicons",
|
||||||
@ -59,14 +65,19 @@
|
|||||||
"output": "./../assets/"
|
"output": "./../assets/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["apps/client/src/styles.scss"],
|
"styles": [
|
||||||
|
"apps/client/src/styles/theme.scss",
|
||||||
|
"apps/client/src/styles.scss"
|
||||||
|
],
|
||||||
"scripts": ["node_modules/marked/marked.min.js"],
|
"scripts": ["node_modules/marked/marked.min.js"],
|
||||||
"vendorChunk": true,
|
"vendorChunk": true,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"buildOptimizer": false,
|
"buildOptimizer": false,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"namedChunks": true
|
"namedChunks": true,
|
||||||
|
"serviceWorker": true,
|
||||||
|
"ngswConfigPath": "apps/client/ngsw-config.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"development-de": {
|
"development-de": {
|
||||||
@ -81,6 +92,10 @@
|
|||||||
"baseHref": "/es/",
|
"baseHref": "/es/",
|
||||||
"localize": ["es"]
|
"localize": ["es"]
|
||||||
},
|
},
|
||||||
|
"development-fr": {
|
||||||
|
"baseHref": "/fr/",
|
||||||
|
"localize": ["fr"]
|
||||||
|
},
|
||||||
"development-it": {
|
"development-it": {
|
||||||
"baseHref": "/it/",
|
"baseHref": "/it/",
|
||||||
"localize": ["it"]
|
"localize": ["it"]
|
||||||
@ -89,6 +104,10 @@
|
|||||||
"baseHref": "/nl/",
|
"baseHref": "/nl/",
|
||||||
"localize": ["nl"]
|
"localize": ["nl"]
|
||||||
},
|
},
|
||||||
|
"development-pt": {
|
||||||
|
"baseHref": "/pt/",
|
||||||
|
"localize": ["pt"]
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
@ -136,12 +155,18 @@
|
|||||||
"development-es": {
|
"development-es": {
|
||||||
"browserTarget": "client:build:development-es"
|
"browserTarget": "client:build:development-es"
|
||||||
},
|
},
|
||||||
|
"development-fr": {
|
||||||
|
"browserTarget": "client:build:development-fr"
|
||||||
|
},
|
||||||
"development-it": {
|
"development-it": {
|
||||||
"browserTarget": "client:build:development-it"
|
"browserTarget": "client:build:development-it"
|
||||||
},
|
},
|
||||||
"development-nl": {
|
"development-nl": {
|
||||||
"browserTarget": "client:build:development-nl"
|
"browserTarget": "client:build:development-nl"
|
||||||
},
|
},
|
||||||
|
"development-pt": {
|
||||||
|
"browserTarget": "client:build:development-pt"
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "client:build:production"
|
"browserTarget": "client:build:production"
|
||||||
}
|
}
|
||||||
@ -156,8 +181,10 @@
|
|||||||
"targetFiles": [
|
"targetFiles": [
|
||||||
"messages.de.xlf",
|
"messages.de.xlf",
|
||||||
"messages.es.xlf",
|
"messages.es.xlf",
|
||||||
|
"messages.fr.xlf",
|
||||||
"messages.it.xlf",
|
"messages.it.xlf",
|
||||||
"messages.nl.xlf"
|
"messages.nl.xlf",
|
||||||
|
"messages.pt.xlf"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -186,6 +213,10 @@
|
|||||||
"baseHref": "/es/",
|
"baseHref": "/es/",
|
||||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||||
},
|
},
|
||||||
|
"fr": {
|
||||||
|
"baseHref": "/fr/",
|
||||||
|
"translation": "apps/client/src/locales/messages.fr.xlf"
|
||||||
|
},
|
||||||
"it": {
|
"it": {
|
||||||
"baseHref": "/it/",
|
"baseHref": "/it/",
|
||||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||||
@ -193,6 +224,10 @@
|
|||||||
"nl": {
|
"nl": {
|
||||||
"baseHref": "/nl/",
|
"baseHref": "/nl/",
|
||||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"baseHref": "/pt/",
|
||||||
|
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sourceLocale": "en"
|
"sourceLocale": "en"
|
||||||
|
@ -2,7 +2,7 @@ import { Platform } from '@angular/cdk/platform';
|
|||||||
import { Inject, forwardRef } from '@angular/core';
|
import { Inject, forwardRef } from '@angular/core';
|
||||||
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
|
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
|
||||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { format, parse } from 'date-fns';
|
import { addYears, format, getYear, parse } from 'date-fns';
|
||||||
|
|
||||||
export class CustomDateAdapter extends NativeDateAdapter {
|
export class CustomDateAdapter extends NativeDateAdapter {
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -31,6 +31,16 @@ export class CustomDateAdapter extends NativeDateAdapter {
|
|||||||
* Parses a date from a provided value
|
* Parses a date from a provided value
|
||||||
*/
|
*/
|
||||||
public parse(aValue: string): Date {
|
public parse(aValue: string): Date {
|
||||||
return parse(aValue, getDateFormatString(this.locale), new Date());
|
let date = parse(aValue, getDateFormatString(this.locale), new Date());
|
||||||
|
|
||||||
|
if (getYear(date) < 1900) {
|
||||||
|
if (getYear(date) > Number(format(new Date(), 'yy')) + 1) {
|
||||||
|
date = addYears(date, 1900);
|
||||||
|
} else {
|
||||||
|
date = addYears(date, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return date;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -102,6 +102,34 @@ const routes: Routes = [
|
|||||||
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
|
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
|
||||||
).then((m) => m.Hacktoberfest2022PageModule)
|
).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: 'demo',
|
path: 'demo',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -145,48 +173,6 @@ const routes: Routes = [
|
|||||||
(m) => m.PortfolioPageModule
|
(m) => m.PortfolioPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'portfolio/activities',
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/portfolio/activities/activities-page.module').then(
|
|
||||||
(m) => m.ActivitiesPageModule
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'portfolio/allocations',
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/portfolio/allocations/allocations-page.module').then(
|
|
||||||
(m) => m.AllocationsPageModule
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'portfolio/analysis',
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/portfolio/analysis/analysis-page.module').then(
|
|
||||||
(m) => m.AnalysisPageModule
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'portfolio/fire',
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/portfolio/fire/fire-page.module').then(
|
|
||||||
(m) => m.FirePageModule
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'portfolio/holdings',
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/portfolio/holdings/holdings-page.module').then(
|
|
||||||
(m) => m.HoldingsPageModule
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'portfolio/report',
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/portfolio/report/report-page.module').then(
|
|
||||||
(m) => m.ReportPageModule
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'pricing',
|
path: 'pricing',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -243,9 +229,8 @@ const routes: Routes = [
|
|||||||
// Preload all lazy loaded modules with the attribute preload === true
|
// Preload all lazy loaded modules with the attribute preload === true
|
||||||
{
|
{
|
||||||
anchorScrolling: 'enabled',
|
anchorScrolling: 'enabled',
|
||||||
preloadingStrategy: ModulePreloadService,
|
preloadingStrategy: ModulePreloadService
|
||||||
// enableTracing: true // <-- debugging purposes only
|
// enableTracing: true // <-- debugging purposes only
|
||||||
relativeLinkResolution: 'legacy'
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
class="position-fixed w-100"
|
class="position-fixed w-100"
|
||||||
[currentRoute]="currentRoute"
|
[currentRoute]="currentRoute"
|
||||||
[info]="info"
|
[info]="info"
|
||||||
|
[pageTitle]="pageTitle"
|
||||||
[user]="user"
|
[user]="user"
|
||||||
(signOut)="onSignOut()"
|
(signOut)="onSignOut()"
|
||||||
></gf-header>
|
></gf-header>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~apps/client/src/styles/ghostfolio-style';
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1,20 +1,17 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
|
Inject,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { Title } from '@angular/platform-browser';
|
||||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
||||||
import {
|
|
||||||
primaryColorHex,
|
|
||||||
secondaryColorHex,
|
|
||||||
warnColorHex
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { ColorScheme } from '@ghostfolio/common/types';
|
import { ColorScheme } from '@ghostfolio/common/types';
|
||||||
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { filter, takeUntil } from 'rxjs/operators';
|
import { filter, takeUntil } from 'rxjs/operators';
|
||||||
@ -36,6 +33,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
public currentYear = new Date().getFullYear();
|
public currentYear = new Date().getFullYear();
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
|
public pageTitle: string;
|
||||||
public user: User;
|
public user: User;
|
||||||
public version = environment.version;
|
public version = environment.version;
|
||||||
|
|
||||||
@ -45,8 +43,9 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private materialCssVarsService: MaterialCssVarsService,
|
@Inject(DOCUMENT) private document: Document,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
private title: Title,
|
||||||
private tokenStorageService: TokenStorageService,
|
private tokenStorageService: TokenStorageService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
@ -66,6 +65,19 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.currentRoute = urlSegments[0].path;
|
this.currentRoute = urlSegments[0].path;
|
||||||
|
|
||||||
this.info = this.dataService.fetchInfo();
|
this.info = this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
if (this.deviceType === 'mobile') {
|
||||||
|
setTimeout(() => {
|
||||||
|
const index = this.title.getTitle().indexOf('–');
|
||||||
|
const title =
|
||||||
|
index === -1
|
||||||
|
? ''
|
||||||
|
: this.title.getTitle().substring(0, index).trim();
|
||||||
|
this.pageTitle = title.length <= 15 ? title : 'Ghostfolio';
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
@ -92,7 +104,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.tokenStorageService.signOut();
|
this.tokenStorageService.signOut();
|
||||||
this.userService.remove();
|
this.userService.remove();
|
||||||
|
|
||||||
document.location.href = '/';
|
document.location.href = `/${document.documentElement.lang}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
@ -105,16 +117,20 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
? userPreferredColorScheme === 'DARK'
|
? userPreferredColorScheme === 'DARK'
|
||||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
this.materialCssVarsService.setDarkTheme(isDarkTheme);
|
this.toggleThemeStyleClass(isDarkTheme);
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
|
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
|
||||||
if (!this.user?.settings.colorScheme) {
|
if (!this.user?.settings.colorScheme) {
|
||||||
this.materialCssVarsService.setDarkTheme(event.matches);
|
this.toggleThemeStyleClass(event.matches);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
private toggleThemeStyleClass(isDarkTheme: boolean) {
|
||||||
this.materialCssVarsService.setAccentColor(secondaryColorHex);
|
if (isDarkTheme) {
|
||||||
this.materialCssVarsService.setWarnColor(warnColorHex);
|
this.document.body.classList.add('is-dark-theme');
|
||||||
|
} else {
|
||||||
|
this.document.body.classList.remove('is-dark-theme');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user