Compare commits
297 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
8d8e55fd0b | |||
ca18621ce8 | |||
b8574d24b2 | |||
6d12c27f9c | |||
c2c5326049 | |||
2a1339b61e | |||
c8a2579624 | |||
832ae063df | |||
b5e026934f | |||
901c997908 | |||
3b6e0b20e2 | |||
e449d51c3c | |||
f72d31bab3 | |||
4c893c4dcc | |||
ffb11cd10e | |||
d424b7731e | |||
6043c87481 | |||
fca0a688b6 | |||
5c6cc4fed5 | |||
64a7d38ff9 | |||
68d0d39161 | |||
233a8a8a18 | |||
190779ee35 | |||
6ef8121561 | |||
58bf57d1e6 | |||
71c5412dd5 | |||
ae85398c3d | |||
048900d01b | |||
074b09b543 | |||
f9e04022f4 | |||
8fd1fbd44a | |||
0fb33ae71c | |||
3a35d72ec2 | |||
32fe3e195f | |||
805f4b05be | |||
5b51a6840a | |||
36bd6164e6 | |||
eac52a215b | |||
9ff8cd5471 | |||
33cc7e4e7e | |||
47f84dab06 | |||
384d18b2a6 | |||
2363983bdc | |||
4af76764be | |||
a65424aafa | |||
f9cd629470 | |||
ccb8c86596 | |||
246de7aa86 | |||
a323313c71 | |||
538c8947cd | |||
1ec5fd12fe | |||
4376b8903e | |||
a8e096f9ac | |||
8e577592f6 | |||
c896bf9199 | |||
16145f18d9 | |||
5398da0dc8 | |||
2466f4ff5d | |||
8f3a9bdfbb | |||
44dfd2bd48 | |||
3fc2228f1d | |||
b018819a1f | |||
ac9311d783 | |||
e23ce0f35d | |||
f4b52aa41c | |||
655b040d4d | |||
0f637a5d0f | |||
3f85c327f5 | |||
c2df99072d | |||
e8afbcad9c | |||
e6d8de781b | |||
6e1935899f | |||
169cb85b66 | |||
fe6658d0ac | |||
1f0381228e | |||
f4b63b5de5 | |||
e45a0ad068 | |||
81c6cc021d | |||
859b24aa5b | |||
2bc325f182 | |||
a6186c23e2 | |||
cf234003ec | |||
8d3954304e | |||
9562139fa6 | |||
c857ea9a8f | |||
5c9fa71d95 | |||
fefbfa31d1 | |||
93a1fae51c | |||
3715edd9ba | |||
e3916e1ba3 | |||
76ceac4edc | |||
333b63bfe2 | |||
3006c21b12 | |||
f01a3f893d | |||
72974e888f | |||
0cee7a0b35 | |||
f3d337b044 | |||
7667af059c | |||
1095b47f45 | |||
dacd7271eb | |||
e093041184 | |||
8f2caa508a | |||
862f670ccf | |||
54bf4c7a43 | |||
c0ace51ee9 | |||
b1b5689242 | |||
b68cdaf8ea | |||
b387a80a0d | |||
6e4660295a | |||
d4c3a9d1e8 | |||
263f6b32f2 | |||
637f31ae3b | |||
547e27c7a1 | |||
f10dc176f2 | |||
0a966e46cd | |||
4f281d25e1 | |||
aaba8c35c2 | |||
7d27cb3398 | |||
91678028b5 | |||
5e3cac8ac9 | |||
33f20b6b48 | |||
e4fd255dd7 | |||
e320aa91f7 | |||
0fcfa6c1bd | |||
42d32ed652 |
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: '[BUG]'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
|
||||
**Bug Description**
|
||||
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
**Screenshots**
|
||||
|
||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||
|
||||
**Logs**
|
||||
|
||||
<!-- If applicable, add logs to help explain your problem. -->
|
||||
|
||||
**Environment**
|
||||
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Browser
|
||||
- OS
|
||||
|
||||
**Additional context**
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 16
|
||||
- 18
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -5,6 +5,7 @@
|
||||
/tmp
|
||||
|
||||
# dependencies
|
||||
/.yarn
|
||||
/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
@ -37,4 +38,4 @@ yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
@ -1,4 +1,13 @@
|
||||
{
|
||||
"attributeGroups": [
|
||||
"$ANGULAR_ELEMENT_REF",
|
||||
"$ANGULAR_STRUCTURAL_DIRECTIVE",
|
||||
"$DEFAULT",
|
||||
"$ANGULAR_INPUT",
|
||||
"$ANGULAR_TWO_WAY_BINDING",
|
||||
"$ANGULAR_OUTPUT"
|
||||
],
|
||||
"attributeSort": "ASC",
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
|
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@ -5,11 +5,11 @@
|
||||
"name": "Debug Jest File",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
|
||||
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||
"args": [
|
||||
"test",
|
||||
"--codeCoverage=false",
|
||||
"--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts"
|
||||
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "internalConsole"
|
||||
|
559
CHANGELOG.md
559
CHANGELOG.md
@ -5,6 +5,557 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.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
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _Buy me a coffee_ button to the about page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the activities import
|
||||
- Improved the usage of the premium indicator component
|
||||
- Removed the intro image in dark mode
|
||||
- Refactored the `TransactionsPageComponent` to `ActivitiesPageComponent`
|
||||
|
||||
## 1.208.0 - 03.11.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added pagination to the activities table
|
||||
|
||||
### Changed
|
||||
|
||||
- Restructured the actions in the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the calculation in the portfolio evolution chart
|
||||
|
||||
## 1.207.0 - 31.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for translated labels of asset and asset sub class
|
||||
- Added support for dates in _ISO 8601_ date format (`YYYY-MM-DD`) in the activities import
|
||||
|
||||
### Changed
|
||||
|
||||
- Darkened the background color of the dark mode
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the public page
|
||||
- Improved the loading indicator of the portfolio evolution chart
|
||||
|
||||
## 1.206.2 - 20.10.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed the `rxjs` version to `7.5.6` (resolutions)
|
||||
- Migrated the `angular.json` to `project.json` files in the `Nx` workspace
|
||||
- Upgraded `nestjs` from version `9.0.7` to `9.1.4`
|
||||
- Upgraded `Nx` from version `14.6.4` to `15.0.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the performance calculation including `SELL` activities with a significant performance gain
|
||||
|
||||
## 1.205.2 - 16.10.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Persisted the language on url change
|
||||
- Improved the portfolio evolution chart
|
||||
- Refactored the appearance (dark mode) in user settings (from `appearance` to `colorScheme`)
|
||||
- Improved the wording on the landing page
|
||||
|
||||
## 1.204.1 - 15.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to change the appearance (dark mode) in user settings
|
||||
- Added the total amount chart to the investment timeline
|
||||
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||
|
||||
### Changed
|
||||
|
||||
- Respected the current date in the _FIRE_ calculator
|
||||
- Simplified the settings management in the admin control panel
|
||||
- Renamed the data source type `RAKUTEN` to `RAPID_API`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed some links in the blog posts
|
||||
- Fixed the alignment of the value component on the allocations page
|
||||
|
||||
### Todo
|
||||
|
||||
- Rename the environment variable from `RAKUTEN_RAPID_API_KEY` to `RAPID_API_API_KEY`
|
||||
|
||||
## 1.203.0 - 08.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Supported a progressive line animation in the line chart component
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the benchmark comparator from experimental to general availability
|
||||
- Improved the user interface of the benchmark comparator
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the performance and chart calculation of today
|
||||
- Fixed the alignment of the value component in the admin control panel
|
||||
|
||||
## 1.202.0 - 07.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for a translated 4% rule in the _FIRE_ section
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the caching of the benchmarks in the markets overview (only cache if fetching was successful)
|
||||
- Improved the wording in the twitter bot service
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the support for cryptocurrencies having a symbol with less than 3 characters (e.g. `SC-USD`)
|
||||
- Fixed the text truncation in the value component
|
||||
|
||||
## 1.201.0 - 01.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a blog post: _Hacktoberfest 2022_
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usage of the value component in the admin control panel
|
||||
- Improved the language localization for Español (`es`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the usage of the value component on the allocations page
|
||||
|
||||
## 1.200.0 - 01.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a mini statistics section to the landing page including pulls on _Docker Hub_
|
||||
- Added an _As seen in_ section to the landing page
|
||||
- Added support for an icon in the value component
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `prisma` from version `4.1.1` to `4.4.0`
|
||||
|
||||
## 1.199.1 - 27.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for Español (`es`)
|
||||
- Added support for sectors in mutual funds
|
||||
|
||||
## 1.198.0 - 25.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to exclude an account from analysis
|
||||
- Set up the language localization for Nederlands (`nl`)
|
||||
|
||||
## 1.197.0 - 24.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the value of the active filter in percentage on the allocations page
|
||||
- Extended the feature overview page by multi-language support (English, German, Italian)
|
||||
|
||||
### Changed
|
||||
|
||||
- Combined the performance and chart calculation
|
||||
- Improved the style of various selectors (density)
|
||||
|
||||
## 1.196.0 - 22.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for Italiano (`it`)
|
||||
- Extended the landing page
|
||||
|
||||
## 1.195.0 - 20.09.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the algorithm of the performance chart calculation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the chart tooltip of the benchmark comparator
|
||||
|
||||
## 1.194.0 - 17.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added `NODE_ENV: production` to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
|
||||
- Visualized the percentage of the active filter on the allocations page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Respected the end date in the performance chart calculation
|
||||
|
||||
### Todo
|
||||
|
||||
- Set `NODE_ENV: production` as in [docker-compose.yml](https://github.com/ghostfolio/ghostfolio/blob/main/docker/docker-compose.yml)
|
||||
|
||||
## 1.193.0 - 14.09.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Sorted the benchmarks by name
|
||||
- Extended the pricing page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the calculations of the exchange rate service by changing `USD` to the base currency
|
||||
- Fixed the missing assets during the local development
|
||||
|
||||
## 1.192.0 - 11.09.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified the configuration of the benchmarks: `symbolProfileId` instead of `dataSource` and `symbol`
|
||||
- Upgraded `yahoo-finance2` from version `2.3.3` to `2.3.6`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the loading indicator of the benchmark comparator
|
||||
- Improved the error handling in the benchmark calculation
|
||||
|
||||
## 1.191.0 - 10.09.2022
|
||||
|
||||
### Changed
|
||||
@ -142,7 +693,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow
|
||||
- Set up language localization for German (`de`)
|
||||
- Set up the language localization for German (`de`)
|
||||
- Resolved the feature graphic of the blog post
|
||||
|
||||
### Changed
|
||||
@ -233,7 +784,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Support a note for activities
|
||||
- Supported a note for activities
|
||||
|
||||
### Todo
|
||||
|
||||
@ -804,8 +1355,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Display the value in base currency in the accounts table on mobile
|
||||
- Display the value in base currency in the activities table on mobile
|
||||
- Displayed the value in base currency in the accounts table on mobile
|
||||
- Displayed the value in base currency in the activities table on mobile
|
||||
- Renamed `orders` to `activities` in import and export functionality
|
||||
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
|
||||
- Improved the pricing page
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -25,7 +25,6 @@ RUN yarn install
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
RUN node decorate-angular-cli.js
|
||||
|
||||
COPY ./angular.json angular.json
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.js replace.build.js
|
||||
COPY ./jest.preset.js jest.preset.js
|
||||
@ -51,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:16-slim
|
||||
FROM node:18-slim
|
||||
RUN apt update && apt install -y \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
136
README.md
136
README.md
@ -1,32 +1,26 @@
|
||||
<div align="center">
|
||||
<a href="https://ghostfol.io">
|
||||
<img
|
||||
alt="Ghostfolio Logo"
|
||||
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
||||
width="100"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<h1>Ghostfolio</h1>
|
||||
<p>
|
||||
<strong>Open Source Wealth Management Software</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#contributing">
|
||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||
</p>
|
||||
[<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io)
|
||||
|
||||
# Ghostfolio
|
||||
|
||||
**Open Source Wealth Management Software**
|
||||
|
||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||
|
||||
[](https://www.buymeacoffee.com/ghostfolio)
|
||||
[](#contributing)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
|
||||
</div>
|
||||
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[<img src="./apps/client/src/assets/images/video-preview.jpg" width="600" alt="Preview image of the Ghostfolio video trailer">](https://www.youtube.com/watch?v=yY6ObSQVJZk)
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
||||
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
||||
</div>
|
||||
|
||||
## Ghostfolio Premium
|
||||
@ -46,23 +40,25 @@ Ghostfolio is for you if you are...
|
||||
- 🧘 into minimalism
|
||||
- 🧺 caring about diversifying your financial resources
|
||||
- 🆓 interested in financial independence
|
||||
- 🙅 saying no to spreadsheets in 2022
|
||||
- 🙅 saying no to spreadsheets in 2023
|
||||
- 😎 still reading this list
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
- ✅ Dark Mode
|
||||
- ✅ Zen Mode
|
||||
- ✅ Mobile-first design
|
||||
- ✅ Progressive Web App (PWA) with a mobile-first design
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||
</div>
|
||||
|
||||
## Technology Stack
|
||||
@ -81,22 +77,28 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
||||
|
||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio)
|
||||
|
||||
</div>
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. 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` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application.<br />`AUD` \| `CAD` \| `CNY` \| `EUR` \| `GBP` \| `JPY` \| `RUB` \| `USD`<br />Caution: Only set if you intend to track cryptocurrencies in a non-`USD` currency. This cannot be changed later! |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -128,7 +130,7 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
#### Upgrade Version
|
||||
@ -146,26 +148,30 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- A local copy of this Git repository (clone)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `yarn build:dev` to build the source code including the assets
|
||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
### Start Server
|
||||
|
||||
<ol type="a">
|
||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
||||
<li>Serve: Run <code>yarn start:server</code></li>
|
||||
</ol>
|
||||
#### Debug
|
||||
|
||||
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Serve
|
||||
|
||||
Run `yarn start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
@ -189,20 +195,22 @@ Run `yarn test`
|
||||
|
||||
## Public API
|
||||
|
||||
### Authorization: Bearer Token
|
||||
|
||||
Set the header for each request as follows:
|
||||
|
||||
```
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||
|
||||
### Import Activities
|
||||
|
||||
#### Request
|
||||
|
||||
`POST http://localhost:3333/api/v1/import`
|
||||
|
||||
#### Authorization: Bearer Token
|
||||
|
||||
Set the header as follows:
|
||||
|
||||
```
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
#### Body
|
||||
|
||||
```
|
||||
@ -214,7 +222,7 @@ Set the header as follows:
|
||||
"date": "2021-09-15T00:00:00.000Z",
|
||||
"fee": 19,
|
||||
"quantity": 5,
|
||||
"symbol": "MSFT"
|
||||
"symbol": "MSFT",
|
||||
"type": "BUY",
|
||||
"unitPrice": 298.58
|
||||
}
|
||||
@ -253,16 +261,20 @@ Set the header as follows:
|
||||
}
|
||||
```
|
||||
|
||||
## Community Projects
|
||||
|
||||
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
||||
|
||||
## Contributing
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
||||
|
||||
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
|
||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||
|
||||
## License
|
||||
|
||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||
© 2023 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
357
angular.json
357
angular.json
@ -1,357 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"projects": {
|
||||
"api": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/api",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"prefix": "api",
|
||||
"schematics": {},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@nrwl/node:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/api/src/environments/environment.ts",
|
||||
"with": "apps/api/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@nrwl/node:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/api"]
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "apps/client",
|
||||
"sourceRoot": "apps/client/src",
|
||||
"prefix": "gf",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/client",
|
||||
"index": "apps/client/src/index.html",
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "assetlinks.json",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../.well-known"
|
||||
},
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/ionicons/dist/ionicons",
|
||||
"output": "./../ionicons"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.js",
|
||||
"input": "node_modules/ionicons/dist/",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../assets/"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
},
|
||||
"development-en": {
|
||||
"baseHref": "/en/",
|
||||
"localize": ["en"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/client/src/environments/environment.ts",
|
||||
"with": "apps/client/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "10kb"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"proxyConfig": "apps/client/proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"browserTarget": "client:build:development-de"
|
||||
},
|
||||
"development-en": {
|
||||
"browserTarget": "client:build:development-en"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": ["messages.de.xlf"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/client/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/client"]
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/client-e2e",
|
||||
"sourceRoot": "apps/client-e2e/src",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/client-e2e/cypress.json",
|
||||
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
|
||||
"devServerTarget": "client:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "client:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
},
|
||||
"common": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "libs/common",
|
||||
"sourceRoot": "libs/common/src",
|
||||
"projectType": "library",
|
||||
"architect": {
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/common/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/common"],
|
||||
"options": {
|
||||
"jestConfig": "libs/common/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "libs/ui",
|
||||
"sourceRoot": "libs/ui/src",
|
||||
"prefix": "gf",
|
||||
"architect": {
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/ui"],
|
||||
"options": {
|
||||
"jestConfig": "libs/ui/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"port": 4400,
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@storybook/angular:build-storybook",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputDir": "dist/storybook/ui",
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/ui-e2e",
|
||||
"sourceRoot": "apps/ui-e2e/src",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/ui-e2e/cypress.json",
|
||||
"devServerTarget": "ui:storybook",
|
||||
"tsConfig": "apps/ui-e2e/tsconfig.json"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"devServerTarget": "ui:storybook:ci"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["ui"]
|
||||
}
|
||||
}
|
||||
}
|
57
apps/api/project.json
Normal file
57
apps/api/project.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "api",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"prefix": "api",
|
||||
"generators": {},
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/webpack:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"],
|
||||
"target": "node",
|
||||
"compiler": "tsc"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/api/src/environments/environment.ts",
|
||||
"with": "apps/api/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nrwl/node:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
@ -1,9 +1,6 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
nullifyValuesInObject,
|
||||
nullifyValuesInObjects
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
@ -22,7 +19,8 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -85,6 +83,7 @@ export class AccountController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<Accounts> {
|
||||
@ -94,38 +93,15 @@ export class AccountController {
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
accountsWithAggregations = {
|
||||
...nullifyValuesInObject(accountsWithAggregations, [
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency'
|
||||
]),
|
||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||
'balance',
|
||||
'balanceInBaseCurrency',
|
||||
'convertedBalance',
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
return accountsWithAggregations;
|
||||
return this.portfolioService.getAccountsWithAggregations({
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountById(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Param('id') id: string
|
||||
@ -136,33 +112,12 @@ export class AccountController {
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id,
|
||||
[{ id, type: 'ACCOUNT' }]
|
||||
);
|
||||
|
||||
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'
|
||||
])
|
||||
};
|
||||
}
|
||||
const accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
filters: [{ id, type: 'ACCOUNT' }],
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
return accountsWithAggregations.accounts[0];
|
||||
}
|
||||
|
@ -107,15 +107,23 @@ export class AccountService {
|
||||
public async getCashDetails({
|
||||
currency,
|
||||
filters = [],
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
currency: string;
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<CashDetails> {
|
||||
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||
|
||||
const where: Prisma.AccountWhereInput = { userId };
|
||||
const where: Prisma.AccountWhereInput = {
|
||||
userId
|
||||
};
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.isExcluded = false;
|
||||
}
|
||||
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
|
@ -1,5 +1,11 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString()
|
||||
@ -11,6 +17,10 @@ export class CreateAccountDto {
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
|
@ -1,5 +1,11 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
@ -14,6 +20,10 @@ export class UpdateAccountDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile,
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
@ -21,6 +22,7 @@ import {
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
@ -33,6 +35,7 @@ import { isDate } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
@Controller('admin')
|
||||
@ -332,6 +335,32 @@ export class AdminController {
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Patch('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async patchAssetProfileData(
|
||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<EnhancedSymbolProfile> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.patchAssetProfileData({
|
||||
...assetProfileData,
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
|
||||
@Put('settings/:key')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateProperty(
|
||||
|
@ -116,6 +116,7 @@ export class AdminService {
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
dataSource: true,
|
||||
Order: {
|
||||
@ -147,9 +148,10 @@ export class AdminService {
|
||||
countriesCount,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activityCount: symbolProfile._count.Order,
|
||||
activitiesCount: symbolProfile._count.Order,
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
comment: symbolProfile.comment,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
@ -165,8 +167,14 @@ export class AdminService {
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||
return {
|
||||
marketData: await this.marketDataService.marketDataItems({
|
||||
const [[assetProfile], marketData] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]),
|
||||
this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
@ -175,16 +183,44 @@ export class AdminService {
|
||||
symbol
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
assetProfile,
|
||||
marketData
|
||||
};
|
||||
}
|
||||
|
||||
public async patchAssetProfileData({
|
||||
comment,
|
||||
dataSource,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
await this.symbolProfileService.updateSymbolProfile({
|
||||
comment,
|
||||
dataSource,
|
||||
symbol,
|
||||
symbolMapping
|
||||
});
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]);
|
||||
|
||||
return symbolProfile;
|
||||
}
|
||||
|
||||
public async putSetting(key: string, value: string) {
|
||||
let response: Property;
|
||||
|
||||
if (value === '') {
|
||||
response = await this.propertyService.delete({ key });
|
||||
} else {
|
||||
if (value) {
|
||||
response = await this.propertyService.put({ key, value });
|
||||
} else {
|
||||
response = await this.propertyService.delete({ key });
|
||||
}
|
||||
|
||||
if (key === PROPERTY_CURRENCIES) {
|
||||
|
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;
|
||||
};
|
||||
}
|
@ -22,10 +22,12 @@ import { AppController } from './app.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { FrontendMiddleware } from './frontend.middleware';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
import { LogoModule } from './logo/logo.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
@ -52,10 +54,12 @@ import { UserModule } from './user/user.module';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateModule,
|
||||
ExchangeRateDataModule,
|
||||
ExportModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
LogoModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
@ -58,18 +59,21 @@ export class AuthController {
|
||||
@Get('google/callback')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public googleLoginCallback(@Req() req, @Res() res) {
|
||||
public googleLoginCallback(
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
// Handles the Google OAuth2 callback
|
||||
const jwt: string = req.user.jwt;
|
||||
const jwt: string = (<any>request.user).jwt;
|
||||
|
||||
if (jwt) {
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||
);
|
||||
} else {
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
}),
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
SubscriptionModule,
|
||||
UserModule
|
||||
],
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Provider } from '@prisma/client';
|
||||
@ -11,6 +12,7 @@ export class AuthService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@ -50,6 +52,13 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (!isUserSignupEnabled) {
|
||||
throw new Error('Sign up forbidden');
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
@ -78,6 +87,13 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (!isUserSignupEnabled) {
|
||||
throw new Error('Sign up forbidden');
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
|
@ -30,8 +30,8 @@ export class BenchmarkController {
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:startDateString')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getBenchmarkMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('startDateString') startDateString: string,
|
||||
|
@ -4,7 +4,10 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
|
||||
import {
|
||||
MAX_CHART_ITEMS,
|
||||
PROPERTY_BENCHMARKS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
@ -12,10 +15,10 @@ import {
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class BenchmarkService {
|
||||
@ -31,7 +34,11 @@ export class BenchmarkService {
|
||||
) {}
|
||||
|
||||
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
||||
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
||||
if (baseValue && currentValue) {
|
||||
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async getBenchmarks({ useCache = true } = {}): Promise<
|
||||
@ -51,25 +58,26 @@ export class BenchmarkService {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const benchmarkAssets: UniqueAsset[] =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as UniqueAsset[]) ?? [];
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||
|
||||
const promises: Promise<number>[] = [];
|
||||
|
||||
const [quotes, assetProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes(benchmarkAssets),
|
||||
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
|
||||
]);
|
||||
const quotes = await this.dataProviderService.getQuotes(
|
||||
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
|
||||
for (const benchmarkAsset of benchmarkAssets) {
|
||||
promises.push(this.marketDataService.getMax(benchmarkAsset));
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||
}
|
||||
|
||||
const allTimeHighs = await Promise.all(promises);
|
||||
let storeInCache = true;
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
const { marketPrice } = quotes[benchmarkAssets[index].symbol] ?? {};
|
||||
const { marketPrice } =
|
||||
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||
|
||||
let performancePercentFromAllTimeHigh = 0;
|
||||
|
||||
@ -78,18 +86,15 @@ export class BenchmarkService {
|
||||
allTimeHigh,
|
||||
marketPrice
|
||||
);
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
),
|
||||
name: assetProfiles.find(({ dataSource, symbol }) => {
|
||||
return (
|
||||
dataSource === benchmarkAssets[index].dataSource &&
|
||||
symbol === benchmarkAssets[index].symbol
|
||||
);
|
||||
})?.name,
|
||||
name: benchmarkAssetProfiles[index].name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
performancePercent: performancePercentFromAllTimeHigh
|
||||
@ -98,31 +103,39 @@ export class BenchmarkService {
|
||||
};
|
||||
});
|
||||
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
);
|
||||
if (storeInCache) {
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
public async getBenchmarkAssetProfiles(): Promise<UniqueAsset[]> {
|
||||
const benchmarkAssets: UniqueAsset[] =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as UniqueAsset[]) ?? [];
|
||||
|
||||
const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
benchmarkAssets
|
||||
);
|
||||
|
||||
return assetProfiles.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol
|
||||
};
|
||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||
const symbolProfileIds: string[] = (
|
||||
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
||||
symbolProfileId: string;
|
||||
}[]) ?? []
|
||||
).map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
|
||||
const assetProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||
|
||||
return assetProfiles
|
||||
.map(({ dataSource, id, name, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
symbol
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
public async getMarketDataBySymbol({
|
||||
@ -151,28 +164,44 @@ export class BenchmarkService {
|
||||
})
|
||||
]);
|
||||
|
||||
marketDataItems.push({
|
||||
...currentSymbolItem,
|
||||
createdAt: new Date(),
|
||||
date: new Date(),
|
||||
id: uuidv4()
|
||||
});
|
||||
const step = Math.round(
|
||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||
);
|
||||
|
||||
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
||||
return {
|
||||
marketData: marketDataItems.map((marketDataItem) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value:
|
||||
marketPriceAtStartDate === 0
|
||||
? 0
|
||||
: this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
marketDataItem.marketPrice
|
||||
) * 100
|
||||
};
|
||||
})
|
||||
const response = {
|
||||
marketData: [
|
||||
...marketDataItems
|
||||
.filter((marketDataItem, index) => {
|
||||
return index % step === 0;
|
||||
})
|
||||
.map((marketDataItem) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value:
|
||||
marketPriceAtStartDate === 0
|
||||
? 0
|
||||
: this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
marketDataItem.marketPrice
|
||||
) * 100
|
||||
};
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
if (currentSymbolItem?.marketPrice) {
|
||||
response.marketData.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
value:
|
||||
this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
currentSymbolItem.marketPrice
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: number) {
|
||||
|
26
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
26
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { ExchangeRateService } from './exchange-rate.service';
|
||||
|
||||
@Controller('exchange-rate')
|
||||
export class ExchangeRateController {
|
||||
public constructor(
|
||||
private readonly exchangeRateService: ExchangeRateService
|
||||
) {}
|
||||
|
||||
@Get(':symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getExchangeRate(
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<IDataProviderHistoricalResponse> {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return this.exchangeRateService.getExchangeRate({
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
}
|
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 {}
|
29
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
29
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ExchangeRateService {
|
||||
public constructor(
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||
) {}
|
||||
|
||||
public async getExchangeRate({
|
||||
date,
|
||||
symbol
|
||||
}: {
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<IDataProviderHistoricalResponse> {
|
||||
const [currency1, currency2] = symbol.split('-');
|
||||
|
||||
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
1,
|
||||
currency1,
|
||||
currency2,
|
||||
date
|
||||
);
|
||||
|
||||
return { marketPrice };
|
||||
}
|
||||
}
|
@ -3,52 +3,172 @@ import * as path from 'path';
|
||||
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { format } from 'date-fns';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlDe = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('de'),
|
||||
'utf8'
|
||||
);
|
||||
public indexHtmlEn = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||
'utf8'
|
||||
);
|
||||
public indexHtmlDe = '';
|
||||
public indexHtmlEn = '';
|
||||
public indexHtmlEs = '';
|
||||
public indexHtmlFr = '';
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public indexHtmlPt = '';
|
||||
public isProduction: boolean;
|
||||
|
||||
public constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
) {
|
||||
const NODE_ENV =
|
||||
this.configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||
'development';
|
||||
|
||||
public use(req: Request, res: Response, next: NextFunction) {
|
||||
this.isProduction = NODE_ENV === 'production';
|
||||
|
||||
try {
|
||||
this.indexHtmlDe = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('de'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlEn = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlEs = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('es'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlFr = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('fr'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlIt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('it'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlNl = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('nl'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlPt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('pt'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public use(request: Request, response: Response, next: NextFunction) {
|
||||
const currentDate = format(new Date(), DATE_FORMAT);
|
||||
let featureGraphicPath = 'assets/cover.png';
|
||||
let title = 'Ghostfolio – Open Source Wealth Management Software';
|
||||
|
||||
if (
|
||||
req.path === '/en/blog/2022/08/500-stars-on-github' ||
|
||||
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
||||
) {
|
||||
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||
title = `500 Stars - ${title}`;
|
||||
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||
title = `Hacktoberfest 2022 - ${title}`;
|
||||
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
||||
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
|
||||
title = `Black Friday 2022 - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/20221226.jpg';
|
||||
title = `The importance of tracking your personal finances - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
||||
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) {
|
||||
if (
|
||||
request.path.startsWith('/api/') ||
|
||||
this.isFileRequest(request.url) ||
|
||||
!this.isProduction
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
||||
res.send(
|
||||
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlDe, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'de',
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlEs, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'es',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlFr, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'fr',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlIt, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'it',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlNl, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'nl',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlPt, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'pt',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else {
|
||||
res.send(
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlEn, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
|
@ -1,16 +1,24 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { ImportDataDto } from './import-data.dto';
|
||||
@ -26,7 +34,10 @@ export class ImportController {
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
||||
public async import(
|
||||
@Body() importData: ImportDataDto,
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
): Promise<ImportResponse> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -45,12 +56,18 @@ export class ImportController {
|
||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
try {
|
||||
return await this.importService.import({
|
||||
const activities = await this.importService.import({
|
||||
maxActivitiesToImport,
|
||||
activities: importData.activities,
|
||||
isDryRun,
|
||||
userCurrency,
|
||||
activitiesDto: importData.activities,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { activities };
|
||||
} catch (error) {
|
||||
Logger.error(error, ImportController);
|
||||
|
||||
@ -63,4 +80,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 { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
@ -19,9 +22,12 @@ import { ImportService } from './import.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [ImportService]
|
||||
})
|
||||
|
@ -1,30 +1,118 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AccountWithPlatform,
|
||||
OrderWithAccount
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isSameDay, parseISO } from 'date-fns';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly orderService: OrderService
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
||||
try {
|
||||
const { firstBuyDate, historicalData, orders } =
|
||||
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||
|
||||
const [[assetProfile], dividends] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]),
|
||||
await this.dataProviderService.getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
from: parseDate(firstBuyDate),
|
||||
granularity: 'day',
|
||||
to: new Date()
|
||||
})
|
||||
]);
|
||||
|
||||
const accounts = orders.map((order) => {
|
||||
return order.Account;
|
||||
});
|
||||
|
||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
|
||||
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
|
||||
const quantity =
|
||||
historicalData.find((historicalDataItem) => {
|
||||
return historicalDataItem.date === dateString;
|
||||
})?.quantity ?? 0;
|
||||
|
||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||
|
||||
return {
|
||||
Account,
|
||||
quantity,
|
||||
value,
|
||||
accountId: Account?.id,
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
createdAt: undefined,
|
||||
date: parseDate(dateString),
|
||||
fee: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
isDraft: false,
|
||||
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||
symbolProfileId: assetProfile.id,
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: marketPrice,
|
||||
updatedAt: undefined,
|
||||
userId: Account?.userId,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
assetProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async import({
|
||||
activities,
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const activity of activities) {
|
||||
}): Promise<Activity[]> {
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
activity.dataSource = 'MANUAL';
|
||||
@ -34,8 +122,8 @@ export class ImportService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.validateActivities({
|
||||
activities,
|
||||
const assetProfiles = await this.validateActivities({
|
||||
activitiesDto,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
});
|
||||
@ -46,60 +134,138 @@ export class ImportService {
|
||||
}
|
||||
);
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (const {
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
date: dateString,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
} of activities) {
|
||||
await this.orderService.createOrder({
|
||||
comment,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
} of activitiesDto) {
|
||||
const date = parseISO(<string>(<unknown>dateString));
|
||||
const validatedAccountId = accountIds.includes(accountId)
|
||||
? accountId
|
||||
: undefined;
|
||||
|
||||
let order: OrderWithAccount;
|
||||
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
id: uuidv4(),
|
||||
isDraft: isAfter(date, endOfToday()),
|
||||
SymbolProfile: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol,
|
||||
assetClass: null,
|
||||
assetSubClass: null,
|
||||
comment: null,
|
||||
countries: null,
|
||||
createdAt: undefined,
|
||||
id: undefined,
|
||||
name: null,
|
||||
scraperConfiguration: null,
|
||||
sectors: null,
|
||||
symbolMapping: null,
|
||||
updatedAt: undefined,
|
||||
url: null,
|
||||
...assetProfiles[symbol]
|
||||
},
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
} else {
|
||||
order = await this.orderService.createOrder({
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
|
||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||
|
||||
activities.push({
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
||||
const uniqueAccountIds = new Set<string>();
|
||||
|
||||
for (const account of accounts) {
|
||||
uniqueAccountIds.add(account.id);
|
||||
}
|
||||
|
||||
return uniqueAccountIds.size === 1;
|
||||
}
|
||||
|
||||
private async validateActivities({
|
||||
activities,
|
||||
activitiesDto,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
userId: string;
|
||||
}) {
|
||||
if (activities?.length > maxActivitiesToImport) {
|
||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||
}
|
||||
|
||||
const assetProfiles: {
|
||||
[symbol: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
const existingActivities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
@ -109,7 +275,7 @@ export class ImportService {
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
] of activities.entries()) {
|
||||
] of activitiesDto.entries()) {
|
||||
const duplicateActivity = existingActivities.find((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
@ -128,22 +294,28 @@ export class ImportService {
|
||||
}
|
||||
|
||||
if (dataSource !== 'MANUAL') {
|
||||
const quotes = await this.dataProviderService.getQuotes([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
const assetProfile = (
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol];
|
||||
|
||||
if (quotes[symbol] === undefined) {
|
||||
if (assetProfile === undefined) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (quotes[symbol].currency !== currency) {
|
||||
if (assetProfile.currency !== currency) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"`
|
||||
);
|
||||
}
|
||||
|
||||
assetProfiles[symbol] = assetProfile;
|
||||
}
|
||||
}
|
||||
|
||||
return assetProfiles;
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEMO_USER_ID,
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
@ -92,6 +93,10 @@ export class InfoService {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
globalPermissions.push(permissions.enableSubscription);
|
||||
|
||||
info.countriesOfSubscribers =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
|
||||
)) as string[]) ?? [];
|
||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
@ -103,6 +108,13 @@ export class InfoService {
|
||||
)) as string;
|
||||
}
|
||||
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (isUserSignupEnabled) {
|
||||
globalPermissions.push(permissions.createUserAccount);
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
globalPermissions,
|
||||
@ -145,6 +157,27 @@ export class InfoService {
|
||||
});
|
||||
}
|
||||
|
||||
private async countDockerHubPulls(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||
'GET',
|
||||
'json',
|
||||
200,
|
||||
{
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
);
|
||||
|
||||
const { pull_count } = await get();
|
||||
return pull_count;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async countGitHubContributors(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
@ -245,6 +278,8 @@ export class InfoService {
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const newUsers30d = await this.countNewUsers(30);
|
||||
|
||||
const dockerHubPulls = await this.countDockerHubPulls();
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||
@ -252,6 +287,7 @@ export class InfoService {
|
||||
statistics = {
|
||||
activeUsers1d,
|
||||
activeUsers30d,
|
||||
dockerHubPulls,
|
||||
gitHubContributors,
|
||||
gitHubStargazers,
|
||||
newUsers30d,
|
||||
@ -271,14 +307,14 @@ export class InfoService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stripeConfig = await this.prismaService.property.findUnique({
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
});
|
||||
})) ?? { value: '{}' };
|
||||
|
||||
if (stripeConfig) {
|
||||
return [JSON.parse(stripeConfig.value)];
|
||||
}
|
||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||
|
||||
return [];
|
||||
return subscriptions;
|
||||
}
|
||||
}
|
||||
|
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 {
|
||||
feeInBaseCurrency: number;
|
||||
value: number;
|
||||
valueInBaseCurrency: number;
|
||||
}
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
@ -36,10 +34,10 @@ import { UpdateOrderDto } from './update-order.dto';
|
||||
@Controller('order')
|
||||
export class OrderController {
|
||||
public constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@ -73,30 +71,11 @@ export class OrderController {
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Activities> {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
const filters: Filter[] = [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
@ -105,27 +84,14 @@ export class OrderController {
|
||||
);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
let activities = await this.orderService.getOrders({
|
||||
const activities = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userId: impersonationUserId || this.request.user.id
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
activities = nullifyValuesInObjects(activities, [
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
return { activities };
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -18,6 +19,7 @@ import { OrderService } from './order.service';
|
||||
controllers: [OrderController],
|
||||
exports: [OrderService],
|
||||
imports: [
|
||||
ApiModule,
|
||||
CacheModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
|
@ -189,13 +189,15 @@ export class OrderService {
|
||||
includeDrafts = false,
|
||||
types,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<Activity[]> {
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
@ -284,24 +286,28 @@ export class OrderService {
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
).map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
)
|
||||
.filter((order) => {
|
||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
||||
})
|
||||
.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async updateOrder({
|
||||
@ -356,6 +362,12 @@ export class OrderService {
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
|
||||
// Remove existing tags
|
||||
await this.prismaService.order.update({
|
||||
data: { tags: { set: [] } },
|
||||
where
|
||||
});
|
||||
|
||||
return this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
|
@ -21,6 +21,17 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'BTCUSD':
|
||||
if (isSameDay(parseDate('2015-01-01'), date)) {
|
||||
return { marketPrice: 314.25 };
|
||||
} else if (isSameDay(parseDate('2017-12-31'), date)) {
|
||||
return { marketPrice: 14156.4 };
|
||||
} else if (isSameDay(parseDate('2018-01-01'), date)) {
|
||||
return { marketPrice: 13657.2 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'NOVN.SW':
|
||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||
return { marketPrice: 87.8 };
|
||||
|
@ -78,6 +78,7 @@ describe('CurrentRateService', () => {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -0,0 +1,111 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BTCUSD buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2015-01-01',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BTCUSD',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(320.43)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2017-12-31',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BTCUSD',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(14156.4)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2015-01-01')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('13657.2'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('320.43'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
investment: new Big('320.43'),
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
marketPrice: 13657.2,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'BTCUSD',
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('320.43')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2017-12-31', investment: new Big('320.43') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -22,7 +22,7 @@ describe('PortfolioCalculator', () => {
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell', async () => {
|
||||
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -0,0 +1,131 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with NOVN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2022-03-07',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(75.8)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2022-04-08',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(85.73)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(chartData[0]).toEqual({
|
||||
date: '2022-03-07',
|
||||
netPerformanceInPercentage: 0,
|
||||
netPerformance: 0,
|
||||
totalInvestment: 151.6,
|
||||
value: 151.6
|
||||
});
|
||||
|
||||
expect(chartData[chartData.length - 1]).toEqual({
|
||||
date: '2022-04-11',
|
||||
netPerformanceInPercentage: 13.100263852242744,
|
||||
netPerformance: 19.86,
|
||||
totalInvestment: 0,
|
||||
value: 19.86
|
||||
});
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('19.86'),
|
||||
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
investment: new Big('0'),
|
||||
netPerformance: new Big('19.86'),
|
||||
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
marketPrice: 87.8,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'NOVN.SW',
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2022-03-07', investment: new Big('151.6') },
|
||||
{ date: '2022-04-08', investment: new Big('0') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||
{ date: '2022-04-01', investment: new Big('-171.46') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -2,6 +2,7 @@ import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { GroupBy } from '@ghostfolio/common/types';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
@ -14,14 +15,14 @@ import {
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
isWithinInterval,
|
||||
max,
|
||||
min,
|
||||
set
|
||||
} from 'date-fns';
|
||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
||||
import { first, flatten, isNumber, last, sortBy } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
@ -168,6 +169,149 @@ export class PortfolioCalculator {
|
||||
this.transactionPoints = transactionPoints;
|
||||
}
|
||||
|
||||
public async getChartData(start: Date, end = new Date(Date.now()), step = 1) {
|
||||
const symbols: { [symbol: string]: boolean } = {};
|
||||
|
||||
const transactionPointsBeforeEndDate =
|
||||
this.transactionPoints?.filter((transactionPoint) => {
|
||||
return isBefore(parseDate(transactionPoint.date), end);
|
||||
}) ?? [];
|
||||
|
||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||
const dates: Date[] = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
|
||||
let day = start;
|
||||
|
||||
while (isBefore(day, end)) {
|
||||
dates.push(resetHours(day));
|
||||
day = addDays(day, step);
|
||||
}
|
||||
|
||||
if (!isSameDay(last(dates), end)) {
|
||||
dates.push(resetHours(end));
|
||||
}
|
||||
|
||||
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
currencies[item.symbol] = item.currency;
|
||||
symbols[item.symbol] = true;
|
||||
}
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const dateString = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[dateString]) {
|
||||
marketSymbolMap[dateString] = {};
|
||||
}
|
||||
if (marketSymbol.marketPriceInBaseCurrency) {
|
||||
marketSymbolMap[dateString][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPriceInBaseCurrency
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const netPerformanceValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
} = {};
|
||||
|
||||
const investmentValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
} = {};
|
||||
|
||||
const maxInvestmentValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
} = {};
|
||||
|
||||
const totalNetPerformanceValues: { [date: string]: Big } = {};
|
||||
const totalInvestmentValues: { [date: string]: Big } = {};
|
||||
const maxTotalInvestmentValues: { [date: string]: Big } = {};
|
||||
|
||||
for (const symbol of Object.keys(symbols)) {
|
||||
const { investmentValues, maxInvestmentValues, netPerformanceValues } =
|
||||
this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step,
|
||||
symbol,
|
||||
isChartMode: true
|
||||
});
|
||||
|
||||
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
||||
investmentValuesBySymbol[symbol] = investmentValues;
|
||||
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
|
||||
}
|
||||
|
||||
for (const currentDate of dates) {
|
||||
const dateString = format(currentDate, DATE_FORMAT);
|
||||
|
||||
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
|
||||
totalNetPerformanceValues[dateString] =
|
||||
totalNetPerformanceValues[dateString] ?? new Big(0);
|
||||
|
||||
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
|
||||
dateString
|
||||
].add(netPerformanceValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
|
||||
totalInvestmentValues[dateString] =
|
||||
totalInvestmentValues[dateString] ?? new Big(0);
|
||||
|
||||
maxTotalInvestmentValues[dateString] =
|
||||
maxTotalInvestmentValues[dateString] ?? new Big(0);
|
||||
|
||||
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalInvestmentValues[dateString] = totalInvestmentValues[
|
||||
dateString
|
||||
].add(investmentValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
|
||||
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
|
||||
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
|
||||
dateString
|
||||
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
||||
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
|
||||
? 0
|
||||
: totalNetPerformanceValues[date]
|
||||
.div(maxTotalInvestmentValues[date])
|
||||
.mul(100)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
||||
totalInvestment: totalInvestmentValues[date].toNumber(),
|
||||
value: totalInvestmentValues[date]
|
||||
.plus(totalNetPerformanceValues[date])
|
||||
.toNumber()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async getCurrentPositions(
|
||||
start: Date,
|
||||
end = new Date(Date.now())
|
||||
@ -303,7 +447,7 @@ export class PortfolioCalculator {
|
||||
transactionCount: item.transactionCount
|
||||
});
|
||||
|
||||
if (hasErrors) {
|
||||
if (hasErrors && item.investment.gt(0)) {
|
||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||
}
|
||||
}
|
||||
@ -335,46 +479,60 @@ export class PortfolioCalculator {
|
||||
});
|
||||
}
|
||||
|
||||
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
||||
public getInvestmentsByGroup(
|
||||
groupBy: GroupBy
|
||||
): { date: string; investment: Big }[] {
|
||||
if (this.orders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const investments = [];
|
||||
let currentDate: Date;
|
||||
let investmentByMonth = new Big(0);
|
||||
let investmentByGroup = new Big(0);
|
||||
|
||||
for (const [index, order] of this.orders.entries()) {
|
||||
if (
|
||||
isSameMonth(parseDate(order.date), currentDate) &&
|
||||
isSameYear(parseDate(order.date), currentDate)
|
||||
isSameYear(parseDate(order.date), currentDate) &&
|
||||
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||
) {
|
||||
// Same month: Add up investments
|
||||
// Same group: Add up investments
|
||||
|
||||
investmentByMonth = investmentByMonth.plus(
|
||||
investmentByGroup = investmentByGroup.plus(
|
||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
);
|
||||
} else {
|
||||
// New month: Store previous month and reset
|
||||
// New group: Store previous group and reset
|
||||
|
||||
if (currentDate) {
|
||||
investments.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup
|
||||
});
|
||||
}
|
||||
|
||||
currentDate = parseDate(order.date);
|
||||
investmentByMonth = order.quantity
|
||||
investmentByGroup = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
}
|
||||
|
||||
if (index === this.orders.length - 1) {
|
||||
// Store current month (latest order)
|
||||
// Store current group (latest order)
|
||||
investments.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -710,15 +868,19 @@ export class PortfolioCalculator {
|
||||
|
||||
private getSymbolMetrics({
|
||||
end,
|
||||
isChartMode = false,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step = 1,
|
||||
symbol
|
||||
}: {
|
||||
end: Date;
|
||||
isChartMode?: boolean;
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
start: Date;
|
||||
step?: number;
|
||||
symbol: string;
|
||||
}) {
|
||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
||||
@ -767,12 +929,11 @@ export class PortfolioCalculator {
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
const investmentValues: { [date: string]: Big } = {};
|
||||
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastTransactionInvestment = new Big(0);
|
||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
||||
let maxTotalInvestment = new Big(0);
|
||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
const netPerformanceValues: { [date: string]: Big } = {};
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
@ -805,6 +966,41 @@ export class PortfolioCalculator {
|
||||
unitPrice: unitPriceAtEndDate
|
||||
});
|
||||
|
||||
let day = start;
|
||||
let lastUnitPrice: Big;
|
||||
|
||||
if (isChartMode) {
|
||||
const datesWithOrders = {};
|
||||
|
||||
for (const order of orders) {
|
||||
datesWithOrders[order.date] = true;
|
||||
}
|
||||
|
||||
while (isBefore(day, end)) {
|
||||
const hasDate = datesWithOrders[format(day, DATE_FORMAT)];
|
||||
|
||||
if (!hasDate) {
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(day, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
unitPrice:
|
||||
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
|
||||
lastUnitPrice
|
||||
});
|
||||
}
|
||||
|
||||
lastUnitPrice = last(orders).unitPrice;
|
||||
|
||||
day = addDays(day, step);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort orders so that the start and end placeholder order are at the right
|
||||
// position
|
||||
orders = sortBy(orders, (order) => {
|
||||
@ -832,6 +1028,12 @@ export class PortfolioCalculator {
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(i + 1, order.type, order.itemType);
|
||||
}
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
// Take the unit price of the order as the market price if there are no
|
||||
// orders of this symbol before the start date
|
||||
@ -859,9 +1061,21 @@ export class PortfolioCalculator {
|
||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||
}
|
||||
|
||||
const transactionInvestment = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
const transactionInvestment =
|
||||
order.type === 'BUY'
|
||||
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
: totalUnits.gt(0)
|
||||
? totalInvestment
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(this.getFactor(order.type))
|
||||
: new Big(0);
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
console.log('order.quantity', order.quantity.toNumber());
|
||||
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||
}
|
||||
|
||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||
|
||||
@ -910,71 +1124,50 @@ export class PortfolioCalculator {
|
||||
? new Big(0)
|
||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
if (
|
||||
i > indexOfStartOrder &&
|
||||
!lastValueOfInvestmentBeforeTransaction
|
||||
.plus(lastTransactionInvestment)
|
||||
.eq(0)
|
||||
) {
|
||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.mul(
|
||||
new Big(1).plus(grossHoldingPeriodReturn)
|
||||
);
|
||||
|
||||
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(fees.minus(feesAtStartDate))
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.mul(
|
||||
new Big(1).plus(netHoldingPeriodReturn)
|
||||
);
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
'totalInvestmentWithGrossPerformanceFromSell',
|
||||
totalInvestmentWithGrossPerformanceFromSell.toNumber()
|
||||
);
|
||||
console.log(
|
||||
'grossPerformanceFromSells',
|
||||
grossPerformanceFromSells.toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestment)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
|
||||
lastTransactionInvestment = transactionInvestment;
|
||||
|
||||
lastValueOfInvestmentBeforeTransaction =
|
||||
valueOfInvestmentBeforeTransaction;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
}
|
||||
|
||||
if (isChartMode && i > indexOfStartOrder) {
|
||||
netPerformanceValues[order.date] = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
investmentValues[order.date] = totalInvestment;
|
||||
maxInvestmentValues[order.date] = maxTotalInvestment;
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
console.log(
|
||||
'totalGrossPerformance',
|
||||
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
if (i === indexOfEndOrder) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.minus(1);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.minus(1);
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
);
|
||||
@ -1038,6 +1231,7 @@ export class PortfolioCalculator {
|
||||
Average price: ${averagePriceAtStartDate.toFixed(
|
||||
2
|
||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||
Total investment: ${totalInvestment.toFixed(2)}
|
||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||
Gross performance: ${totalGrossPerformance.toFixed(
|
||||
2
|
||||
@ -1052,7 +1246,10 @@ export class PortfolioCalculator {
|
||||
return {
|
||||
initialValue,
|
||||
grossPerformancePercentage,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformancePercentage,
|
||||
netPerformanceValues,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance,
|
||||
grossPerformance: totalGrossPerformance
|
||||
|
@ -7,20 +7,17 @@ import {
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Filter,
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
PortfolioReport
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
DateRange,
|
||||
GroupBy,
|
||||
@ -40,6 +37,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import Big from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
@ -52,6 +50,7 @@ export class PortfolioController {
|
||||
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@ -61,75 +60,6 @@ export class PortfolioController {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getChart(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioService.getChart(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
let chartData = historicalDataContainer.items;
|
||||
|
||||
let hasError = false;
|
||||
|
||||
chartData.forEach((chartDataItem) => {
|
||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||
hasError = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
let maxValue = 0;
|
||||
|
||||
chartData.forEach((portfolioItem) => {
|
||||
if (portfolioItem.value > maxValue) {
|
||||
maxValue = portfolioItem.value;
|
||||
}
|
||||
});
|
||||
|
||||
chartData = chartData.map((historicalDataItem) => {
|
||||
return {
|
||||
...historicalDataItem,
|
||||
marketPrice: Number((historicalDataItem.value / maxValue).toFixed(2))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasError,
|
||||
chart: chartData,
|
||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||
};
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Version('2')
|
||||
public async getChartV2(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioService.getChartV2(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
return {
|
||||
chart: historicalDataContainer.items,
|
||||
hasError: false,
|
||||
isAllTimeHigh: false,
|
||||
isAllTimeLow: false
|
||||
};
|
||||
}
|
||||
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@ -138,48 +68,43 @@ export class PortfolioController {
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') range?: DateRange,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
let hasDetails = true;
|
||||
let hasError = false;
|
||||
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const filters: Filter[] = [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
this.request.user.id,
|
||||
range,
|
||||
filters
|
||||
);
|
||||
const {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasErrors,
|
||||
holdings,
|
||||
summary,
|
||||
totalValueInBaseCurrency
|
||||
} = await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
let portfolioSummary = summary;
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
@ -206,7 +131,8 @@ export class PortfolioController {
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
portfolioPosition.netPerformance = null;
|
||||
portfolioPosition.quantity = null;
|
||||
portfolioPosition.value = portfolioPosition.value / totalValue;
|
||||
portfolioPosition.valueInPercentage =
|
||||
portfolioPosition.value / totalValue;
|
||||
}
|
||||
|
||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||
@ -215,9 +141,26 @@ export class PortfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||
if (
|
||||
hasDetails === false ||
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
portfolioSummary = nullifyValuesInObject(summary, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'emergencyFund',
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'items',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
]);
|
||||
}
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
@ -234,37 +177,87 @@ export class PortfolioController {
|
||||
|
||||
return {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasError,
|
||||
holdings
|
||||
holdings,
|
||||
totalValueInBaseCurrency,
|
||||
summary: portfolioSummary
|
||||
};
|
||||
}
|
||||
|
||||
@Get('dividends')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getDividends(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDividends> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let dividends = await this.portfolioService.getDividends({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxDividend = dividends.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
1
|
||||
);
|
||||
|
||||
dividends = dividends.map((item) => ({
|
||||
date: item.date,
|
||||
investment: item.investment / maxDividend
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
dividends = dividends.map((item) => {
|
||||
return nullifyValuesInObject(item, ['investment']);
|
||||
});
|
||||
}
|
||||
|
||||
return { dividends };
|
||||
}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('groupBy') groupBy?: GroupBy
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioInvestments> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
if (groupBy === 'month') {
|
||||
investments = await this.portfolioService.getInvestments(
|
||||
impersonationId,
|
||||
'month'
|
||||
);
|
||||
} else {
|
||||
investments = await this.portfolioService.getInvestments(impersonationId);
|
||||
}
|
||||
let investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -281,29 +274,81 @@ export class PortfolioController {
|
||||
}));
|
||||
}
|
||||
|
||||
return { firstOrderDate: parseDate(investments[0]?.date), investments };
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
investments = investments.map((item) => {
|
||||
return nullifyValuesInObject(item, ['investment']);
|
||||
});
|
||||
}
|
||||
|
||||
return { investments };
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPerformance(
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const performanceInformation = await this.portfolioService.getPerformance({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
performanceInformation.chart = performanceInformation.chart.map(
|
||||
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment: new Big(totalInvestment)
|
||||
.div(performanceInformation.performance.totalInvestment)
|
||||
.toNumber(),
|
||||
valueInPercentage: new Big(value)
|
||||
.div(performanceInformation.performance.currentValue)
|
||||
.toNumber()
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentGrossPerformance', 'currentValue']
|
||||
[
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'totalInvestment'
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
performanceInformation.chart = performanceInformation.chart.map(
|
||||
(item) => {
|
||||
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -312,34 +357,30 @@ export class PortfolioController {
|
||||
|
||||
@Get('positions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
result.positions = result.positions.map((position) => {
|
||||
return nullifyValuesInObject(position, [
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'quantity'
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return this.portfolioService.getPositions({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId
|
||||
});
|
||||
}
|
||||
|
||||
@Get('public/:accessId')
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPublic(
|
||||
@Param('accessId') accessId
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
@ -360,12 +401,12 @@ export class PortfolioController {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails(
|
||||
access.userId,
|
||||
access.userId,
|
||||
'max',
|
||||
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
||||
);
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
dateRange: 'max',
|
||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||
impersonationId: access.userId,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
hasDetails,
|
||||
@ -386,63 +427,26 @@ export class PortfolioController {
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
portfolioPublicDetails.holdings[symbol] = {
|
||||
allocationCurrent: portfolioPosition.value / totalValue,
|
||||
allocationInPercentage: portfolioPosition.value / totalValue,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
dataSource: portfolioPosition.dataSource,
|
||||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
name: portfolioPosition.name,
|
||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
symbol: portfolioPosition.symbol,
|
||||
url: portfolioPosition.url,
|
||||
value: portfolioPosition.value / totalValue
|
||||
valueInPercentage: portfolioPosition.value / totalValue
|
||||
};
|
||||
}
|
||||
|
||||
return portfolioPublicDetails;
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getSummary(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<PortfolioSummary> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let summary = await this.portfolioService.getSummary(impersonationId);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
summary = nullifyValuesInObject(summary, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'emergencyFund',
|
||||
'fees',
|
||||
'items',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
]);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Get('position/:dataSource/:symbol')
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ -451,27 +455,13 @@ export class PortfolioController {
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
let position = await this.portfolioService.getPosition(
|
||||
const position = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
if (position) {
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
position = nullifyValuesInObject(position, [
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'orders',
|
||||
'quantity',
|
||||
'value'
|
||||
]);
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
@ -486,16 +476,19 @@ export class PortfolioController {
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId: string
|
||||
): Promise<PortfolioReport> {
|
||||
const report = await this.portfolioService.getReport(impersonationId);
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
for (const rule in report.rules) {
|
||||
if (report.rules[rule]) {
|
||||
report.rules[rule] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await this.portfolioService.getReport(impersonationId);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -22,6 +23,7 @@ import { RulesService } from './rules.service';
|
||||
exports: [PortfolioService],
|
||||
imports: [
|
||||
AccessModule,
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
@ -62,6 +63,7 @@ export class SubscriptionController {
|
||||
|
||||
await this.subscriptionService.createSubscription({
|
||||
duration: coupon.duration,
|
||||
price: 0,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
@ -86,9 +88,12 @@ export class SubscriptionController {
|
||||
}
|
||||
|
||||
@Get('stripe/callback')
|
||||
public async stripeCallback(@Req() req, @Res() res) {
|
||||
public async stripeCallback(
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||
req.query.checkoutSessionId
|
||||
<string>request.query.checkoutSessionId
|
||||
);
|
||||
|
||||
Logger.log(
|
||||
@ -96,7 +101,7 @@ export class SubscriptionController {
|
||||
'SubscriptionController'
|
||||
);
|
||||
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
PROPERTY_STRIPE_CONFIG
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Subscription } from '@prisma/client';
|
||||
@ -70,13 +74,16 @@ export class SubscriptionService {
|
||||
|
||||
public async createSubscription({
|
||||
duration = '1 year',
|
||||
price,
|
||||
userId
|
||||
}: {
|
||||
duration?: StringValue;
|
||||
price: number;
|
||||
userId: string;
|
||||
}) {
|
||||
await this.prismaService.subscription.create({
|
||||
data: {
|
||||
price,
|
||||
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||
User: {
|
||||
connect: {
|
||||
@ -93,7 +100,21 @@ export class SubscriptionService {
|
||||
aCheckoutSessionId
|
||||
);
|
||||
|
||||
await this.createSubscription({ userId: session.client_reference_id });
|
||||
let subscriptions: SubscriptionInterface[] = [];
|
||||
|
||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
})) ?? { value: '{}' };
|
||||
|
||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||
|
||||
const coupon = subscriptions[0]?.coupon ?? 0;
|
||||
const price = subscriptions[0]?.price ?? 0;
|
||||
|
||||
await this.createSubscription({
|
||||
price: price - coupon,
|
||||
userId: session.client_reference_id
|
||||
});
|
||||
|
||||
await this.stripe.customers.update(session.customer as string, {
|
||||
description: session.client_reference_id
|
||||
|
@ -91,10 +91,19 @@ export class SymbolController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.symbolService.getForDate({
|
||||
const result = await this.symbolService.getForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (!result || isEmpty(result)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
@ -32,7 +31,7 @@ export class SymbolService {
|
||||
]);
|
||||
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
||||
|
||||
if (dataGatheringItem.dataSource && marketPrice) {
|
||||
if (dataGatheringItem.dataSource && marketPrice >= 0) {
|
||||
let historicalData: HistoricalDataItem[] = [];
|
||||
|
||||
if (includeHistoricalData > 0) {
|
||||
@ -65,13 +64,9 @@ export class SymbolService {
|
||||
|
||||
public async getForDate({
|
||||
dataSource,
|
||||
date,
|
||||
date = new Date(),
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<IDataProviderHistoricalResponse> {
|
||||
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[{ dataSource, symbol }],
|
||||
date,
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import type { DateRange, ViewMode } from '@ghostfolio/common/types';
|
||||
import type {
|
||||
ColorScheme,
|
||||
DateRange,
|
||||
ViewMode
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString
|
||||
} from 'class-validator';
|
||||
@ -14,9 +16,13 @@ export class UpdateUserSettingDto {
|
||||
@IsString()
|
||||
baseCurrency?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
benchmark?: UniqueAsset;
|
||||
benchmark?: string;
|
||||
|
||||
@IsIn(<ColorScheme[]>['DARK', 'LIGHT'])
|
||||
@IsOptional()
|
||||
colorScheme?: ColorScheme;
|
||||
|
||||
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
|
||||
@IsOptional()
|
||||
|
@ -1,6 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
@ -31,7 +29,6 @@ import { UserService } from './user.service';
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
@ -69,17 +66,14 @@ export class UserController {
|
||||
|
||||
@Post()
|
||||
public async signupUser(): Promise<UserItem> {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (isReadOnlyMode) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
if (!isUserSignupEnabled) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const hasAdmin = await this.userService.hasAdmin();
|
||||
@ -102,10 +96,10 @@ export class UserController {
|
||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||
if (
|
||||
size(data) === 1 &&
|
||||
data.dateRange &&
|
||||
(data.benchmark || data.dateRange) &&
|
||||
this.request.user.role === 'DEMO'
|
||||
) {
|
||||
// Allow date range change for demo user
|
||||
// Allow benchmark or date range change for demo user
|
||||
} else if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
|
@ -97,6 +97,7 @@ export class UserService {
|
||||
const {
|
||||
accessToken,
|
||||
Account,
|
||||
Analytics,
|
||||
authChallenge,
|
||||
createdAt,
|
||||
id,
|
||||
@ -107,7 +108,12 @@ export class UserService {
|
||||
thirdPartyId,
|
||||
updatedAt
|
||||
} = await this.prismaService.user.findUnique({
|
||||
include: { Account: true, Settings: true, Subscription: true },
|
||||
include: {
|
||||
Account: true,
|
||||
Analytics: true,
|
||||
Settings: true,
|
||||
Subscription: true
|
||||
},
|
||||
where: userWhereUniqueInput
|
||||
});
|
||||
|
||||
@ -121,7 +127,8 @@ export class UserService {
|
||||
role,
|
||||
Settings,
|
||||
thirdPartyId,
|
||||
updatedAt
|
||||
updatedAt,
|
||||
activityCount: Analytics?.activityCount
|
||||
};
|
||||
|
||||
if (user?.Settings) {
|
||||
@ -154,15 +161,22 @@ export class UserService {
|
||||
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
|
||||
}
|
||||
|
||||
let currentPermissions = getPermissions(user.role);
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
user.subscription =
|
||||
this.subscriptionService.getSubscription(Subscription);
|
||||
}
|
||||
|
||||
let currentPermissions = getPermissions(user.role);
|
||||
if (
|
||||
Analytics?.activityCount % 25 === 0 &&
|
||||
user.subscription?.type === 'Basic'
|
||||
) {
|
||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||
}
|
||||
|
||||
if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
import { cloneDeep, isObject } from 'lodash';
|
||||
import { cloneDeep, isArray, isObject } from 'lodash';
|
||||
|
||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||
for (const key in aObject) {
|
||||
@ -27,3 +27,48 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
|
||||
return nullifyValuesInObject(object, keys);
|
||||
});
|
||||
}
|
||||
|
||||
export function redactAttributes({
|
||||
object,
|
||||
options
|
||||
}: {
|
||||
object: any;
|
||||
options: { attribute: string; valueMap: { [key: string]: any } }[];
|
||||
}): any {
|
||||
if (!object || !options || !options.length) {
|
||||
return object;
|
||||
}
|
||||
|
||||
const redactedObject = cloneDeep(object);
|
||||
|
||||
for (const option of options) {
|
||||
if (redactedObject.hasOwnProperty(option.attribute)) {
|
||||
if (option.valueMap['*'] || option.valueMap['*'] === null) {
|
||||
redactedObject[option.attribute] = option.valueMap['*'];
|
||||
} else if (option.valueMap[redactedObject[option.attribute]]) {
|
||||
redactedObject[option.attribute] =
|
||||
option.valueMap[redactedObject[option.attribute]];
|
||||
}
|
||||
} else {
|
||||
// If the attribute is not present on the current object,
|
||||
// check if it exists on any nested objects
|
||||
for (const property in redactedObject) {
|
||||
if (isArray(redactedObject[property])) {
|
||||
redactedObject[property] = redactedObject[property].map(
|
||||
(currentObject) => {
|
||||
return redactAttributes({ options, object: currentObject });
|
||||
}
|
||||
);
|
||||
} else if (isObject(redactedObject[property])) {
|
||||
// Recursively call the function on the nested object
|
||||
redactedObject[property] = redactAttributes({
|
||||
options,
|
||||
object: redactedObject[property]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redactedObject;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
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 {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
@ -12,7 +13,7 @@ import { map } from 'rxjs/operators';
|
||||
export class RedactValuesInResponseInterceptor<T>
|
||||
implements NestInterceptor<T, any>
|
||||
{
|
||||
public constructor() {}
|
||||
public constructor(private userService: UserService) {}
|
||||
|
||||
public intercept(
|
||||
context: ExecutionContext,
|
||||
@ -23,24 +24,39 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
||||
|
||||
if (hasImpersonationId) {
|
||||
if (data.accounts) {
|
||||
for (const accountId of Object.keys(data.accounts)) {
|
||||
if (data.accounts[accountId]?.balance !== undefined) {
|
||||
data.accounts[accountId].balance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.activities) {
|
||||
data.activities = data.activities.map((activity: Activity) => {
|
||||
if (activity.Account?.balance !== undefined) {
|
||||
activity.Account.balance = null;
|
||||
}
|
||||
|
||||
return activity;
|
||||
});
|
||||
}
|
||||
if (
|
||||
hasImpersonationId ||
|
||||
this.userService.isRestrictedView(request.user)
|
||||
) {
|
||||
data = redactAttributes({
|
||||
object: data,
|
||||
options: [
|
||||
'balance',
|
||||
'balanceInBaseCurrency',
|
||||
'comment',
|
||||
'convertedBalance',
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'filteredValueInBaseCurrency',
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'quantity',
|
||||
'symbolMapping',
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
].map((attribute) => {
|
||||
return {
|
||||
attribute,
|
||||
valueMap: {
|
||||
'*': null
|
||||
}
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
CallHandler,
|
||||
@ -5,7 +6,7 @@ import {
|
||||
Injectable,
|
||||
NestInterceptor
|
||||
} from '@nestjs/common';
|
||||
import { isArray } from 'lodash';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@ -28,63 +29,23 @@ export class TransformDataSourceInResponseInterceptor<T>
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
if (data.activities) {
|
||||
data.activities.map((activity) => {
|
||||
activity.SymbolProfile.dataSource = encodeDataSource(
|
||||
activity.SymbolProfile.dataSource
|
||||
);
|
||||
return activity;
|
||||
});
|
||||
}
|
||||
|
||||
if (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);
|
||||
data = redactAttributes({
|
||||
options: [
|
||||
{
|
||||
attribute: 'dataSource',
|
||||
valueMap: Object.keys(DataSource).reduce(
|
||||
(valueMap, dataSource) => {
|
||||
valueMap[dataSource] = encodeDataSource(
|
||||
DataSource[dataSource]
|
||||
);
|
||||
return valueMap;
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.holdings) {
|
||||
for (const symbol of Object.keys(data.holdings)) {
|
||||
if (data.holdings[symbol].dataSource) {
|
||||
data.holdings[symbol].dataSource = encodeDataSource(
|
||||
data.holdings[symbol].dataSource
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.items) {
|
||||
data.items.map((item) => {
|
||||
item.dataSource = encodeDataSource(item.dataSource);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.positions) {
|
||||
data.positions.map((position) => {
|
||||
position.dataSource = encodeDataSource(position.dataSource);
|
||||
return position;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.SymbolProfile) {
|
||||
data.SymbolProfile.dataSource = encodeDataSource(
|
||||
data.SymbolProfile.dataSource
|
||||
);
|
||||
}
|
||||
],
|
||||
object: data
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
|
@ -19,13 +19,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
|
||||
if (accounts.length === 1) {
|
||||
return {
|
||||
evaluation: `All your investment is managed by a single account`,
|
||||
evaluation: `Your net worth is managed by a single account`,
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `Your investment is managed by ${accounts.length} accounts`,
|
||||
evaluation: `Your net worth is managed by ${accounts.length} accounts`,
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
private positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment: Base Currency'
|
||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
this.positions,
|
||||
'currency',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
private positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment: Base Currency'
|
||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
this.positions,
|
||||
'currency',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
public exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment'
|
||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
this.positions,
|
||||
'currency',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
private positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment'
|
||||
@ -17,7 +16,7 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
this.positions,
|
||||
'currency',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
|
9
apps/api/src/services/api/api.module.ts
Normal file
9
apps/api/src/services/api/api.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Module({
|
||||
exports: [ApiService],
|
||||
providers: [ApiService]
|
||||
})
|
||||
export class ApiModule {}
|
42
apps/api/src/services/api/api.service.ts
Normal file
42
apps/api/src/services/api/api.service.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ApiService {
|
||||
public constructor() {}
|
||||
|
||||
public buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
}: {
|
||||
filterByAccounts?: string;
|
||||
filterByAssetClasses?: string;
|
||||
filterByTags?: string;
|
||||
}): Filter[] {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
return [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
@ -12,11 +12,14 @@ export class ConfigurationService {
|
||||
this.environmentConfiguration = cleanEnv(process.env, {
|
||||
ACCESS_TOKEN_SALT: str(),
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
BASE_CURRENCY: str({ default: 'USD' }),
|
||||
BASE_CURRENCY: str({
|
||||
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
|
||||
default: 'USD'
|
||||
}),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCES: json({
|
||||
default: [DataSource.GHOSTFOLIO, DataSource.YAHOO]
|
||||
default: [DataSource.GHOSTFOLIO, DataSource.MANUAL, DataSource.YAHOO]
|
||||
}),
|
||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
@ -38,7 +41,7 @@ export class ConfigurationService {
|
||||
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
PORT: port({ default: 3333 }),
|
||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||
RAPID_API_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: host({ default: 'localhost' }),
|
||||
REDIS_PASSWORD: str({ default: '' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
|
@ -11,14 +11,16 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
|
||||
|
||||
@Injectable()
|
||||
export class CronService {
|
||||
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
|
||||
|
||||
public constructor(
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly twitterBotService: TwitterBotService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
public async runEveryHour() {
|
||||
@Cron(CronExpression.EVERY_4_HOURS)
|
||||
public async runEveryFourHours() {
|
||||
await this.dataGatheringService.gather7Days();
|
||||
}
|
||||
|
||||
@ -28,12 +30,12 @@ export class CronService {
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_5PM)
|
||||
public async runEveryDayAtFivePM() {
|
||||
public async runEveryDayAtFivePm() {
|
||||
this.twitterBotService.tweetFearAndGreedIndex();
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_WEEKEND)
|
||||
public async runEveryWeekend() {
|
||||
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
|
||||
public async runEverySundayAtTwelvePm() {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
|
@ -280,7 +280,7 @@ export class DataGatheringService {
|
||||
return (
|
||||
dataSource !== DataSource.GHOSTFOLIO &&
|
||||
dataSource !== DataSource.MANUAL &&
|
||||
dataSource !== DataSource.RAKUTEN
|
||||
dataSource !== DataSource.RAPID_API
|
||||
);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
|
@ -37,6 +37,20 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -5,7 +5,7 @@ import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
@ -27,7 +27,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RakutenRapidApiService,
|
||||
RapidApiService,
|
||||
YahooFinanceService,
|
||||
{
|
||||
inject: [
|
||||
@ -36,7 +36,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RakutenRapidApiService,
|
||||
RapidApiService,
|
||||
YahooFinanceService
|
||||
],
|
||||
provide: 'DataProviderInterfaces',
|
||||
@ -46,7 +46,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rakutenRapidApiService,
|
||||
rapidApiService,
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
@ -54,11 +54,15 @@ import { DataProviderService } from './data-provider.service';
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rakutenRapidApiService,
|
||||
rapidApiService,
|
||||
yahooFinanceService
|
||||
]
|
||||
}
|
||||
],
|
||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
||||
exports: [
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class DataProviderModule {}
|
||||
|
@ -23,6 +23,27 @@ export class DataProviderService {
|
||||
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(
|
||||
aItems: IDataGatheringItem[],
|
||||
aGranularity: Granularity = 'month',
|
||||
@ -114,9 +135,13 @@ export class DataProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
const allData = await Promise.all(promises);
|
||||
for (const { data, symbol } of allData) {
|
||||
result[symbol] = data;
|
||||
try {
|
||||
const allData = await Promise.all(promises);
|
||||
for (const { data, symbol } of allData) {
|
||||
result[symbol] = data;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'DataProviderService');
|
||||
}
|
||||
|
||||
return result;
|
||||
@ -209,7 +234,9 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
|
||||
`Fetched ${symbolsChunk.length} quote${
|
||||
symbolsChunk.length > 1 ? 's' : ''
|
||||
} from ${dataSource} in ${(
|
||||
(performance.now() - startTimeDataSource) /
|
||||
1000
|
||||
).toFixed(3)} seconds`
|
||||
@ -223,7 +250,7 @@ export class DataProviderService {
|
||||
|
||||
Logger.debug('------------------------------------------------');
|
||||
Logger.debug(
|
||||
`Fetched ${items.length} quotes in ${(
|
||||
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
|
||||
(performance.now() - startTimeTotal) /
|
||||
1000
|
||||
).toFixed(3)} seconds`
|
||||
|
@ -37,6 +37,20 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -37,6 +37,20 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -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(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -11,6 +11,18 @@ export interface DataProviderInterface {
|
||||
|
||||
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
||||
|
||||
getDividends({
|
||||
from,
|
||||
granularity,
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}): Promise<{ [date: string]: IDataProviderHistoricalResponse }>;
|
||||
|
||||
getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity,
|
||||
|
@ -4,13 +4,18 @@ 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 { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class ManualService implements DataProviderInterface {
|
||||
public constructor() {}
|
||||
public constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return false;
|
||||
@ -24,6 +29,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(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
@ -42,10 +61,77 @@ export class ManualService implements DataProviderInterface {
|
||||
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 ?? 0,
|
||||
marketState: 'delayed'
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'ManualService');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
export interface IRakutenRapidApiResponse {}
|
@ -0,0 +1 @@
|
||||
export interface IRapidApiResponse {}
|
@ -5,24 +5,22 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
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 { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class RakutenRapidApiService implements DataProviderInterface {
|
||||
export class RapidApiService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
|
||||
return !!this.configurationService.get('RAPID_API_API_KEY');
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
@ -33,6 +31,20 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
@ -47,41 +59,6 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||
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 {
|
||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||
[format(getYesterday(), DATE_FORMAT)]: {
|
||||
@ -103,7 +80,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
return DataSource.RAKUTEN;
|
||||
return DataSource.RAPID_API;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
@ -129,7 +106,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'RakutenRapidApiService');
|
||||
Logger.error(error, 'RapidApiService');
|
||||
}
|
||||
|
||||
return {};
|
||||
@ -155,16 +132,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
{
|
||||
useQueryString: true,
|
||||
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
|
||||
'x-rapidapi-key': this.configurationService.get(
|
||||
'RAKUTEN_RAPID_API_KEY'
|
||||
)
|
||||
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
|
||||
}
|
||||
);
|
||||
|
||||
const { fgi } = await get();
|
||||
return fgi;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'RakutenRapidApiService');
|
||||
Logger.error(error, 'RapidApiService');
|
||||
|
||||
return undefined;
|
||||
}
|
@ -6,6 +6,7 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -57,8 +58,15 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
* DOGEUSD -> DOGE-USD
|
||||
*/
|
||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||
if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) {
|
||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||
if (
|
||||
aSymbol.includes(this.baseCurrency) &&
|
||||
aSymbol.length > this.baseCurrency.length
|
||||
) {
|
||||
if (
|
||||
isCurrency(
|
||||
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
|
||||
)
|
||||
) {
|
||||
return `${aSymbol}=X`;
|
||||
} else if (
|
||||
this.cryptocurrencyService.isCryptocurrency(
|
||||
@ -90,7 +98,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
try {
|
||||
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
||||
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
||||
modules: ['price', 'summaryProfile']
|
||||
modules: ['price', 'summaryProfile', 'topHoldings']
|
||||
});
|
||||
|
||||
const { assetClass, assetSubClass } = this.parseAssetClass(
|
||||
@ -109,7 +117,16 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
});
|
||||
response.symbol = aSymbol;
|
||||
|
||||
if (
|
||||
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
||||
response.sectors = [];
|
||||
|
||||
for (const sectorWeighting of assetProfile.topHoldings
|
||||
?.sectorWeightings ?? []) {
|
||||
for (const [sector, weight] of Object.entries(sectorWeighting)) {
|
||||
response.sectors.push({ weight, name: this.parseSector(sector) });
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
assetSubClass === AssetSubClass.STOCK &&
|
||||
assetProfile.summaryProfile?.country
|
||||
) {
|
||||
@ -137,16 +154,65 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
response.url = url;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
|
||||
error.name
|
||||
}] ${error.message}`
|
||||
);
|
||||
Logger.error(error, 'YahooFinanceService');
|
||||
}
|
||||
|
||||
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(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
@ -159,11 +225,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
to = addDays(to, 1);
|
||||
}
|
||||
|
||||
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
|
||||
|
||||
try {
|
||||
const historicalResult = await yahooFinance.historical(
|
||||
yahooFinanceSymbol,
|
||||
this.convertToYahooFinanceSymbol(aSymbol),
|
||||
{
|
||||
interval: '1d',
|
||||
period1: format(from, DATE_FORMAT),
|
||||
@ -175,24 +239,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
// Convert symbol back
|
||||
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
||||
|
||||
response[symbol] = {};
|
||||
response[aSymbol] = {};
|
||||
|
||||
for (const historicalItem of historicalResult) {
|
||||
let marketPrice = historicalItem.close;
|
||||
|
||||
if (symbol === 'USDGBp') {
|
||||
// Convert GPB to GBp (pence)
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
} else if (symbol === 'USDILA') {
|
||||
// Convert ILS to ILA
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
}
|
||||
|
||||
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||
marketPrice,
|
||||
response[aSymbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||
marketPrice: this.getConvertedValue({
|
||||
symbol: aSymbol,
|
||||
value: historicalItem.close
|
||||
}),
|
||||
performance: historicalItem.open - historicalItem.close
|
||||
};
|
||||
}
|
||||
@ -246,9 +300,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
marketPrice: quote.regularMarketPrice || 0
|
||||
};
|
||||
|
||||
if (symbol === 'USDGBP' && yahooFinanceSymbols.includes('USDGBp=X')) {
|
||||
if (
|
||||
symbol === `${this.baseCurrency}GBP` &&
|
||||
yahooFinanceSymbols.includes(`${this.baseCurrency}GBp=X`)
|
||||
) {
|
||||
// Convert GPB to GBp (pence)
|
||||
response['USDGBp'] = {
|
||||
response[`${this.baseCurrency}GBp`] = {
|
||||
...response[symbol],
|
||||
currency: 'GBp',
|
||||
marketPrice: new Big(response[symbol].marketPrice)
|
||||
@ -256,23 +313,35 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
.toNumber()
|
||||
};
|
||||
} else if (
|
||||
symbol === 'USDILS' &&
|
||||
yahooFinanceSymbols.includes('USDILA=X')
|
||||
symbol === `${this.baseCurrency}ILS` &&
|
||||
yahooFinanceSymbols.includes(`${this.baseCurrency}ILA=X`)
|
||||
) {
|
||||
// Convert ILS to ILA
|
||||
response['USDILA'] = {
|
||||
response[`${this.baseCurrency}ILA`] = {
|
||||
...response[symbol],
|
||||
currency: 'ILA',
|
||||
marketPrice: new Big(response[symbol].marketPrice)
|
||||
.mul(100)
|
||||
.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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (yahooFinanceSymbols.includes('USDUSX=X')) {
|
||||
if (yahooFinanceSymbols.includes(`${this.baseCurrency}USX=X`)) {
|
||||
// Convert USD to USX (cent)
|
||||
response['USDUSX'] = {
|
||||
response[`${this.baseCurrency}USX`] = {
|
||||
currency: 'USX',
|
||||
dataSource: this.getName(),
|
||||
marketPrice: new Big(1).mul(100).toNumber(),
|
||||
@ -392,6 +461,27 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
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): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
@ -434,4 +524,46 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
return { assetClass, assetSubClass };
|
||||
}
|
||||
|
||||
private parseSector(aString: string): string {
|
||||
let sector = UNKNOWN_KEY;
|
||||
|
||||
switch (aString) {
|
||||
case 'basic_materials':
|
||||
sector = 'Basic Materials';
|
||||
break;
|
||||
case 'communication_services':
|
||||
sector = 'Communication Services';
|
||||
break;
|
||||
case 'consumer_cyclical':
|
||||
sector = 'Consumer Cyclical';
|
||||
break;
|
||||
case 'consumer_defensive':
|
||||
sector = 'Consumer Staples';
|
||||
break;
|
||||
case 'energy':
|
||||
sector = 'Energy';
|
||||
break;
|
||||
case 'financial_services':
|
||||
sector = 'Financial Services';
|
||||
break;
|
||||
case 'healthcare':
|
||||
sector = 'Healthcare';
|
||||
break;
|
||||
case 'industrials':
|
||||
sector = 'Industrials';
|
||||
break;
|
||||
case 'realestate':
|
||||
sector = 'Real Estate';
|
||||
break;
|
||||
case 'technology':
|
||||
sector = 'Technology';
|
||||
break;
|
||||
case 'utilities':
|
||||
sector = 'Utilities';
|
||||
break;
|
||||
}
|
||||
|
||||
return sector;
|
||||
}
|
||||
}
|
||||
|
@ -4,16 +4,18 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MarketDataModule } from './market-data.module';
|
||||
import { PrismaModule } from './prisma.module';
|
||||
|
||||
@Module({
|
||||
exports: [ExchangeRateDataService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule
|
||||
],
|
||||
providers: [ExchangeRateDataService],
|
||||
exports: [ExchangeRateDataService]
|
||||
providers: [ExchangeRateDataService]
|
||||
})
|
||||
export class ExchangeRateDataModule {}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { format, isToday } from 'date-fns';
|
||||
import { isNumber, uniq } from 'lodash';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { PropertyService } from './property/property.service';
|
||||
|
||||
@ -20,6 +21,7 @@ export class ExchangeRateDataService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {}
|
||||
@ -99,10 +101,12 @@ export class ExchangeRateDataService {
|
||||
this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice;
|
||||
|
||||
if (!this.exchangeRates[symbol]) {
|
||||
// Not found, calculate indirectly via USD
|
||||
// Not found, calculate indirectly via base currency
|
||||
this.exchangeRates[symbol] =
|
||||
resultExtended[`${currency1}${'USD'}`]?.[date]?.marketPrice *
|
||||
resultExtended[`${'USD'}${currency2}`]?.[date]?.marketPrice;
|
||||
resultExtended[`${currency1}${this.baseCurrency}`]?.[date]
|
||||
?.marketPrice *
|
||||
resultExtended[`${this.baseCurrency}${currency2}`]?.[date]
|
||||
?.marketPrice;
|
||||
|
||||
// Calculate the opposite direction
|
||||
this.exchangeRates[`${currency2}${currency1}`] =
|
||||
@ -126,9 +130,11 @@ export class ExchangeRateDataService {
|
||||
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
|
||||
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
|
||||
} else {
|
||||
// Calculate indirectly via USD
|
||||
const factor1 = this.exchangeRates[`${aFromCurrency}${'USD'}`];
|
||||
const factor2 = this.exchangeRates[`${'USD'}${aToCurrency}`];
|
||||
// Calculate indirectly via base currency
|
||||
const factor1 =
|
||||
this.exchangeRates[`${aFromCurrency}${this.baseCurrency}`];
|
||||
const factor2 =
|
||||
this.exchangeRates[`${this.baseCurrency}${aToCurrency}`];
|
||||
|
||||
factor = factor1 * factor2;
|
||||
|
||||
@ -148,6 +154,53 @@ export class ExchangeRateDataService {
|
||||
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 = 1;
|
||||
|
||||
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 {
|
||||
// TODO: Get from data provider service or calculate indirectly via base currency
|
||||
// and market data
|
||||
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNumber(factor) && !isNaN(factor)) {
|
||||
return factor * aValue;
|
||||
}
|
||||
|
||||
// Fallback with error, if currencies are not available
|
||||
Logger.error(
|
||||
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`,
|
||||
'ExchangeRateDataService'
|
||||
);
|
||||
return aValue;
|
||||
}
|
||||
|
||||
private async prepareCurrencies(): Promise<string[]> {
|
||||
let currencies: string[] = [];
|
||||
|
||||
|
@ -26,7 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
PORT: number;
|
||||
RAKUTEN_RAPID_API_KEY: string;
|
||||
RAPID_API_API_KEY: string;
|
||||
REDIS_HOST: string;
|
||||
REDIS_PASSWORD: string;
|
||||
REDIS_PORT: number;
|
||||
|
@ -6,6 +6,8 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
|
||||
@Injectable()
|
||||
export class MarketDataService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
@ -20,14 +22,13 @@ export class MarketDataService {
|
||||
}
|
||||
|
||||
public async get({
|
||||
date,
|
||||
dataSource,
|
||||
date = new Date(),
|
||||
symbol
|
||||
}: {
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<MarketData> {
|
||||
}: IDataGatheringItem): Promise<MarketData> {
|
||||
return await this.prismaService.marketData.findFirst({
|
||||
where: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: resetHours(date)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class PropertyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
value: string;
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
@ -39,6 +42,13 @@ export class PropertyService {
|
||||
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 }) {
|
||||
return this.prismaService.property.upsert({
|
||||
create: { key, value },
|
||||
|
@ -8,25 +8,14 @@ import {
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
DataSource,
|
||||
Prisma,
|
||||
SymbolProfile,
|
||||
SymbolProfileOverrides
|
||||
} from '@prisma/client';
|
||||
import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
|
||||
@Injectable()
|
||||
export class SymbolProfileService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async delete({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
public async delete({ dataSource, symbol }: UniqueAsset) {
|
||||
return this.prismaService.symbolProfile.delete({
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
});
|
||||
@ -43,7 +32,19 @@ export class SymbolProfileService {
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
include: { SymbolProfileOverrides: true },
|
||||
include: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
Order: {
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
SymbolProfileOverrides: true
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
@ -64,6 +65,28 @@ export class SymbolProfileService {
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
}
|
||||
|
||||
public async getSymbolProfilesByIds(
|
||||
symbolProfileIds: string[]
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
SymbolProfileOverrides: true
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
in: symbolProfileIds.map((symbolProfileId) => {
|
||||
return symbolProfileId;
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@ -72,7 +95,12 @@ export class SymbolProfileService {
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
include: { SymbolProfileOverrides: true },
|
||||
include: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
SymbolProfileOverrides: true
|
||||
},
|
||||
where: {
|
||||
symbol: {
|
||||
in: symbols
|
||||
@ -82,22 +110,46 @@ export class SymbolProfileService {
|
||||
.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(
|
||||
symbolProfiles: (SymbolProfile & {
|
||||
_count: { Order: number };
|
||||
Order?: {
|
||||
date: Date;
|
||||
}[];
|
||||
SymbolProfileOverrides: SymbolProfileOverrides;
|
||||
})[]
|
||||
): EnhancedSymbolProfile[] {
|
||||
return symbolProfiles.map((symbolProfile) => {
|
||||
const item = {
|
||||
...symbolProfile,
|
||||
activitiesCount: 0,
|
||||
countries: this.getCountries(
|
||||
symbolProfile?.countries as unknown as Prisma.JsonArray
|
||||
),
|
||||
dateOfFirstActivity: <Date>undefined,
|
||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||
sectors: this.getSectors(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) {
|
||||
item.assetClass =
|
||||
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
||||
|
@ -53,13 +53,15 @@ export class TwitterBotService {
|
||||
symbolItem.marketPrice
|
||||
);
|
||||
|
||||
let status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)`;
|
||||
let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${
|
||||
symbolItem.marketPrice
|
||||
}/100)`;
|
||||
|
||||
const benchmarkListing = await this.getBenchmarkListing(3);
|
||||
|
||||
if (benchmarkListing?.length > 1) {
|
||||
status += '\n\n';
|
||||
status += '±% from ATH\n';
|
||||
status += '± from ATH in %\n';
|
||||
status += benchmarkListing;
|
||||
}
|
||||
|
||||
|
23
apps/client-e2e/project.json
Normal file
23
apps/client-e2e/project.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "client-e2e",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/client-e2e/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"e2e": {
|
||||
"executor": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/client-e2e/cypress.json",
|
||||
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
|
||||
"devServerTarget": "client:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "client:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
}
|
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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
233
apps/client/project.json
Normal file
233
apps/client/project.json
Normal file
@ -0,0 +1,233 @@
|
||||
{
|
||||
"name": "client",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"generators": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"sourceRoot": "apps/client/src",
|
||||
"prefix": "gf",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/client",
|
||||
"index": "apps/client/src/index.html",
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "assetlinks.json",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../.well-known"
|
||||
},
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "site.webmanifest",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/ionicons/dist/ionicons",
|
||||
"output": "./../ionicons"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.js",
|
||||
"input": "node_modules/ionicons/dist/",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../assets/"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true,
|
||||
"serviceWorker": true,
|
||||
"ngswConfigPath": "apps/client/ngsw-config.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
},
|
||||
"development-en": {
|
||||
"baseHref": "/en/",
|
||||
"localize": ["en"]
|
||||
},
|
||||
"development-es": {
|
||||
"baseHref": "/es/",
|
||||
"localize": ["es"]
|
||||
},
|
||||
"development-fr": {
|
||||
"baseHref": "/fr/",
|
||||
"localize": ["fr"]
|
||||
},
|
||||
"development-it": {
|
||||
"baseHref": "/it/",
|
||||
"localize": ["it"]
|
||||
},
|
||||
"development-nl": {
|
||||
"baseHref": "/nl/",
|
||||
"localize": ["nl"]
|
||||
},
|
||||
"development-pt": {
|
||||
"baseHref": "/pt/",
|
||||
"localize": ["pt"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/client/src/environments/environment.ts",
|
||||
"with": "apps/client/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "10kb"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"proxyConfig": "apps/client/proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"browserTarget": "client:build:development-de"
|
||||
},
|
||||
"development-en": {
|
||||
"browserTarget": "client:build:development-en"
|
||||
},
|
||||
"development-es": {
|
||||
"browserTarget": "client:build:development-es"
|
||||
},
|
||||
"development-fr": {
|
||||
"browserTarget": "client:build:development-fr"
|
||||
},
|
||||
"development-it": {
|
||||
"browserTarget": "client:build:development-it"
|
||||
},
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
},
|
||||
"development-pt": {
|
||||
"browserTarget": "client:build:development-pt"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": [
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.fr.xlf",
|
||||
"messages.it.xlf",
|
||||
"messages.nl.xlf",
|
||||
"messages.pt.xlf"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/client/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/client"]
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
},
|
||||
"es": {
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"fr": {
|
||||
"baseHref": "/fr/",
|
||||
"translation": "apps/client/src/locales/messages.fr.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
},
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
},
|
||||
"pt": {
|
||||
"baseHref": "/pt/",
|
||||
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
},
|
||||
"tags": []
|
||||
}
|
@ -2,7 +2,7 @@ import { Platform } from '@angular/cdk/platform';
|
||||
import { Inject, forwardRef } from '@angular/core';
|
||||
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
|
||||
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 {
|
||||
public constructor(
|
||||
@ -31,6 +31,16 @@ export class CustomDateAdapter extends NativeDateAdapter {
|
||||
* Parses a date from a provided value
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -95,6 +95,34 @@ const routes: Routes = [
|
||||
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
|
||||
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/10/hacktoberfest-2022',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
|
||||
).then((m) => m.Hacktoberfest2022PageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/11/black-friday-2022',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
|
||||
).then((m) => m.BlackFriday2022PageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
|
||||
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
|
||||
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
|
||||
},
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
@ -138,48 +166,6 @@ const routes: Routes = [
|
||||
(m) => m.PortfolioPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/activities',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/transactions/transactions-page.module').then(
|
||||
(m) => m.TransactionsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
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',
|
||||
loadChildren: () =>
|
||||
|
@ -3,6 +3,7 @@
|
||||
class="position-fixed w-100"
|
||||
[currentRoute]="currentRoute"
|
||||
[info]="info"
|
||||
[pageTitle]="pageTitle"
|
||||
[user]="user"
|
||||
(signOut)="onSignOut()"
|
||||
></gf-header>
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user