Compare commits
170 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,37 +1,45 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: "[BUG]"
|
title: '[BUG]'
|
||||||
labels: ''
|
labels: ''
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
**Describe the bug**
|
**Bug Description**
|
||||||
|
|
||||||
<!-- A clear and concise description of what the bug is. -->
|
<!-- A clear and concise description of what the bug is. -->
|
||||||
|
|
||||||
**To Reproduce**
|
**To Reproduce**
|
||||||
Steps to reproduce the behavior:
|
|
||||||
|
<!-- Steps to reproduce the behavior -->
|
||||||
|
|
||||||
1.
|
1.
|
||||||
2.
|
2.
|
||||||
3.
|
3.
|
||||||
4.
|
|
||||||
|
|
||||||
**Expected behavior**
|
**Expected behavior**
|
||||||
|
|
||||||
<!-- A clear and concise description of what you expected to happen. -->
|
<!-- A clear and concise description of what you expected to happen. -->
|
||||||
|
|
||||||
**Screenshots**
|
**Screenshots**
|
||||||
|
|
||||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||||
|
|
||||||
**Logs**
|
**Logs**
|
||||||
|
|
||||||
<!-- If applicable, add logs to help explain your problem. -->
|
<!-- If applicable, add logs to help explain your problem. -->
|
||||||
|
|
||||||
**Environment (please complete the following information):**
|
**Environment**
|
||||||
- Ghostfolio Version [e.g. 1.194.0]
|
|
||||||
- Browser [e.g. chrome]
|
<!-- Please complete the following information -->
|
||||||
- OS
|
|
||||||
|
- Ghostfolio Version X.Y.Z
|
||||||
|
- Browser
|
||||||
|
- OS
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
|
|
||||||
<!-- Add any other context about the problem here. -->
|
<!-- Add any other context about the problem here. -->
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -5,6 +5,7 @@
|
|||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
/.yarn
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
@ -37,4 +38,4 @@ yarn-error.log
|
|||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.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",
|
"endOfLine": "auto",
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
|
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@ -5,11 +5,11 @@
|
|||||||
"name": "Debug Jest File",
|
"name": "Debug Jest File",
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
|
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||||
"args": [
|
"args": [
|
||||||
"test",
|
"test",
|
||||||
"--codeCoverage=false",
|
"--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}",
|
"cwd": "${workspaceFolder}",
|
||||||
"console": "internalConsole"
|
"console": "internalConsole"
|
||||||
|
300
CHANGELOG.md
300
CHANGELOG.md
@ -5,6 +5,300 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.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
|
## 1.197.0 - 24.09.2022
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -305,7 +599,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Support a note for activities
|
- Supported a note for activities
|
||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
@ -876,8 +1170,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Display the value in base currency in the accounts table on mobile
|
- Displayed 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 activities table on mobile
|
||||||
- Renamed `orders` to `activities` in import and export functionality
|
- Renamed `orders` to `activities` in import and export functionality
|
||||||
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
|
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
|
||||||
- Improved the pricing page
|
- Improved the pricing page
|
||||||
|
@ -25,7 +25,6 @@ RUN yarn install
|
|||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
RUN node decorate-angular-cli.js
|
RUN node decorate-angular-cli.js
|
||||||
|
|
||||||
COPY ./angular.json angular.json
|
|
||||||
COPY ./nx.json nx.json
|
COPY ./nx.json nx.json
|
||||||
COPY ./replace.build.js replace.build.js
|
COPY ./replace.build.js replace.build.js
|
||||||
COPY ./jest.preset.js jest.preset.js
|
COPY ./jest.preset.js jest.preset.js
|
||||||
|
76
README.md
76
README.md
@ -15,14 +15,16 @@
|
|||||||
<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>
|
<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>
|
||||||
<p>
|
<p>
|
||||||
|
<a href="https://www.buymeacoffee.com/ghostfolio">
|
||||||
|
<img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee"/></a>
|
||||||
<a href="#contributing">
|
<a href="#contributing">
|
||||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
<img src="https://img.shields.io/badge/Contributions-Welcome-orange.svg"/></a>
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
<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>
|
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||||
|
|
||||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
||||||
@ -53,13 +55,13 @@ Ghostfolio is for you if you are...
|
|||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ Multi account management
|
||||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||||
- ✅ Various charts
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Import and export transactions
|
- ✅ Import and export transactions
|
||||||
- ✅ Dark Mode
|
- ✅ Dark Mode
|
||||||
- ✅ Zen Mode
|
- ✅ Zen Mode
|
||||||
- ✅ Mobile-first design
|
- ✅ Progressive Web App (PWA) with a mobile-first design
|
||||||
|
|
||||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||||
@ -81,22 +83,32 @@ 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`.
|
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">
|
||||||
|
<a href="https://www.buymeacoffee.com/ghostfolio">
|
||||||
|
<img
|
||||||
|
alt="Buy me a coffee button"
|
||||||
|
src="./apps/client/src/assets/images/button-buy-me-a-coffee.png"
|
||||||
|
width="150"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Default Value | Description |
|
| Name | Default Value | Description |
|
||||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
|
| `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` |
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -128,7 +140,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:
|
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. 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_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
@ -158,7 +170,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
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. 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_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
### Start Server
|
### Start Server
|
||||||
@ -190,20 +202,22 @@ Run `yarn test`
|
|||||||
|
|
||||||
## Public API
|
## 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
|
### Import Activities
|
||||||
|
|
||||||
#### Request
|
#### Request
|
||||||
|
|
||||||
`POST http://localhost:3333/api/v1/import`
|
`POST http://localhost:3333/api/v1/import`
|
||||||
|
|
||||||
#### Authorization: Bearer Token
|
|
||||||
|
|
||||||
Set the header as follows:
|
|
||||||
|
|
||||||
```
|
|
||||||
"Authorization": "Bearer eyJh..."
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Body
|
#### Body
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -215,7 +229,7 @@ Set the header as follows:
|
|||||||
"date": "2021-09-15T00:00:00.000Z",
|
"date": "2021-09-15T00:00:00.000Z",
|
||||||
"fee": 19,
|
"fee": 19,
|
||||||
"quantity": 5,
|
"quantity": 5,
|
||||||
"symbol": "MSFT"
|
"symbol": "MSFT",
|
||||||
"type": "BUY",
|
"type": "BUY",
|
||||||
"unitPrice": 298.58
|
"unitPrice": 298.58
|
||||||
}
|
}
|
||||||
@ -254,6 +268,10 @@ Set the header as follows:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Community Projects
|
||||||
|
|
||||||
|
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
|
368
angular.json
368
angular.json
@ -1,368 +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"]
|
|
||||||
},
|
|
||||||
"development-it": {
|
|
||||||
"baseHref": "/it/",
|
|
||||||
"localize": ["it"]
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"development-it": {
|
|
||||||
"browserTarget": "client:build:development-it"
|
|
||||||
},
|
|
||||||
"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", "messages.it.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"
|
|
||||||
},
|
|
||||||
"it": {
|
|
||||||
"baseHref": "/it/",
|
|
||||||
"translation": "apps/client/src/locales/messages.it.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": []
|
||||||
|
}
|
@ -95,9 +95,10 @@ export class AccountController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations =
|
let accountsWithAggregations =
|
||||||
await this.portfolioService.getAccountsWithAggregations(
|
await this.portfolioService.getAccountsWithAggregations({
|
||||||
impersonationUserId || this.request.user.id
|
userId: impersonationUserId || this.request.user.id,
|
||||||
);
|
withExcludedAccounts: true
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
@ -137,10 +138,11 @@ export class AccountController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations =
|
let accountsWithAggregations =
|
||||||
await this.portfolioService.getAccountsWithAggregations(
|
await this.portfolioService.getAccountsWithAggregations({
|
||||||
impersonationUserId || this.request.user.id,
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
[{ id, type: 'ACCOUNT' }]
|
userId: impersonationUserId || this.request.user.id,
|
||||||
);
|
withExcludedAccounts: true
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
|
@ -107,15 +107,23 @@ export class AccountService {
|
|||||||
public async getCashDetails({
|
public async getCashDetails({
|
||||||
currency,
|
currency,
|
||||||
filters = [],
|
filters = [],
|
||||||
userId
|
userId,
|
||||||
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
currency: string;
|
currency: string;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
userId: string;
|
userId: string;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<CashDetails> {
|
}): Promise<CashDetails> {
|
||||||
let totalCashBalanceInBaseCurrency = new Big(0);
|
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||||
|
|
||||||
const where: Prisma.AccountWhereInput = { userId };
|
const where: Prisma.AccountWhereInput = {
|
||||||
|
userId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (withExcludedAccounts === false) {
|
||||||
|
where.isExcluded = false;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ACCOUNT: filtersByAccount,
|
ACCOUNT: filtersByAccount,
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateIf
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -11,6 +17,10 @@ export class CreateAccountDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isExcluded?: boolean;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateIf
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -14,6 +20,10 @@ export class UpdateAccountDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isExcluded?: boolean;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
|
EnhancedSymbolProfile,
|
||||||
Filter
|
Filter
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -21,6 +22,7 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
@ -33,6 +35,7 @@ import { isDate } from 'date-fns';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@ -332,6 +335,32 @@ export class AdminController {
|
|||||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async patchAssetProfileData(
|
||||||
|
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<EnhancedSymbolProfile> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.patchAssetProfileData({
|
||||||
|
...assetProfileData,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
|
@ -116,6 +116,7 @@ export class AdminService {
|
|||||||
},
|
},
|
||||||
assetClass: true,
|
assetClass: true,
|
||||||
assetSubClass: true,
|
assetSubClass: true,
|
||||||
|
comment: true,
|
||||||
countries: true,
|
countries: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
Order: {
|
Order: {
|
||||||
@ -147,9 +148,10 @@ export class AdminService {
|
|||||||
countriesCount,
|
countriesCount,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
sectorsCount,
|
sectorsCount,
|
||||||
activityCount: symbolProfile._count.Order,
|
activitiesCount: symbolProfile._count.Order,
|
||||||
assetClass: symbolProfile.assetClass,
|
assetClass: symbolProfile.assetClass,
|
||||||
assetSubClass: symbolProfile.assetSubClass,
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
|
comment: symbolProfile.comment,
|
||||||
dataSource: symbolProfile.dataSource,
|
dataSource: symbolProfile.dataSource,
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
date: symbolProfile.Order?.[0]?.date,
|
||||||
symbol: symbolProfile.symbol
|
symbol: symbolProfile.symbol
|
||||||
@ -165,8 +167,14 @@ export class AdminService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||||
return {
|
const [[assetProfile], marketData] = await Promise.all([
|
||||||
marketData: await this.marketDataService.marketDataItems({
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
this.marketDataService.marketDataItems({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'asc'
|
date: 'asc'
|
||||||
},
|
},
|
||||||
@ -175,16 +183,44 @@ export class AdminService {
|
|||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetProfile,
|
||||||
|
marketData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async patchAssetProfileData({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
});
|
||||||
|
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
return symbolProfile;
|
||||||
|
}
|
||||||
|
|
||||||
public async putSetting(key: string, value: string) {
|
public async putSetting(key: string, value: string) {
|
||||||
let response: Property;
|
let response: Property;
|
||||||
|
|
||||||
if (value === '') {
|
if (value) {
|
||||||
response = await this.propertyService.delete({ key });
|
|
||||||
} else {
|
|
||||||
response = await this.propertyService.put({ key, value });
|
response = await this.propertyService.put({ key, value });
|
||||||
|
} else {
|
||||||
|
response = await this.propertyService.delete({ key });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === PROPERTY_CURRENCIES) {
|
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 { AuthModule } from './auth/auth.module';
|
||||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
|
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { FrontendMiddleware } from './frontend.middleware';
|
import { FrontendMiddleware } from './frontend.middleware';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
|
import { LogoModule } from './logo/logo.module';
|
||||||
import { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||||
import { SubscriptionModule } from './subscription/subscription.module';
|
import { SubscriptionModule } from './subscription/subscription.module';
|
||||||
@ -52,10 +54,12 @@ import { UserModule } from './user/user.module';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
|
LogoModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
Version
|
Version
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@ -58,18 +59,21 @@ export class AuthController {
|
|||||||
@Get('google/callback')
|
@Get('google/callback')
|
||||||
@UseGuards(AuthGuard('google'))
|
@UseGuards(AuthGuard('google'))
|
||||||
@Version(VERSION_NEUTRAL)
|
@Version(VERSION_NEUTRAL)
|
||||||
public googleLoginCallback(@Req() req, @Res() res) {
|
public googleLoginCallback(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
// Handles the Google OAuth2 callback
|
// Handles the Google OAuth2 callback
|
||||||
const jwt: string = req.user.jwt;
|
const jwt: string = (<any>request.user).jwt;
|
||||||
|
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
res.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
res.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||||
|
@ -4,6 +4,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
|
|||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
PropertyModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
@ -11,6 +12,7 @@ export class AuthService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -50,6 +52,13 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (!isUserSignupEnabled) {
|
||||||
|
throw new Error('Sign up forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Create new user if not found
|
// Create new user if not found
|
||||||
user = await this.userService.createUser({
|
user = await this.userService.createUser({
|
||||||
provider,
|
provider,
|
||||||
@ -78,6 +87,13 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (!isUserSignupEnabled) {
|
||||||
|
throw new Error('Sign up forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Create new user if not found
|
// Create new user if not found
|
||||||
user = await this.userService.createUser({
|
user = await this.userService.createUser({
|
||||||
provider,
|
provider,
|
||||||
|
@ -30,8 +30,8 @@ export class BenchmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:startDateString')
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('startDateString') startDateString: string,
|
@Param('startDateString') startDateString: string,
|
||||||
|
@ -73,6 +73,7 @@ export class BenchmarkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allTimeHighs = await Promise.all(promises);
|
const allTimeHighs = await Promise.all(promises);
|
||||||
|
let storeInCache = true;
|
||||||
|
|
||||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
const { marketPrice } =
|
const { marketPrice } =
|
||||||
@ -85,6 +86,8 @@ export class BenchmarkService {
|
|||||||
allTimeHigh,
|
allTimeHigh,
|
||||||
marketPrice
|
marketPrice
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
storeInCache = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -100,11 +103,13 @@ export class BenchmarkService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
if (storeInCache) {
|
||||||
this.CACHE_KEY_BENCHMARKS,
|
await this.redisCacheService.set(
|
||||||
JSON.stringify(benchmarks),
|
this.CACHE_KEY_BENCHMARKS,
|
||||||
ms('4 hours') / 1000
|
JSON.stringify(benchmarks),
|
||||||
);
|
ms('4 hours') / 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return benchmarks;
|
return benchmarks;
|
||||||
}
|
}
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
@ -11,7 +11,9 @@ import { NextFunction, Request, Response } from 'express';
|
|||||||
export class FrontendMiddleware implements NestMiddleware {
|
export class FrontendMiddleware implements NestMiddleware {
|
||||||
public indexHtmlDe = '';
|
public indexHtmlDe = '';
|
||||||
public indexHtmlEn = '';
|
public indexHtmlEn = '';
|
||||||
|
public indexHtmlEs = '';
|
||||||
public indexHtmlIt = '';
|
public indexHtmlIt = '';
|
||||||
|
public indexHtmlNl = '';
|
||||||
public isProduction: boolean;
|
public isProduction: boolean;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -33,54 +35,81 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
this.indexHtmlEs = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('es'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
this.indexHtmlIt = fs.readFileSync(
|
this.indexHtmlIt = fs.readFileSync(
|
||||||
this.getPathOfIndexHtmlFile('it'),
|
this.getPathOfIndexHtmlFile('it'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
this.indexHtmlNl = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('nl'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
public use(req: Request, res: Response, next: NextFunction) {
|
public use(request: Request, response: Response, next: NextFunction) {
|
||||||
let featureGraphicPath = 'assets/cover.png';
|
let featureGraphicPath = 'assets/cover.png';
|
||||||
|
|
||||||
if (
|
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||||
req.path === '/en/blog/2022/08/500-stars-on-github' ||
|
|
||||||
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||||
|
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||||
|
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.path.startsWith('/api/') ||
|
request.path.startsWith('/api/') ||
|
||||||
this.isFileRequest(req.url) ||
|
this.isFileRequest(request.url) ||
|
||||||
!this.isProduction
|
!this.isProduction
|
||||||
) {
|
) {
|
||||||
// Skip
|
// Skip
|
||||||
next();
|
next();
|
||||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||||
res.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlDe, {
|
this.interpolate(this.indexHtmlDe, {
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
languageCode: 'de',
|
languageCode: 'de',
|
||||||
path: req.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else if (req.path === '/it' || req.path.startsWith('/it/')) {
|
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
|
||||||
res.send(
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlEs, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'es',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
||||||
|
response.send(
|
||||||
this.interpolate(this.indexHtmlIt, {
|
this.interpolate(this.indexHtmlIt, {
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
languageCode: 'it',
|
languageCode: 'it',
|
||||||
path: req.path,
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlNl, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'nl',
|
||||||
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
res.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlEn, {
|
this.interpolate(this.indexHtmlEn, {
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||||
path: req.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -8,6 +8,7 @@ import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
|||||||
import {
|
import {
|
||||||
DEMO_USER_ID,
|
DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
PROPERTY_SYSTEM_MESSAGE,
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
@ -103,6 +104,13 @@ export class InfoService {
|
|||||||
)) as string;
|
)) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (isUserSignupEnabled) {
|
||||||
|
globalPermissions.push(permissions.createUserAccount);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...info,
|
...info,
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
@ -145,6 +153,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> {
|
private async countGitHubContributors(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(
|
||||||
@ -245,6 +274,8 @@ export class InfoService {
|
|||||||
const activeUsers1d = await this.countActiveUsers(1);
|
const activeUsers1d = await this.countActiveUsers(1);
|
||||||
const activeUsers30d = await this.countActiveUsers(30);
|
const activeUsers30d = await this.countActiveUsers(30);
|
||||||
const newUsers30d = await this.countNewUsers(30);
|
const newUsers30d = await this.countNewUsers(30);
|
||||||
|
|
||||||
|
const dockerHubPulls = await this.countDockerHubPulls();
|
||||||
const gitHubContributors = await this.countGitHubContributors();
|
const gitHubContributors = await this.countGitHubContributors();
|
||||||
const gitHubStargazers = await this.countGitHubStargazers();
|
const gitHubStargazers = await this.countGitHubStargazers();
|
||||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||||
@ -252,6 +283,7 @@ export class InfoService {
|
|||||||
statistics = {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
|
dockerHubPulls,
|
||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d,
|
newUsers30d,
|
||||||
|
54
apps/api/src/app/logo/logo.controller.ts
Normal file
54
apps/api/src/app/logo/logo.controller.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
import { LogoService } from './logo.service';
|
||||||
|
|
||||||
|
@Controller('logo')
|
||||||
|
export class LogoController {
|
||||||
|
public constructor(private readonly logoService: LogoService) {}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getLogoByDataSourceAndSymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
response.contentType('image/png');
|
||||||
|
response.send(buffer);
|
||||||
|
} catch {
|
||||||
|
response.status(HttpStatus.NOT_FOUND).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
public async getLogoByUrl(
|
||||||
|
@Query('url') url: string,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const buffer = await this.logoService.getLogoByUrl(url);
|
||||||
|
|
||||||
|
response.contentType('image/png');
|
||||||
|
response.send(buffer);
|
||||||
|
} catch {
|
||||||
|
response.status(HttpStatus.NOT_FOUND).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/logo/logo.module.ts
Normal file
13
apps/api/src/app/logo/logo.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { LogoController } from './logo.controller';
|
||||||
|
import { LogoService } from './logo.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [LogoController],
|
||||||
|
imports: [ConfigurationModule, SymbolProfileModule],
|
||||||
|
providers: [LogoService]
|
||||||
|
})
|
||||||
|
export class LogoModule {}
|
55
apps/api/src/app/logo/logo.service.ts
Normal file
55
apps/api/src/app/logo/logo.service.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import * as bent from 'bent';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LogoService {
|
||||||
|
public constructor(
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getLogoByDataSourceAndSymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset) {
|
||||||
|
if (!DataSource[dataSource]) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getBuffer(assetProfile.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLogoByUrl(aUrl: string) {
|
||||||
|
return this.getBuffer(aUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBuffer(aUrl: string) {
|
||||||
|
const get = bent(
|
||||||
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
|
'GET',
|
||||||
|
'buffer',
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
'User-Agent': 'request'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return get();
|
||||||
|
}
|
||||||
|
}
|
@ -6,5 +6,6 @@ export interface Activities {
|
|||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends OrderWithAccount {
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -36,10 +34,10 @@ import { UpdateOrderDto } from './update-order.dto';
|
|||||||
@Controller('order')
|
@Controller('order')
|
||||||
export class OrderController {
|
export class OrderController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ -73,30 +71,11 @@ export class OrderController {
|
|||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
filterByAccounts,
|
||||||
const tagIds = filterByTags?.split(',') ?? [];
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
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 impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
@ -105,27 +84,14 @@ export class OrderController {
|
|||||||
);
|
);
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
let activities = await this.orderService.getOrders({
|
const activities = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
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 };
|
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 { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
@ -18,6 +19,7 @@ import { OrderService } from './order.service';
|
|||||||
controllers: [OrderController],
|
controllers: [OrderController],
|
||||||
exports: [OrderService],
|
exports: [OrderService],
|
||||||
imports: [
|
imports: [
|
||||||
|
ApiModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
|
@ -189,13 +189,15 @@ export class OrderService {
|
|||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId,
|
||||||
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
types?: TypeOfOrder[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<Activity[]> {
|
}): Promise<Activity[]> {
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
@ -284,24 +286,28 @@ export class OrderService {
|
|||||||
},
|
},
|
||||||
orderBy: { date: 'asc' }
|
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 {
|
return {
|
||||||
...order,
|
...order,
|
||||||
value,
|
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
order.fee,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
value,
|
value,
|
||||||
order.SymbolProfile.currency,
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
userCurrency
|
order.fee,
|
||||||
)
|
order.SymbolProfile.currency,
|
||||||
};
|
userCurrency
|
||||||
});
|
),
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateOrder({
|
public async updateOrder({
|
||||||
|
@ -21,6 +21,17 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
|
|
||||||
return { marketPrice: 0 };
|
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':
|
case 'NOVN.SW':
|
||||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||||
return { marketPrice: 87.8 };
|
return { marketPrice: 87.8 };
|
||||||
|
@ -78,6 +78,7 @@ describe('CurrentRateService', () => {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
marketDataService = new MarketDataService(null);
|
marketDataService = new MarketDataService(null);
|
||||||
|
@ -0,0 +1,110 @@
|
|||||||
|
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.getInvestmentsByMonth();
|
||||||
|
|
||||||
|
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') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -22,7 +22,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
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({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
|
@ -0,0 +1,130 @@
|
|||||||
|
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.getInvestmentsByMonth();
|
||||||
|
|
||||||
|
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') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -14,6 +14,7 @@ import {
|
|||||||
format,
|
format,
|
||||||
isAfter,
|
isAfter,
|
||||||
isBefore,
|
isBefore,
|
||||||
|
isSameDay,
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
isSameYear,
|
isSameYear,
|
||||||
max,
|
max,
|
||||||
@ -187,7 +188,9 @@ export class PortfolioCalculator {
|
|||||||
day = addDays(day, step);
|
day = addDays(day, step);
|
||||||
}
|
}
|
||||||
|
|
||||||
dates.push(resetHours(end));
|
if (!isSameDay(last(dates), end)) {
|
||||||
|
dates.push(resetHours(end));
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||||
dataGatheringItems.push({
|
dataGatheringItems.push({
|
||||||
@ -231,21 +234,28 @@ export class PortfolioCalculator {
|
|||||||
[symbol: string]: { [date: string]: Big };
|
[symbol: string]: { [date: string]: Big };
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
|
const maxInvestmentValuesBySymbol: {
|
||||||
|
[symbol: string]: { [date: string]: Big };
|
||||||
|
} = {};
|
||||||
|
|
||||||
const totalNetPerformanceValues: { [date: string]: Big } = {};
|
const totalNetPerformanceValues: { [date: string]: Big } = {};
|
||||||
const totalInvestmentValues: { [date: string]: Big } = {};
|
const totalInvestmentValues: { [date: string]: Big } = {};
|
||||||
|
const maxTotalInvestmentValues: { [date: string]: Big } = {};
|
||||||
|
|
||||||
for (const symbol of Object.keys(symbols)) {
|
for (const symbol of Object.keys(symbols)) {
|
||||||
const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({
|
const { investmentValues, maxInvestmentValues, netPerformanceValues } =
|
||||||
end,
|
this.getSymbolMetrics({
|
||||||
marketSymbolMap,
|
end,
|
||||||
start,
|
marketSymbolMap,
|
||||||
step,
|
start,
|
||||||
symbol,
|
step,
|
||||||
isChartMode: true
|
symbol,
|
||||||
});
|
isChartMode: true
|
||||||
|
});
|
||||||
|
|
||||||
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
||||||
investmentValuesBySymbol[symbol] = investmentValues;
|
investmentValuesBySymbol[symbol] = investmentValues;
|
||||||
|
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const currentDate of dates) {
|
for (const currentDate of dates) {
|
||||||
@ -264,19 +274,28 @@ export class PortfolioCalculator {
|
|||||||
totalInvestmentValues[dateString] =
|
totalInvestmentValues[dateString] =
|
||||||
totalInvestmentValues[dateString] ?? new Big(0);
|
totalInvestmentValues[dateString] ?? new Big(0);
|
||||||
|
|
||||||
|
maxTotalInvestmentValues[dateString] =
|
||||||
|
maxTotalInvestmentValues[dateString] ?? new Big(0);
|
||||||
|
|
||||||
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
||||||
totalInvestmentValues[dateString] = totalInvestmentValues[
|
totalInvestmentValues[dateString] = totalInvestmentValues[
|
||||||
dateString
|
dateString
|
||||||
].add(investmentValuesBySymbol[symbol][dateString]);
|
].add(investmentValuesBySymbol[symbol][dateString]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
|
||||||
|
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
|
||||||
|
dateString
|
||||||
|
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
return Object.keys(totalNetPerformanceValues).map((date) => {
|
||||||
const netPerformanceInPercentage = totalInvestmentValues[date].eq(0)
|
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
|
||||||
? 0
|
? 0
|
||||||
: totalNetPerformanceValues[date]
|
: totalNetPerformanceValues[date]
|
||||||
.div(totalInvestmentValues[date])
|
.div(maxTotalInvestmentValues[date])
|
||||||
.mul(100)
|
.mul(100)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
@ -284,7 +303,10 @@ export class PortfolioCalculator {
|
|||||||
date,
|
date,
|
||||||
netPerformanceInPercentage,
|
netPerformanceInPercentage,
|
||||||
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
||||||
value: netPerformanceInPercentage
|
totalInvestment: totalInvestmentValues[date].toNumber(),
|
||||||
|
value: totalInvestmentValues[date]
|
||||||
|
.plus(totalNetPerformanceValues[date])
|
||||||
|
.toNumber()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -893,13 +915,10 @@ export class PortfolioCalculator {
|
|||||||
let initialValue: Big;
|
let initialValue: Big;
|
||||||
let investmentAtStartDate: Big;
|
let investmentAtStartDate: Big;
|
||||||
const investmentValues: { [date: string]: Big } = {};
|
const investmentValues: { [date: string]: Big } = {};
|
||||||
|
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||||
let lastAveragePrice = new Big(0);
|
let lastAveragePrice = new Big(0);
|
||||||
let lastTransactionInvestment = new Big(0);
|
|
||||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
|
||||||
let maxTotalInvestment = new Big(0);
|
let maxTotalInvestment = new Big(0);
|
||||||
const netPerformanceValues: { [date: string]: Big } = {};
|
const netPerformanceValues: { [date: string]: Big } = {};
|
||||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
|
||||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
|
||||||
let totalInvestment = new Big(0);
|
let totalInvestment = new Big(0);
|
||||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||||
let totalUnits = new Big(0);
|
let totalUnits = new Big(0);
|
||||||
@ -994,6 +1013,12 @@ export class PortfolioCalculator {
|
|||||||
for (let i = 0; i < orders.length; i += 1) {
|
for (let i = 0; i < orders.length; i += 1) {
|
||||||
const order = orders[i];
|
const order = orders[i];
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log();
|
||||||
|
console.log();
|
||||||
|
console.log(i + 1, order.type, order.itemType);
|
||||||
|
}
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
if (order.itemType === 'start') {
|
||||||
// Take the unit price of the order as the market price if there are no
|
// Take the unit price of the order as the market price if there are no
|
||||||
// orders of this symbol before the start date
|
// orders of this symbol before the start date
|
||||||
@ -1021,9 +1046,21 @@ export class PortfolioCalculator {
|
|||||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transactionInvestment = order.quantity
|
const transactionInvestment =
|
||||||
.mul(order.unitPrice)
|
order.type === 'BUY'
|
||||||
.mul(this.getFactor(order.type));
|
? 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);
|
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||||
|
|
||||||
@ -1072,59 +1109,23 @@ export class PortfolioCalculator {
|
|||||||
? new Big(0)
|
? new Big(0)
|
||||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||||
|
|
||||||
const newGrossPerformance = valueOfInvestment
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
console.log(
|
||||||
.plus(grossPerformanceFromSells);
|
'totalInvestmentWithGrossPerformanceFromSell',
|
||||||
|
totalInvestmentWithGrossPerformanceFromSell.toNumber()
|
||||||
if (
|
);
|
||||||
i > indexOfStartOrder &&
|
console.log(
|
||||||
!lastValueOfInvestmentBeforeTransaction
|
'grossPerformanceFromSells',
|
||||||
.plus(lastTransactionInvestment)
|
grossPerformanceFromSells.toNumber()
|
||||||
.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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newGrossPerformance = valueOfInvestment
|
||||||
|
.minus(totalInvestment)
|
||||||
|
.plus(grossPerformanceFromSells);
|
||||||
|
|
||||||
grossPerformance = newGrossPerformance;
|
grossPerformance = newGrossPerformance;
|
||||||
|
|
||||||
lastTransactionInvestment = transactionInvestment;
|
|
||||||
|
|
||||||
lastValueOfInvestmentBeforeTransaction =
|
|
||||||
valueOfInvestmentBeforeTransaction;
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
if (order.itemType === 'start') {
|
||||||
feesAtStartDate = fees;
|
feesAtStartDate = fees;
|
||||||
grossPerformanceAtStartDate = grossPerformance;
|
grossPerformanceAtStartDate = grossPerformance;
|
||||||
@ -1136,6 +1137,15 @@ export class PortfolioCalculator {
|
|||||||
.minus(fees.minus(feesAtStartDate));
|
.minus(fees.minus(feesAtStartDate));
|
||||||
|
|
||||||
investmentValues[order.date] = totalInvestment;
|
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) {
|
if (i === indexOfEndOrder) {
|
||||||
@ -1143,12 +1153,6 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
timeWeightedGrossPerformancePercentage =
|
|
||||||
timeWeightedGrossPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
timeWeightedNetPerformancePercentage =
|
|
||||||
timeWeightedNetPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
const totalGrossPerformance = grossPerformance.minus(
|
const totalGrossPerformance = grossPerformance.minus(
|
||||||
grossPerformanceAtStartDate
|
grossPerformanceAtStartDate
|
||||||
);
|
);
|
||||||
@ -1212,6 +1216,7 @@ export class PortfolioCalculator {
|
|||||||
Average price: ${averagePriceAtStartDate.toFixed(
|
Average price: ${averagePriceAtStartDate.toFixed(
|
||||||
2
|
2
|
||||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||||
|
Total investment: ${totalInvestment.toFixed(2)}
|
||||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||||
Gross performance: ${totalGrossPerformance.toFixed(
|
Gross performance: ${totalGrossPerformance.toFixed(
|
||||||
2
|
2
|
||||||
@ -1227,6 +1232,7 @@ export class PortfolioCalculator {
|
|||||||
initialValue,
|
initialValue,
|
||||||
grossPerformancePercentage,
|
grossPerformancePercentage,
|
||||||
investmentValues,
|
investmentValues,
|
||||||
|
maxInvestmentValues,
|
||||||
netPerformancePercentage,
|
netPerformancePercentage,
|
||||||
netPerformanceValues,
|
netPerformanceValues,
|
||||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||||
|
@ -7,18 +7,16 @@ import {
|
|||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
import {
|
||||||
Filter,
|
|
||||||
PortfolioChart,
|
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioDividends,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport
|
||||||
PortfolioSummary
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import type {
|
import type {
|
||||||
@ -40,6 +38,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import Big from 'big.js';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
@ -52,6 +51,7 @@ export class PortfolioController {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@ -61,55 +61,6 @@ export class PortfolioController {
|
|||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
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('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@ -118,35 +69,21 @@ export class PortfolioController {
|
|||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') range?: DateRange,
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
|
let hasDetails = true;
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||||
const tagIds = filterByTags?.split(',') ?? [];
|
}
|
||||||
|
|
||||||
const filters: Filter[] = [
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
...accountIds.map((accountId) => {
|
filterByAccounts,
|
||||||
return <Filter>{
|
filterByAssetClasses,
|
||||||
id: accountId,
|
filterByTags
|
||||||
type: 'ACCOUNT'
|
});
|
||||||
};
|
|
||||||
}),
|
|
||||||
...assetClasses.map((assetClass) => {
|
|
||||||
return <Filter>{
|
|
||||||
id: assetClass,
|
|
||||||
type: 'ASSET_CLASS'
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
...tagIds.map((tagId) => {
|
|
||||||
return <Filter>{
|
|
||||||
id: tagId,
|
|
||||||
type: 'TAG'
|
|
||||||
};
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accounts,
|
accounts,
|
||||||
@ -154,18 +91,21 @@ export class PortfolioController {
|
|||||||
filteredValueInPercentage,
|
filteredValueInPercentage,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
holdings,
|
holdings,
|
||||||
|
summary,
|
||||||
totalValueInBaseCurrency
|
totalValueInBaseCurrency
|
||||||
} = await this.portfolioService.getDetails(
|
} = await this.portfolioService.getDetails({
|
||||||
|
dateRange,
|
||||||
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id,
|
userId: this.request.user.id
|
||||||
range,
|
});
|
||||||
filters
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let portfolioSummary = summary;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
@ -201,9 +141,26 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasDetails = true;
|
if (
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
hasDetails === false ||
|
||||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
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)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
@ -224,35 +181,80 @@ export class PortfolioController {
|
|||||||
filteredValueInPercentage,
|
filteredValueInPercentage,
|
||||||
hasError,
|
hasError,
|
||||||
holdings,
|
holdings,
|
||||||
totalValueInBaseCurrency
|
totalValueInBaseCurrency,
|
||||||
|
summary: portfolioSummary
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dividends')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getDividends(
|
||||||
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('groupBy') groupBy?: GroupBy
|
||||||
|
): Promise<PortfolioDividends> {
|
||||||
|
let dividends: InvestmentItem[];
|
||||||
|
|
||||||
|
if (groupBy === 'month') {
|
||||||
|
dividends = await this.portfolioService.getDividends({
|
||||||
|
dateRange,
|
||||||
|
groupBy,
|
||||||
|
impersonationId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dividends = await this.portfolioService.getDividends({
|
||||||
|
dateRange,
|
||||||
|
impersonationId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
const maxDividend = dividends.reduce(
|
||||||
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
dividends = dividends.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
investment: item.investment / maxDividend
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
dividends = dividends.map((item) => {
|
||||||
|
return nullifyValuesInObject(item, ['investment']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dividends };
|
||||||
|
}
|
||||||
|
|
||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('groupBy') groupBy?: GroupBy
|
@Query('groupBy') groupBy?: GroupBy
|
||||||
): Promise<PortfolioInvestments> {
|
): Promise<PortfolioInvestments> {
|
||||||
if (
|
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let investments: InvestmentItem[];
|
let investments: InvestmentItem[];
|
||||||
|
|
||||||
if (groupBy === 'month') {
|
if (groupBy === 'month') {
|
||||||
investments = await this.portfolioService.getInvestments(
|
investments = await this.portfolioService.getInvestments({
|
||||||
impersonationId,
|
dateRange,
|
||||||
'month'
|
groupBy,
|
||||||
);
|
impersonationId
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
investments = await this.portfolioService.getInvestments(impersonationId);
|
investments = await this.portfolioService.getInvestments({
|
||||||
|
dateRange,
|
||||||
|
impersonationId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -270,33 +272,16 @@ export class PortfolioController {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { firstOrderDate: parseDate(investments[0]?.date), investments };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('performance')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
||||||
public async getPerformance(
|
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
|
||||||
@Query('range') range
|
|
||||||
): Promise<PortfolioPerformanceResponse> {
|
|
||||||
const performanceInformation = await this.portfolioService.getPerformance(
|
|
||||||
impersonationId,
|
|
||||||
range
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
this.request.user.subscription.type === 'Basic'
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
) {
|
||||||
performanceInformation.performance = nullifyValuesInObject(
|
investments = investments.map((item) => {
|
||||||
performanceInformation.performance,
|
return nullifyValuesInObject(item, ['investment']);
|
||||||
['currentGrossPerformance', 'currentValue']
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return performanceInformation;
|
return { investments };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@ -305,23 +290,53 @@ export class PortfolioController {
|
|||||||
@Version('2')
|
@Version('2')
|
||||||
public async getPerformanceV2(
|
public async getPerformanceV2(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') dateRange
|
@Query('range') dateRange: DateRange = 'max'
|
||||||
): Promise<PortfolioPerformanceResponse> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
const performanceInformation = await this.portfolioService.getPerformanceV2(
|
const performanceInformation = await this.portfolioService.getPerformance({
|
||||||
{
|
dateRange,
|
||||||
dateRange,
|
impersonationId,
|
||||||
impersonationId
|
userId: this.request.user.id
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
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(),
|
||||||
|
value: new Big(value)
|
||||||
|
.div(performanceInformation.performance.currentValue)
|
||||||
|
.toNumber()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
performanceInformation.performance = nullifyValuesInObject(
|
performanceInformation.performance = nullifyValuesInObject(
|
||||||
performanceInformation.performance,
|
performanceInformation.performance,
|
||||||
['currentGrossPerformance', 'currentNetPerformance', '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']);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -333,11 +348,11 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('range') dateRange: DateRange = 'max'
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const result = await this.portfolioService.getPositions(
|
const result = await this.portfolioService.getPositions(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
range
|
dateRange
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -358,6 +373,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('public/:accessId')
|
@Get('public/:accessId')
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPublic(
|
public async getPublic(
|
||||||
@Param('accessId') accessId
|
@Param('accessId') accessId
|
||||||
): Promise<PortfolioPublicDetails> {
|
): Promise<PortfolioPublicDetails> {
|
||||||
@ -378,12 +394,12 @@ export class PortfolioController {
|
|||||||
hasDetails = user.subscription.type === 'Premium';
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { holdings } = await this.portfolioService.getDetails(
|
const { holdings } = await this.portfolioService.getDetails({
|
||||||
access.userId,
|
dateRange: 'max',
|
||||||
access.userId,
|
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||||
'max',
|
impersonationId: access.userId,
|
||||||
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
userId: user.id
|
||||||
);
|
});
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
hasDetails,
|
hasDetails,
|
||||||
@ -407,6 +423,8 @@ export class PortfolioController {
|
|||||||
allocationCurrent: portfolioPosition.value / totalValue,
|
allocationCurrent: portfolioPosition.value / totalValue,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
dataSource: portfolioPosition.dataSource,
|
||||||
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
name: portfolioPosition.name,
|
name: portfolioPosition.name,
|
||||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||||
@ -420,46 +438,6 @@ export class PortfolioController {
|
|||||||
return portfolioPublicDetails;
|
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')
|
@Get('position/:dataSource/:symbol')
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@ -504,16 +482,19 @@ export class PortfolioController {
|
|||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId: string
|
@Headers('impersonation-id') impersonationId: string
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
|
const report = await this.portfolioService.getReport(impersonationId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.subscription.type === 'Basic'
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
for (const rule in report.rules) {
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
if (report.rules[rule]) {
|
||||||
StatusCodes.FORBIDDEN
|
report.rules[rule] = [];
|
||||||
);
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.portfolioService.getReport(impersonationId);
|
return report;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
@ -22,6 +23,7 @@ import { RulesService } from './rules.service';
|
|||||||
exports: [PortfolioService],
|
exports: [PortfolioService],
|
||||||
imports: [
|
imports: [
|
||||||
AccessModule,
|
AccessModule,
|
||||||
|
ApiModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -86,9 +87,12 @@ export class SubscriptionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('stripe/callback')
|
@Get('stripe/callback')
|
||||||
public async stripeCallback(@Req() req, @Res() res) {
|
public async stripeCallback(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||||
req.query.checkoutSessionId
|
<string>request.query.checkoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
Logger.log(
|
Logger.log(
|
||||||
@ -96,7 +100,7 @@ export class SubscriptionController {
|
|||||||
'SubscriptionController'
|
'SubscriptionController'
|
||||||
);
|
);
|
||||||
|
|
||||||
res.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||||
|
@ -91,10 +91,19 @@ export class SymbolController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.symbolService.getForDate({
|
const result = await this.symbolService.getForDate({
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date,
|
||||||
symbol
|
symbol
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result || isEmpty(result)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,6 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
|
|||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
@ -32,7 +31,7 @@ export class SymbolService {
|
|||||||
]);
|
]);
|
||||||
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
||||||
|
|
||||||
if (dataGatheringItem.dataSource && marketPrice) {
|
if (dataGatheringItem.dataSource && marketPrice >= 0) {
|
||||||
let historicalData: HistoricalDataItem[] = [];
|
let historicalData: HistoricalDataItem[] = [];
|
||||||
|
|
||||||
if (includeHistoricalData > 0) {
|
if (includeHistoricalData > 0) {
|
||||||
@ -65,13 +64,9 @@ export class SymbolService {
|
|||||||
|
|
||||||
public async getForDate({
|
public async getForDate({
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date = new Date(),
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
|
||||||
dataSource: DataSource;
|
|
||||||
date: Date;
|
|
||||||
symbol: string;
|
|
||||||
}): Promise<IDataProviderHistoricalResponse> {
|
|
||||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
[{ dataSource, symbol }],
|
[{ dataSource, symbol }],
|
||||||
date,
|
date,
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
import type { DateRange, ViewMode } from '@ghostfolio/common/types';
|
import type {
|
||||||
|
ColorScheme,
|
||||||
|
DateRange,
|
||||||
|
ViewMode
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
@ -16,6 +20,10 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
benchmark?: string;
|
benchmark?: string;
|
||||||
|
|
||||||
|
@IsIn(<ColorScheme[]>['DARK', 'LIGHT'])
|
||||||
|
@IsOptional()
|
||||||
|
colorScheme?: ColorScheme;
|
||||||
|
|
||||||
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
|
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
dateRange?: DateRange;
|
dateRange?: DateRange;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
import { PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config';
|
||||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -69,17 +69,14 @@ export class UserController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
public async signupUser(): Promise<UserItem> {
|
public async signupUser(): Promise<UserItem> {
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
const isUserSignupEnabled =
|
||||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
await this.propertyService.isUserSignupEnabled();
|
||||||
PROPERTY_IS_READ_ONLY_MODE
|
|
||||||
)) as boolean;
|
|
||||||
|
|
||||||
if (isReadOnlyMode) {
|
if (!isUserSignupEnabled) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAdmin = await this.userService.hasAdmin();
|
const hasAdmin = await this.userService.hasAdmin();
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
@ -12,7 +13,7 @@ import { map } from 'rxjs/operators';
|
|||||||
export class RedactValuesInResponseInterceptor<T>
|
export class RedactValuesInResponseInterceptor<T>
|
||||||
implements NestInterceptor<T, any>
|
implements NestInterceptor<T, any>
|
||||||
{
|
{
|
||||||
public constructor() {}
|
public constructor(private userService: UserService) {}
|
||||||
|
|
||||||
public intercept(
|
public intercept(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
@ -23,7 +24,10 @@ export class RedactValuesInResponseInterceptor<T>
|
|||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
||||||
|
|
||||||
if (hasImpersonationId) {
|
if (
|
||||||
|
hasImpersonationId ||
|
||||||
|
this.userService.isRestrictedView(request.user)
|
||||||
|
) {
|
||||||
if (data.accounts) {
|
if (data.accounts) {
|
||||||
for (const accountId of Object.keys(data.accounts)) {
|
for (const accountId of Object.keys(data.accounts)) {
|
||||||
if (data.accounts[accountId]?.balance !== undefined) {
|
if (data.accounts[accountId]?.balance !== undefined) {
|
||||||
@ -38,6 +42,34 @@ export class RedactValuesInResponseInterceptor<T>
|
|||||||
activity.Account.balance = null;
|
activity.Account.balance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activity.comment !== undefined) {
|
||||||
|
activity.comment = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.fee !== undefined) {
|
||||||
|
activity.fee = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.feeInBaseCurrency !== undefined) {
|
||||||
|
activity.feeInBaseCurrency = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.quantity !== undefined) {
|
||||||
|
activity.quantity = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.unitPrice !== undefined) {
|
||||||
|
activity.unitPrice = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.value !== undefined) {
|
||||||
|
activity.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.valueInBaseCurrency !== undefined) {
|
||||||
|
activity.valueInBaseCurrency = null;
|
||||||
|
}
|
||||||
|
|
||||||
return activity;
|
return activity;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -19,13 +19,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
|||||||
|
|
||||||
if (accounts.length === 1) {
|
if (accounts.length === 1) {
|
||||||
return {
|
return {
|
||||||
evaluation: `All your investment is managed by a single account`,
|
evaluation: `Your net worth is managed by a single account`,
|
||||||
value: false
|
value: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
evaluation: `Your investment is managed by ${accounts.length} accounts`,
|
evaluation: `Your net worth is managed by ${accounts.length} accounts`,
|
||||||
value: true
|
value: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
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, {
|
this.environmentConfiguration = cleanEnv(process.env, {
|
||||||
ACCESS_TOKEN_SALT: str(),
|
ACCESS_TOKEN_SALT: str(),
|
||||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||||
BASE_CURRENCY: str({ default: 'USD' }),
|
BASE_CURRENCY: str({
|
||||||
|
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
|
||||||
|
default: 'USD'
|
||||||
|
}),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCES: json({
|
DATA_SOURCES: json({
|
||||||
default: [DataSource.GHOSTFOLIO, DataSource.YAHOO]
|
default: [DataSource.GHOSTFOLIO, DataSource.MANUAL, DataSource.YAHOO]
|
||||||
}),
|
}),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||||
@ -38,7 +41,7 @@ export class ConfigurationService {
|
|||||||
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
PORT: port({ default: 3333 }),
|
PORT: port({ default: 3333 }),
|
||||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
RAPID_API_API_KEY: str({ default: '' }),
|
||||||
REDIS_HOST: host({ default: 'localhost' }),
|
REDIS_HOST: host({ default: 'localhost' }),
|
||||||
REDIS_PASSWORD: str({ default: '' }),
|
REDIS_PASSWORD: str({ default: '' }),
|
||||||
REDIS_PORT: port({ default: 6379 }),
|
REDIS_PORT: port({ default: 6379 }),
|
||||||
|
@ -280,7 +280,7 @@ export class DataGatheringService {
|
|||||||
return (
|
return (
|
||||||
dataSource !== DataSource.GHOSTFOLIO &&
|
dataSource !== DataSource.GHOSTFOLIO &&
|
||||||
dataSource !== DataSource.MANUAL &&
|
dataSource !== DataSource.MANUAL &&
|
||||||
dataSource !== DataSource.RAKUTEN
|
dataSource !== DataSource.RAPID_API
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
|
@ -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 { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
import { 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 { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
@ -27,7 +27,7 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
GoogleSheetsService,
|
GoogleSheetsService,
|
||||||
ManualService,
|
ManualService,
|
||||||
RakutenRapidApiService,
|
RapidApiService,
|
||||||
YahooFinanceService,
|
YahooFinanceService,
|
||||||
{
|
{
|
||||||
inject: [
|
inject: [
|
||||||
@ -36,7 +36,7 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
GoogleSheetsService,
|
GoogleSheetsService,
|
||||||
ManualService,
|
ManualService,
|
||||||
RakutenRapidApiService,
|
RapidApiService,
|
||||||
YahooFinanceService
|
YahooFinanceService
|
||||||
],
|
],
|
||||||
provide: 'DataProviderInterfaces',
|
provide: 'DataProviderInterfaces',
|
||||||
@ -46,7 +46,7 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
ghostfolioScraperApiService,
|
ghostfolioScraperApiService,
|
||||||
googleSheetsService,
|
googleSheetsService,
|
||||||
manualService,
|
manualService,
|
||||||
rakutenRapidApiService,
|
rapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
) => [
|
) => [
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
@ -54,7 +54,7 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
ghostfolioScraperApiService,
|
ghostfolioScraperApiService,
|
||||||
googleSheetsService,
|
googleSheetsService,
|
||||||
manualService,
|
manualService,
|
||||||
rakutenRapidApiService,
|
rapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -114,9 +114,13 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allData = await Promise.all(promises);
|
try {
|
||||||
for (const { data, symbol } of allData) {
|
const allData = await Promise.all(promises);
|
||||||
result[symbol] = data;
|
for (const { data, symbol } of allData) {
|
||||||
|
result[symbol] = data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'DataProviderService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -209,7 +213,9 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(
|
Logger.debug(
|
||||||
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
|
`Fetched ${symbolsChunk.length} quote${
|
||||||
|
symbolsChunk.length > 1 ? 's' : ''
|
||||||
|
} from ${dataSource} in ${(
|
||||||
(performance.now() - startTimeDataSource) /
|
(performance.now() - startTimeDataSource) /
|
||||||
1000
|
1000
|
||||||
).toFixed(3)} seconds`
|
).toFixed(3)} seconds`
|
||||||
@ -223,7 +229,7 @@ export class DataProviderService {
|
|||||||
|
|
||||||
Logger.debug('------------------------------------------------');
|
Logger.debug('------------------------------------------------');
|
||||||
Logger.debug(
|
Logger.debug(
|
||||||
`Fetched ${items.length} quotes in ${(
|
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
|
||||||
(performance.now() - startTimeTotal) /
|
(performance.now() - startTimeTotal) /
|
||||||
1000
|
1000
|
||||||
).toFixed(3)} seconds`
|
).toFixed(3)} seconds`
|
||||||
|
@ -4,13 +4,18 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ManualService implements DataProviderInterface {
|
export class ManualService implements DataProviderInterface {
|
||||||
public constructor() {}
|
public constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return false;
|
return false;
|
||||||
@ -42,10 +47,77 @@ export class ManualService implements DataProviderInterface {
|
|||||||
public async getQuotes(
|
public async getQuotes(
|
||||||
aSymbols: string[]
|
aSymbols: string[]
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const symbolProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
|
||||||
|
|
||||||
|
const marketData = await this.prismaService.marketData.findMany({
|
||||||
|
distinct: ['symbol'],
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
take: aSymbols.length,
|
||||||
|
where: {
|
||||||
|
symbol: {
|
||||||
|
in: aSymbols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const symbolProfile of symbolProfiles) {
|
||||||
|
response[symbolProfile.symbol] = {
|
||||||
|
currency: symbolProfile.currency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
marketPrice:
|
||||||
|
marketData.find((marketDataItem) => {
|
||||||
|
return marketDataItem.symbol === symbolProfile.symbol;
|
||||||
|
})?.marketPrice ?? 0,
|
||||||
|
marketState: 'delayed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'ManualService');
|
||||||
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
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 {}
|
@ -15,14 +15,14 @@ import bent from 'bent';
|
|||||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RakutenRapidApiService implements DataProviderInterface {
|
export class RapidApiService implements DataProviderInterface {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
|
return !!this.configurationService.get('RAPID_API_API_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetProfile(
|
public async getAssetProfile(
|
||||||
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
return DataSource.RAKUTEN;
|
return DataSource.RAPID_API;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuotes(
|
public async getQuotes(
|
||||||
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'RakutenRapidApiService');
|
Logger.error(error, 'RapidApiService');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@ -155,16 +155,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
{
|
{
|
||||||
useQueryString: true,
|
useQueryString: true,
|
||||||
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
|
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
|
||||||
'x-rapidapi-key': this.configurationService.get(
|
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
|
||||||
'RAKUTEN_RAPID_API_KEY'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { fgi } = await get();
|
const { fgi } = await get();
|
||||||
return fgi;
|
return fgi;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'RakutenRapidApiService');
|
Logger.error(error, 'RapidApiService');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
@ -6,6 +6,7 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
@ -57,8 +58,15 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
* DOGEUSD -> DOGE-USD
|
* DOGEUSD -> DOGE-USD
|
||||||
*/
|
*/
|
||||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||||
if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) {
|
if (
|
||||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
aSymbol.includes(this.baseCurrency) &&
|
||||||
|
aSymbol.length > this.baseCurrency.length
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
isCurrency(
|
||||||
|
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
|
||||||
|
)
|
||||||
|
) {
|
||||||
return `${aSymbol}=X`;
|
return `${aSymbol}=X`;
|
||||||
} else if (
|
} else if (
|
||||||
this.cryptocurrencyService.isCryptocurrency(
|
this.cryptocurrencyService.isCryptocurrency(
|
||||||
@ -90,7 +98,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
try {
|
try {
|
||||||
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
||||||
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
||||||
modules: ['price', 'summaryProfile']
|
modules: ['price', 'summaryProfile', 'topHoldings']
|
||||||
});
|
});
|
||||||
|
|
||||||
const { assetClass, assetSubClass } = this.parseAssetClass(
|
const { assetClass, assetSubClass } = this.parseAssetClass(
|
||||||
@ -109,7 +117,16 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
});
|
});
|
||||||
response.symbol = aSymbol;
|
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 &&
|
assetSubClass === AssetSubClass.STOCK &&
|
||||||
assetProfile.summaryProfile?.country
|
assetProfile.summaryProfile?.country
|
||||||
) {
|
) {
|
||||||
@ -189,6 +206,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
} else if (symbol === `${this.baseCurrency}ILA`) {
|
} else if (symbol === `${this.baseCurrency}ILA`) {
|
||||||
// Convert ILS to ILA
|
// Convert ILS to ILA
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||||
|
} else if (symbol === `${this.baseCurrency}ZAc`) {
|
||||||
|
// Convert ZAR to ZAc (cents)
|
||||||
|
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||||
@ -270,6 +290,18 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
.mul(100)
|
.mul(100)
|
||||||
.toNumber()
|
.toNumber()
|
||||||
};
|
};
|
||||||
|
} else if (
|
||||||
|
symbol === `${this.baseCurrency}ZAR` &&
|
||||||
|
yahooFinanceSymbols.includes(`${this.baseCurrency}ZAc=X`)
|
||||||
|
) {
|
||||||
|
// Convert ZAR to ZAc (cents)
|
||||||
|
response[`${this.baseCurrency}ZAc`] = {
|
||||||
|
...response[symbol],
|
||||||
|
currency: 'ZAc',
|
||||||
|
marketPrice: new Big(response[symbol].marketPrice)
|
||||||
|
.mul(100)
|
||||||
|
.toNumber()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -437,4 +469,46 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
return { assetClass, assetSubClass };
|
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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MarketDataModule } from './market-data.module';
|
||||||
import { PrismaModule } from './prisma.module';
|
import { PrismaModule } from './prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
exports: [ExchangeRateDataService],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule
|
PropertyModule
|
||||||
],
|
],
|
||||||
providers: [ExchangeRateDataService],
|
providers: [ExchangeRateDataService]
|
||||||
exports: [ExchangeRateDataService]
|
|
||||||
})
|
})
|
||||||
export class ExchangeRateDataModule {}
|
export class ExchangeRateDataModule {}
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { format } from 'date-fns';
|
import { format, isToday } from 'date-fns';
|
||||||
import { isNumber, uniq } from 'lodash';
|
import { isNumber, uniq } from 'lodash';
|
||||||
|
|
||||||
import { ConfigurationService } from './configuration.service';
|
import { ConfigurationService } from './configuration.service';
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
|
import { MarketDataService } from './market-data.service';
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from './prisma.service';
|
||||||
import { PropertyService } from './property/property.service';
|
import { PropertyService } from './property/property.service';
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ export class ExchangeRateDataService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService
|
private readonly propertyService: PropertyService
|
||||||
) {}
|
) {}
|
||||||
@ -152,6 +154,53 @@ export class ExchangeRateDataService {
|
|||||||
return aValue;
|
return aValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async toCurrencyAtDate(
|
||||||
|
aValue: number,
|
||||||
|
aFromCurrency: string,
|
||||||
|
aToCurrency: string,
|
||||||
|
aDate: Date
|
||||||
|
) {
|
||||||
|
if (aValue === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToday(aDate)) {
|
||||||
|
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
let factor = 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[]> {
|
private async prepareCurrencies(): Promise<string[]> {
|
||||||
let currencies: string[] = [];
|
let currencies: string[] = [];
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
MAX_ACTIVITIES_TO_IMPORT: number;
|
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||||
MAX_ITEM_IN_CACHE: number;
|
MAX_ITEM_IN_CACHE: number;
|
||||||
PORT: number;
|
PORT: number;
|
||||||
RAKUTEN_RAPID_API_KEY: string;
|
RAPID_API_API_KEY: string;
|
||||||
REDIS_HOST: string;
|
REDIS_HOST: string;
|
||||||
REDIS_PASSWORD: string;
|
REDIS_PASSWORD: string;
|
||||||
REDIS_PORT: number;
|
REDIS_PORT: number;
|
||||||
|
@ -6,6 +6,8 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MarketDataService {
|
export class MarketDataService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
@ -20,14 +22,13 @@ export class MarketDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async get({
|
public async get({
|
||||||
date,
|
dataSource,
|
||||||
|
date = new Date(),
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: IDataGatheringItem): Promise<MarketData> {
|
||||||
date: Date;
|
|
||||||
symbol: string;
|
|
||||||
}): Promise<MarketData> {
|
|
||||||
return await this.prismaService.marketData.findFirst({
|
return await this.prismaService.marketData.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: resetHours(date)
|
date: resetHours(date)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { IsString } from 'class-validator';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class PropertyDto {
|
export class PropertyDto {
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
import {
|
||||||
|
PROPERTY_CURRENCIES,
|
||||||
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -39,6 +42,13 @@ export class PropertyService {
|
|||||||
return properties?.[aKey];
|
return properties?.[aKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async isUserSignupEnabled() {
|
||||||
|
return (
|
||||||
|
((await this.getByKey(PROPERTY_IS_USER_SIGNUP_ENABLED)) as boolean) ??
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async put({ key, value }: { key: string; value: string }) {
|
public async put({ key, value }: { key: string; value: string }) {
|
||||||
return this.prismaService.property.upsert({
|
return this.prismaService.property.upsert({
|
||||||
create: { key, value },
|
create: { key, value },
|
||||||
|
@ -8,25 +8,14 @@ import {
|
|||||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client';
|
||||||
DataSource,
|
|
||||||
Prisma,
|
|
||||||
SymbolProfile,
|
|
||||||
SymbolProfileOverrides
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { continents, countries } from 'countries-list';
|
import { continents, countries } from 'countries-list';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SymbolProfileService {
|
export class SymbolProfileService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
public async delete({
|
public async delete({ dataSource, symbol }: UniqueAsset) {
|
||||||
dataSource,
|
|
||||||
symbol
|
|
||||||
}: {
|
|
||||||
dataSource: DataSource;
|
|
||||||
symbol: string;
|
|
||||||
}) {
|
|
||||||
return this.prismaService.symbolProfile.delete({
|
return this.prismaService.symbolProfile.delete({
|
||||||
where: { dataSource_symbol: { dataSource, symbol } }
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
});
|
});
|
||||||
@ -43,7 +32,19 @@ export class SymbolProfileService {
|
|||||||
): Promise<EnhancedSymbolProfile[]> {
|
): Promise<EnhancedSymbolProfile[]> {
|
||||||
return this.prismaService.symbolProfile
|
return this.prismaService.symbolProfile
|
||||||
.findMany({
|
.findMany({
|
||||||
include: { SymbolProfileOverrides: true },
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { Order: true }
|
||||||
|
},
|
||||||
|
Order: {
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
select: { date: true },
|
||||||
|
take: 1
|
||||||
|
},
|
||||||
|
SymbolProfileOverrides: true
|
||||||
|
},
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
@ -69,7 +70,12 @@ export class SymbolProfileService {
|
|||||||
): Promise<EnhancedSymbolProfile[]> {
|
): Promise<EnhancedSymbolProfile[]> {
|
||||||
return this.prismaService.symbolProfile
|
return this.prismaService.symbolProfile
|
||||||
.findMany({
|
.findMany({
|
||||||
include: { SymbolProfileOverrides: true },
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { Order: true }
|
||||||
|
},
|
||||||
|
SymbolProfileOverrides: true
|
||||||
|
},
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
in: symbolProfileIds.map((symbolProfileId) => {
|
in: symbolProfileIds.map((symbolProfileId) => {
|
||||||
@ -89,7 +95,12 @@ export class SymbolProfileService {
|
|||||||
): Promise<EnhancedSymbolProfile[]> {
|
): Promise<EnhancedSymbolProfile[]> {
|
||||||
return this.prismaService.symbolProfile
|
return this.prismaService.symbolProfile
|
||||||
.findMany({
|
.findMany({
|
||||||
include: { SymbolProfileOverrides: true },
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { Order: true }
|
||||||
|
},
|
||||||
|
SymbolProfileOverrides: true
|
||||||
|
},
|
||||||
where: {
|
where: {
|
||||||
symbol: {
|
symbol: {
|
||||||
in: symbols
|
in: symbols
|
||||||
@ -99,22 +110,46 @@ export class SymbolProfileService {
|
|||||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public updateSymbolProfile({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
|
return this.prismaService.symbolProfile.update({
|
||||||
|
data: { comment, symbolMapping },
|
||||||
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getSymbols(
|
private getSymbols(
|
||||||
symbolProfiles: (SymbolProfile & {
|
symbolProfiles: (SymbolProfile & {
|
||||||
|
_count: { Order: number };
|
||||||
|
Order?: {
|
||||||
|
date: Date;
|
||||||
|
}[];
|
||||||
SymbolProfileOverrides: SymbolProfileOverrides;
|
SymbolProfileOverrides: SymbolProfileOverrides;
|
||||||
})[]
|
})[]
|
||||||
): EnhancedSymbolProfile[] {
|
): EnhancedSymbolProfile[] {
|
||||||
return symbolProfiles.map((symbolProfile) => {
|
return symbolProfiles.map((symbolProfile) => {
|
||||||
const item = {
|
const item = {
|
||||||
...symbolProfile,
|
...symbolProfile,
|
||||||
|
activitiesCount: 0,
|
||||||
countries: this.getCountries(
|
countries: this.getCountries(
|
||||||
symbolProfile?.countries as unknown as Prisma.JsonArray
|
symbolProfile?.countries as unknown as Prisma.JsonArray
|
||||||
),
|
),
|
||||||
|
dateOfFirstActivity: <Date>undefined,
|
||||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||||
sectors: this.getSectors(symbolProfile),
|
sectors: this.getSectors(symbolProfile),
|
||||||
symbolMapping: this.getSymbolMapping(symbolProfile)
|
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
item.activitiesCount = symbolProfile._count.Order;
|
||||||
|
delete item._count;
|
||||||
|
|
||||||
|
item.dateOfFirstActivity = symbolProfile.Order?.[0]?.date;
|
||||||
|
delete item.Order;
|
||||||
|
|
||||||
if (item.SymbolProfileOverrides) {
|
if (item.SymbolProfileOverrides) {
|
||||||
item.assetClass =
|
item.assetClass =
|
||||||
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
||||||
|
@ -53,13 +53,15 @@ export class TwitterBotService {
|
|||||||
symbolItem.marketPrice
|
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);
|
const benchmarkListing = await this.getBenchmarkListing(3);
|
||||||
|
|
||||||
if (benchmarkListing?.length > 1) {
|
if (benchmarkListing?.length > 1) {
|
||||||
status += '\n\n';
|
status += '\n\n';
|
||||||
status += '±% from ATH\n';
|
status += '± from ATH in %\n';
|
||||||
status += benchmarkListing;
|
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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
209
apps/client/project.json
Normal file
209
apps/client/project.json
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
{
|
||||||
|
"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-it": {
|
||||||
|
"baseHref": "/it/",
|
||||||
|
"localize": ["it"]
|
||||||
|
},
|
||||||
|
"development-nl": {
|
||||||
|
"baseHref": "/nl/",
|
||||||
|
"localize": ["nl"]
|
||||||
|
},
|
||||||
|
"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-it": {
|
||||||
|
"browserTarget": "client:build:development-it"
|
||||||
|
},
|
||||||
|
"development-nl": {
|
||||||
|
"browserTarget": "client:build:development-nl"
|
||||||
|
},
|
||||||
|
"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.it.xlf",
|
||||||
|
"messages.nl.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"
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"baseHref": "/it/",
|
||||||
|
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||||
|
},
|
||||||
|
"nl": {
|
||||||
|
"baseHref": "/nl/",
|
||||||
|
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sourceLocale": "en"
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
@ -95,6 +95,20 @@ const routes: Routes = [
|
|||||||
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
|
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
|
||||||
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
|
).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: 'demo',
|
path: 'demo',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -138,48 +152,6 @@ const routes: Routes = [
|
|||||||
(m) => m.PortfolioPageModule
|
(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',
|
path: 'pricing',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { ColorScheme } from '@ghostfolio/common/types';
|
||||||
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
@ -77,6 +78,8 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
permissions.createUserAccount
|
permissions.createUserAccount
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.initializeTheme(this.user?.settings.colorScheme);
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -97,13 +100,17 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeTheme() {
|
private initializeTheme(userPreferredColorScheme?: ColorScheme) {
|
||||||
this.materialCssVarsService.setDarkTheme(
|
const isDarkTheme = userPreferredColorScheme
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
? userPreferredColorScheme === 'DARK'
|
||||||
);
|
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
this.materialCssVarsService.setDarkTheme(isDarkTheme);
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
|
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
|
||||||
this.materialCssVarsService.setDarkTheme(event.matches);
|
if (!this.user?.settings.colorScheme) {
|
||||||
|
this.materialCssVarsService.setDarkTheme(event.matches);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
||||||
|
@ -10,8 +10,10 @@ import {
|
|||||||
MatNativeDateModule
|
MatNativeDateModule
|
||||||
} from '@angular/material/core';
|
} from '@angular/material/core';
|
||||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { BrowserModule } from '@angular/platform-browser';
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
|
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||||
import { MaterialCssVarsModule } from 'angular-material-css-vars';
|
import { MaterialCssVarsModule } from 'angular-material-css-vars';
|
||||||
import { MarkdownModule } from 'ngx-markdown';
|
import { MarkdownModule } from 'ngx-markdown';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
@ -49,8 +51,13 @@ export function NgxStripeFactory(): string {
|
|||||||
}),
|
}),
|
||||||
MatNativeDateModule,
|
MatNativeDateModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
|
MatTooltipModule,
|
||||||
NgxSkeletonLoaderModule,
|
NgxSkeletonLoaderModule,
|
||||||
NgxStripeModule.forRoot(environment.stripePublicKey)
|
NgxStripeModule.forRoot(environment.stripePublicKey),
|
||||||
|
ServiceWorkerModule.register('ngsw-worker.js', {
|
||||||
|
enabled: environment.production,
|
||||||
|
registrationStrategy: 'registerImmediately'
|
||||||
|
})
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
authInterceptorProviders,
|
authInterceptorProviders,
|
||||||
|
@ -61,7 +61,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
|||||||
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
|
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
|
||||||
this.accountType = accountType;
|
this.accountType = accountType;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.platformName = Platform?.name;
|
this.platformName = Platform?.name ?? '-';
|
||||||
this.valueInBaseCurrency = valueInBaseCurrency;
|
this.valueInBaseCurrency = valueInBaseCurrency;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
|
@ -21,14 +21,16 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value size="medium" [value]="accountType">Account Type</gf-value>
|
<gf-value i18n size="medium" [value]="accountType"
|
||||||
|
>Account Type</gf-value
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value size="medium" [value]="platformName">Platform</gf-value>
|
<gf-value i18n size="medium" [value]="platformName">Platform</gf-value>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="orders?.length > 0" class="row">
|
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
|
||||||
<div class="col mb-3">
|
<div class="col mb-3">
|
||||||
<div class="h5 mb-0" i18n>Activities</div>
|
<div class="h5 mb-0" i18n>Activities</div>
|
||||||
<gf-activities-table
|
<gf-activities-table
|
||||||
@ -36,7 +38,7 @@
|
|||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
[deviceType]="data.deviceType"
|
[deviceType]="data.deviceType"
|
||||||
[hasPermissionToCreateActivity]="false"
|
[hasPermissionToCreateActivity]="false"
|
||||||
[hasPermissionToExportActivities]="!hasImpersonationId"
|
[hasPermissionToExportActivities]="true"
|
||||||
[hasPermissionToFilter]="false"
|
[hasPermissionToFilter]="false"
|
||||||
[hasPermissionToImportActivities]="false"
|
[hasPermissionToImportActivities]="false"
|
||||||
[hasPermissionToOpenDetails]="false"
|
[hasPermissionToOpenDetails]="false"
|
||||||
|
@ -1,6 +1,26 @@
|
|||||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-header-cell
|
||||||
|
></th>
|
||||||
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
<ion-icon *ngIf="element.isExcluded" name="eye-off-outline"></ion-icon>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
*matFooterCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-footer-cell
|
||||||
|
></td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="account">
|
<ng-container matColumnDef="account">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
|
||||||
|
<ng-container i18n>Name</ng-container>
|
||||||
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<gf-symbol-icon
|
<gf-symbol-icon
|
||||||
*ngIf="element.Platform?.url"
|
*ngIf="element.Platform?.url"
|
||||||
@ -15,11 +35,16 @@
|
|||||||
>(Default)</span
|
>(Default)</span
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td>
|
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="currency">
|
<ng-container matColumnDef="currency">
|
||||||
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header
|
||||||
|
>
|
||||||
<ng-container i18n>Currency</ng-container>
|
<ng-container i18n>Currency</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||||
@ -31,7 +56,12 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="platform">
|
<ng-container matColumnDef="platform">
|
||||||
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header="Platform.name"
|
||||||
|
>
|
||||||
<ng-container i18n>Platform</ng-container>
|
<ng-container i18n>Platform</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||||
@ -53,7 +83,12 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="transactions">
|
<ng-container matColumnDef="transactions">
|
||||||
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="px-1 text-right"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header="transactionCount"
|
||||||
|
>
|
||||||
<span class="d-block d-sm-none">#</span>
|
<span class="d-block d-sm-none">#</span>
|
||||||
<span class="d-none d-sm-block" i18n>Activities</span>
|
<span class="d-none d-sm-block" i18n>Activities</span>
|
||||||
</th>
|
</th>
|
||||||
@ -70,8 +105,9 @@
|
|||||||
<ng-container matColumnDef="balance">
|
<ng-container matColumnDef="balance">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
|
mat-sort-header
|
||||||
>
|
>
|
||||||
<ng-container i18n>Cash Balance</ng-container>
|
<ng-container i18n>Cash Balance</ng-container>
|
||||||
</th>
|
</th>
|
||||||
@ -104,8 +140,9 @@
|
|||||||
<ng-container matColumnDef="value">
|
<ng-container matColumnDef="value">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
|
mat-sort-header
|
||||||
>
|
>
|
||||||
<ng-container i18n>Value</ng-container>
|
<ng-container i18n>Value</ng-container>
|
||||||
</th>
|
</th>
|
||||||
@ -140,6 +177,7 @@
|
|||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-lg-none d-xl-none px-1 text-right"
|
class="d-lg-none d-xl-none px-1 text-right"
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
|
mat-sort-header
|
||||||
>
|
>
|
||||||
<ng-container i18n>Value</ng-container>
|
<ng-container i18n>Value</ng-container>
|
||||||
</th>
|
</th>
|
||||||
|
@ -3,12 +3,6 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
::ng-deep {
|
|
||||||
.mat-form-field-infix {
|
|
||||||
border-top: 0 solid transparent !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-table {
|
.mat-table {
|
||||||
td {
|
td {
|
||||||
&.mat-footer-cell {
|
&.mat-footer-cell {
|
||||||
|
@ -6,11 +6,14 @@ import {
|
|||||||
OnChanges,
|
OnChanges,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
Output
|
Output,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { MatSort } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Account as AccountModel } from '@prisma/client';
|
import { Account as AccountModel } from '@prisma/client';
|
||||||
|
import { get } from 'lodash';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -32,6 +35,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
@Output() accountDeleted = new EventEmitter<string>();
|
@Output() accountDeleted = new EventEmitter<string>();
|
||||||
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
||||||
|
|
||||||
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
public dataSource: MatTableDataSource<AccountModel> =
|
public dataSource: MatTableDataSource<AccountModel> =
|
||||||
new MatTableDataSource();
|
new MatTableDataSource();
|
||||||
public displayedColumns = [];
|
public displayedColumns = [];
|
||||||
@ -46,6 +51,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
this.displayedColumns = [
|
this.displayedColumns = [
|
||||||
|
'status',
|
||||||
'account',
|
'account',
|
||||||
'platform',
|
'platform',
|
||||||
'transactions',
|
'transactions',
|
||||||
@ -63,6 +69,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
if (this.accounts) {
|
if (this.accounts) {
|
||||||
this.dataSource = new MatTableDataSource(this.accounts);
|
this.dataSource = new MatTableDataSource(this.accounts);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
this.dataSource.sortingDataAccessor = get;
|
||||||
|
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||||
@ -21,6 +22,7 @@ import { AccountsTableComponent } from './accounts-table.component';
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
|
MatSortModule,
|
||||||
MatTableModule,
|
MatTableModule,
|
||||||
NgxSkeletonLoaderModule,
|
NgxSkeletonLoaderModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<form class="align-items-center d-flex" [formGroup]="filterForm">
|
<form class="align-items-center d-flex" [formGroup]="filterForm">
|
||||||
<mat-form-field
|
<mat-form-field
|
||||||
appearance="outline"
|
appearance="outline"
|
||||||
class="compact-with-outline flex-grow-1 mr-2 without-hint"
|
class="compact-with-outline without-hint w-100"
|
||||||
>
|
>
|
||||||
<mat-select formControlName="status">
|
<mat-select formControlName="status">
|
||||||
<mat-option></mat-option>
|
<mat-option></mat-option>
|
||||||
@ -15,14 +15,6 @@
|
|||||||
>
|
>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<button
|
|
||||||
class="mt-1"
|
|
||||||
color="warn"
|
|
||||||
mat-flat-button
|
|
||||||
(click)="onDeleteJobs()"
|
|
||||||
>
|
|
||||||
<span i18n>Delete Jobs</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
<table class="gf-table w-100">
|
<table class="gf-table w-100">
|
||||||
<thead>
|
<thead>
|
||||||
@ -35,7 +27,21 @@
|
|||||||
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
|
||||||
<th class="mat-header-cell px-1 py-2"></th>
|
<th class="mat-header-cell px-1 py-2">
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="jobsActionsMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
|
||||||
|
<button mat-menu-item (click)="onDeleteJobs()">
|
||||||
|
<ng-container i18n>Delete Jobs</ng-container>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -102,12 +108,12 @@
|
|||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
[matMenuTriggerFor]="accountMenu"
|
[matMenuTriggerFor]="jobActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onViewData(job.data)">
|
<button mat-menu-item (click)="onViewData(job.data)">
|
||||||
<ng-container i18n>View Data</ng-container>
|
<ng-container i18n>View Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
[historicalDataItems]="historicalDataItems"
|
[historicalDataItems]="historicalDataItems"
|
||||||
|
[isAnimated]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
[showXAxis]="true"
|
[showXAxis]="true"
|
||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
|
@ -20,11 +20,14 @@ import {
|
|||||||
addDays,
|
addDays,
|
||||||
format,
|
format,
|
||||||
isBefore,
|
isBefore,
|
||||||
|
isDate,
|
||||||
isSameDay,
|
isSameDay,
|
||||||
|
isToday,
|
||||||
isValid,
|
isValid,
|
||||||
parse,
|
parse,
|
||||||
parseISO
|
parseISO
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
|
import { last } from 'lodash';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
@ -106,9 +109,15 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const marketDataItems = [...missingMarketData, ...this.marketData];
|
||||||
|
|
||||||
|
if (!isToday(last(marketDataItems)?.date)) {
|
||||||
|
marketDataItems.push({ date: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
this.marketDataByMonth = {};
|
this.marketDataByMonth = {};
|
||||||
|
|
||||||
for (const marketDataItem of [...missingMarketData, ...this.marketData]) {
|
for (const marketDataItem of marketDataItems) {
|
||||||
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
|
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
|
||||||
const key = format(marketDataItem.date, 'yyyy-MM');
|
const key = format(marketDataItem.date, 'yyyy-MM');
|
||||||
|
|
||||||
|
@ -13,11 +13,11 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||||
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { AssetSubClass, DataSource } from '@prisma/client';
|
import { AssetSubClass, DataSource } from '@prisma/client';
|
||||||
import { format, parseISO } from 'date-fns';
|
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
@ -44,10 +44,10 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
AssetSubClass.PRECIOUS_METAL,
|
AssetSubClass.PRECIOUS_METAL,
|
||||||
AssetSubClass.PRIVATE_EQUITY,
|
AssetSubClass.PRIVATE_EQUITY,
|
||||||
AssetSubClass.STOCK
|
AssetSubClass.STOCK
|
||||||
].map((id) => {
|
].map((assetSubClass) => {
|
||||||
return {
|
return {
|
||||||
id,
|
id: assetSubClass,
|
||||||
label: id,
|
label: translate(assetSubClass),
|
||||||
type: 'ASSET_SUB_CLASS'
|
type: 'ASSET_SUB_CLASS'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -63,10 +63,11 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
'assetClass',
|
'assetClass',
|
||||||
'assetSubClass',
|
'assetSubClass',
|
||||||
'date',
|
'date',
|
||||||
'activityCount',
|
'activitiesCount',
|
||||||
'marketDataItemCount',
|
'marketDataItemCount',
|
||||||
'countriesCount',
|
|
||||||
'sectorsCount',
|
'sectorsCount',
|
||||||
|
'countriesCount',
|
||||||
|
'comment',
|
||||||
'actions'
|
'actions'
|
||||||
];
|
];
|
||||||
public filters$ = new Subject<Filter[]>();
|
public filters$ = new Subject<Filter[]>();
|
||||||
@ -96,7 +97,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
) {
|
) {
|
||||||
this.openAssetProfileDialog({
|
this.openAssetProfileDialog({
|
||||||
dataSource: params['dataSource'],
|
dataSource: params['dataSource'],
|
||||||
dateOfFirstActivity: params['dateOfFirstActivity'],
|
|
||||||
symbol: params['symbol']
|
symbol: params['symbol']
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -150,6 +150,35 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onGather7Days() {
|
||||||
|
this.adminService
|
||||||
|
.gather7Days()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGatherMax() {
|
||||||
|
this.adminService
|
||||||
|
.gatherMax()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGatherProfileData() {
|
||||||
|
this.adminService
|
||||||
|
.gatherProfileData()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||||
this.adminService
|
this.adminService
|
||||||
.gatherProfileDataBySymbol({ dataSource, symbol })
|
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||||
@ -164,18 +193,9 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onOpenAssetProfileDialog({
|
public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) {
|
||||||
dataSource,
|
|
||||||
dateOfFirstActivity,
|
|
||||||
symbol
|
|
||||||
}: UniqueAsset & { dateOfFirstActivity: string }) {
|
|
||||||
try {
|
|
||||||
dateOfFirstActivity = format(parseISO(dateOfFirstActivity), DATE_FORMAT);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
this.router.navigate([], {
|
this.router.navigate([], {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
dateOfFirstActivity,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
assetProfileDialog: true
|
assetProfileDialog: true
|
||||||
@ -190,11 +210,9 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private openAssetProfileDialog({
|
private openAssetProfileDialog({
|
||||||
dataSource,
|
dataSource,
|
||||||
dateOfFirstActivity,
|
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
dateOfFirstActivity: string;
|
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}) {
|
}) {
|
||||||
this.userService
|
this.userService
|
||||||
@ -207,7 +225,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
data: <AssetProfileDialogParams>{
|
data: <AssetProfileDialogParams>{
|
||||||
dataSource,
|
dataSource,
|
||||||
dateOfFirstActivity,
|
|
||||||
symbol,
|
symbol,
|
||||||
deviceType: this.deviceType,
|
deviceType: this.deviceType,
|
||||||
locale: this.user?.settings?.locale
|
locale: this.user?.settings?.locale
|
||||||
|
@ -13,10 +13,10 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<table
|
<table
|
||||||
class="gf-table w-100"
|
class="gf-table w-100"
|
||||||
|
mat-table
|
||||||
matSort
|
matSort
|
||||||
matSortActive="symbol"
|
matSortActive="symbol"
|
||||||
matSortDirection="asc"
|
matSortDirection="asc"
|
||||||
mat-table
|
|
||||||
[dataSource]="dataSource"
|
[dataSource]="dataSource"
|
||||||
>
|
>
|
||||||
<ng-container matColumnDef="symbol">
|
<ng-container matColumnDef="symbol">
|
||||||
@ -64,12 +64,12 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="activityCount">
|
<ng-container matColumnDef="activitiesCount">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
<ng-container i18n>Activity Count</ng-container>
|
<ng-container i18n>Activities Count</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
{{ element.activityCount }}
|
{{ element.activitiesCount }}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@ -82,15 +82,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="countriesCount">
|
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
|
||||||
<ng-container i18n>Countries Count</ng-container>
|
|
||||||
</th>
|
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
|
||||||
{{ element.countriesCount }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="sectorsCount">
|
<ng-container matColumnDef="sectorsCount">
|
||||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
<ng-container i18n>Sectors Count</ng-container>
|
<ng-container i18n>Sectors Count</ng-container>
|
||||||
@ -100,18 +91,63 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="countriesCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Countries Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.countriesCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="comment">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header
|
||||||
|
></th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.comment"
|
||||||
|
class="d-block"
|
||||||
|
name="document-text-outline"
|
||||||
|
></ion-icon>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
|
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
||||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
[matMenuTriggerFor]="accountMenu"
|
[matMenuTriggerFor]="assetProfilesActionsMenu"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
|
||||||
|
<button mat-menu-item (click)="onGather7Days()">
|
||||||
|
<ng-container i18n>Gather Recent Data</ng-container>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="onGatherMax()">
|
||||||
|
<ng-container i18n>Gather All Data</ng-container>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="onGatherProfileData()">
|
||||||
|
<ng-container i18n>Gather Profile Data</ng-container>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||||
<button
|
<button
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
@ -126,7 +162,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[disabled]="element.activityCount !== 0"
|
[disabled]="element.activitiesCount !== 0"
|
||||||
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Delete</ng-container>
|
<ng-container i18n>Delete</ng-container>
|
||||||
@ -140,7 +176,7 @@
|
|||||||
*matRowDef="let row; columns: displayedColumns"
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
mat-row
|
mat-row
|
||||||
(click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
|
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
|
||||||
></tr>
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,9 +6,14 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormBuilder } from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { MarketData } from '@prisma/client';
|
import { MarketData } from '@prisma/client';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
@ -23,51 +28,126 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
|||||||
styleUrls: ['./asset-profile-dialog.component.scss']
|
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||||
})
|
})
|
||||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||||
|
public assetProfile: EnhancedSymbolProfile;
|
||||||
|
public assetProfileForm = this.formBuilder.group({
|
||||||
|
comment: '',
|
||||||
|
symbolMapping: ''
|
||||||
|
});
|
||||||
|
public countries: {
|
||||||
|
[code: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
public marketDataDetails: MarketData[] = [];
|
public marketDataDetails: MarketData[] = [];
|
||||||
|
public sectors: {
|
||||||
|
[name: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
|
||||||
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams
|
private formBuilder: FormBuilder
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit(): void {
|
public ngOnInit(): void {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public initialize() {
|
||||||
|
this.adminService
|
||||||
|
.fetchAdminMarketDataBySymbol({
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
symbol: this.data.symbol
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ assetProfile, marketData }) => {
|
||||||
|
this.assetProfile = assetProfile;
|
||||||
|
this.countries = {};
|
||||||
|
this.marketDataDetails = marketData;
|
||||||
|
this.sectors = {};
|
||||||
|
|
||||||
|
if (assetProfile?.countries?.length > 0) {
|
||||||
|
for (const country of assetProfile.countries) {
|
||||||
|
this.countries[country.code] = {
|
||||||
|
name: country.name,
|
||||||
|
value: country.weight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetProfile?.sectors?.length > 0) {
|
||||||
|
for (const sector of assetProfile.sectors) {
|
||||||
|
this.sectors[sector.name] = {
|
||||||
|
name: sector.name,
|
||||||
|
value: sector.weight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assetProfileForm.setValue({
|
||||||
|
comment: this.assetProfile?.comment ?? '',
|
||||||
|
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
|
||||||
|
});
|
||||||
|
|
||||||
|
this.assetProfileForm.markAsPristine();
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onClose(): void {
|
public onClose(): void {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
this.adminService
|
||||||
|
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
this.adminService
|
||||||
|
.gatherSymbol({ dataSource, symbol })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||||
if (withRefresh) {
|
if (withRefresh) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onSubmit() {
|
||||||
|
let symbolMapping = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
symbolMapping = JSON.parse(
|
||||||
|
this.assetProfileForm.controls['symbolMapping'].value
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const assetProfileData: UpdateAssetProfileDto = {
|
||||||
|
symbolMapping,
|
||||||
|
comment: this.assetProfileForm.controls['comment'].value ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.adminService
|
||||||
|
.patchAssetProfile({
|
||||||
|
...assetProfileData,
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
symbol: this.data.symbol
|
||||||
|
})
|
||||||
|
.subscribe(() => {
|
||||||
|
this.initialize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
|
||||||
this.adminService
|
|
||||||
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(({ marketData }) => {
|
|
||||||
this.marketDataDetails = marketData;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private initialize() {
|
|
||||||
this.fetchAdminMarketDataBySymbol({
|
|
||||||
dataSource: this.data.dataSource,
|
|
||||||
symbol: this.data.symbol
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,176 @@
|
|||||||
<gf-dialog-header
|
<form
|
||||||
mat-dialog-title
|
class="d-flex flex-column h-100"
|
||||||
position="center"
|
[formGroup]="assetProfileForm"
|
||||||
[deviceType]="data.deviceType"
|
(keyup.enter)="assetProfileForm.valid && onSubmit()"
|
||||||
[title]="data.symbol"
|
(ngSubmit)="onSubmit()"
|
||||||
(closeButtonClicked)="onClose()"
|
>
|
||||||
></gf-dialog-header>
|
<div class="d-flex mb-3">
|
||||||
|
<h1 class="flex-grow-1 m-0" mat-dialog-title>
|
||||||
|
{{ assetProfile?.name ?? data.symbol }}
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
type="button"
|
||||||
|
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||||
|
<button mat-menu-item type="button" (click)="initialize()">
|
||||||
|
<ng-container i18n>Refresh</ng-container>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
type="button"
|
||||||
|
[disabled]="assetProfileForm.dirty"
|
||||||
|
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Gather Data</ng-container>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
type="button"
|
||||||
|
[disabled]="assetProfileForm.dirty"
|
||||||
|
(click)="onGatherProfileDataBySymbol({dataSource: data.dataSource, symbol: data.symbol})"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Gather Profile Data</ng-container>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
<gf-admin-market-data-detail
|
<gf-admin-market-data-detail
|
||||||
[dataSource]="data.dataSource"
|
class="mb-3"
|
||||||
[dateOfFirstActivity]="data.dateOfFirstActivity"
|
[dataSource]="data.dataSource"
|
||||||
[locale]="data.locale"
|
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
||||||
[marketData]="marketDataDetails"
|
[locale]="data.locale"
|
||||||
[symbol]="data.symbol"
|
[marketData]="marketDataDetails"
|
||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
[symbol]="data.symbol"
|
||||||
></gf-admin-market-data-detail>
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
</div>
|
></gf-admin-market-data-detail>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<gf-value
|
||||||
|
i18n
|
||||||
|
size="medium"
|
||||||
|
[isDate]="assetProfile?.dateOfFirstActivity ? true : false"
|
||||||
|
[locale]="data.locale"
|
||||||
|
[value]="assetProfile?.dateOfFirstActivity ?? '-'"
|
||||||
|
>First Activity</gf-value
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<gf-value
|
||||||
|
i18n
|
||||||
|
size="medium"
|
||||||
|
[locale]="data.locale"
|
||||||
|
[value]="assetProfile?.activitiesCount ?? 0"
|
||||||
|
>Activities</gf-value
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<gf-value
|
||||||
|
i18n
|
||||||
|
size="medium"
|
||||||
|
[hidden]="!assetProfile?.assetClass"
|
||||||
|
[value]="assetProfile?.assetClass"
|
||||||
|
>Asset Class</gf-value
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<gf-value
|
||||||
|
i18n
|
||||||
|
size="medium"
|
||||||
|
[hidden]="!assetProfile?.assetSubClass"
|
||||||
|
[value]="assetProfile?.assetSubClass"
|
||||||
|
>Asset Sub Class</gf-value
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<ng-container
|
||||||
|
*ngIf="assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0"
|
||||||
|
>
|
||||||
|
<ng-container
|
||||||
|
*ngIf="assetProfile?.countries?.length === 1 && assetProfile?.sectors?.length === 1; else charts"
|
||||||
|
>
|
||||||
|
<div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3">
|
||||||
|
<gf-value
|
||||||
|
i18n
|
||||||
|
size="medium"
|
||||||
|
[locale]="data.locale"
|
||||||
|
[value]="assetProfile?.sectors[0].name"
|
||||||
|
>Sector</gf-value
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="assetProfile?.countries?.length === 1" class="col-6 mb-3">
|
||||||
|
<gf-value
|
||||||
|
i18n
|
||||||
|
size="medium"
|
||||||
|
[locale]="data.locale"
|
||||||
|
[value]="assetProfile?.countries[0].name"
|
||||||
|
>Country</gf-value
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #charts>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="h5" i18n>Sectors</div>
|
||||||
|
<gf-portfolio-proportion-chart
|
||||||
|
[colorScheme]="data.colorScheme"
|
||||||
|
[isInPercent]="true"
|
||||||
|
[keys]="['name']"
|
||||||
|
[maxItems]="10"
|
||||||
|
[positions]="sectors"
|
||||||
|
></gf-portfolio-proportion-chart>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="h5" i18n>Countries</div>
|
||||||
|
<gf-portfolio-proportion-chart
|
||||||
|
[colorScheme]="data.colorScheme"
|
||||||
|
[isInPercent]="true"
|
||||||
|
[keys]="['name']"
|
||||||
|
[maxItems]="10"
|
||||||
|
[positions]="countries"
|
||||||
|
></gf-portfolio-proportion-chart>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Symbol Mapping</mat-label>
|
||||||
|
<textarea
|
||||||
|
cdkTextareaAutosize
|
||||||
|
formControlName="symbolMapping"
|
||||||
|
matInput
|
||||||
|
type="text"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Note</mat-label>
|
||||||
|
<textarea
|
||||||
|
cdkAutosizeMinRows="2"
|
||||||
|
cdkTextareaAutosize
|
||||||
|
formControlName="comment"
|
||||||
|
matInput
|
||||||
|
(keyup.enter)="$event.stopPropagation()"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<gf-dialog-footer
|
<div class="d-flex justify-content-end" mat-dialog-actions>
|
||||||
mat-dialog-actions
|
<button i18n mat-button type="button" (click)="onClose()">Cancel</button>
|
||||||
[deviceType]="data.deviceType"
|
<button
|
||||||
(closeButtonClicked)="onClose()"
|
color="primary"
|
||||||
></gf-dialog-footer>
|
mat-flat-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="!(assetProfileForm.dirty && assetProfileForm.valid)"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Save</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
|
import { TextFieldModule } from '@angular/cdk/text-field';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
import { AssetProfileDialog } from './asset-profile-dialog.component';
|
import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||||
|
|
||||||
@ -12,11 +16,16 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
|||||||
declarations: [AssetProfileDialog],
|
declarations: [AssetProfileDialog],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
GfAdminMarketDataDetailModule,
|
GfAdminMarketDataDetailModule,
|
||||||
GfDialogFooterModule,
|
GfPortfolioProportionChartModule,
|
||||||
GfDialogHeaderModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatDialogModule
|
MatDialogModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatMenuModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
TextFieldModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface AssetProfileDialogParams {
|
export interface AssetProfileDialogParams {
|
||||||
dateOfFirstActivity: string;
|
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
deviceType: string;
|
deviceType: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
|
||||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
@ -8,6 +7,7 @@ import {
|
|||||||
PROPERTY_COUPONS,
|
PROPERTY_COUPONS,
|
||||||
PROPERTY_CURRENCIES,
|
PROPERTY_CURRENCIES,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
PROPERTY_SYSTEM_MESSAGE
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
@ -36,6 +36,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public hasPermissionForSystemMessage: boolean;
|
public hasPermissionForSystemMessage: boolean;
|
||||||
public hasPermissionToToggleReadOnlyMode: boolean;
|
public hasPermissionToToggleReadOnlyMode: boolean;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
|
public permissions = permissions;
|
||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
public userCount: number;
|
public userCount: number;
|
||||||
public user: User;
|
public user: User;
|
||||||
@ -43,7 +44,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -99,7 +99,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
...this.coupons,
|
...this.coupons,
|
||||||
{ code: this.generateCouponCode(16), duration: this.couponDuration }
|
{ code: this.generateCouponCode(16), duration: this.couponDuration }
|
||||||
];
|
];
|
||||||
this.putCoupons(coupons);
|
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
||||||
}
|
}
|
||||||
|
|
||||||
public onAddCurrency() {
|
public onAddCurrency() {
|
||||||
@ -107,7 +107,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
if (currency) {
|
if (currency) {
|
||||||
const currencies = uniq([...this.customCurrencies, currency]);
|
const currencies = uniq([...this.customCurrencies, currency]);
|
||||||
this.putCurrencies(currencies);
|
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,7 +124,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
const coupons = this.coupons.filter((coupon) => {
|
const coupons = this.coupons.filter((coupon) => {
|
||||||
return coupon.code !== aCouponCode;
|
return coupon.code !== aCouponCode;
|
||||||
});
|
});
|
||||||
this.putCoupons(coupons);
|
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,12 +137,12 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
const currencies = this.customCurrencies.filter((currency) => {
|
const currencies = this.customCurrencies.filter((currency) => {
|
||||||
return currency !== aCurrency;
|
return currency !== aCurrency;
|
||||||
});
|
});
|
||||||
this.putCurrencies(currencies);
|
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteSystemMessage() {
|
public onDeleteSystemMessage() {
|
||||||
this.putSystemMessage('');
|
this.putAdminSetting({ key: PROPERTY_SYSTEM_MESSAGE, value: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
public onFlushCache() {
|
public onFlushCache() {
|
||||||
@ -162,44 +162,28 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onGather7Days() {
|
|
||||||
this.adminService
|
|
||||||
.gather7Days()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onGatherMax() {
|
|
||||||
this.adminService
|
|
||||||
.gatherMax()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onGatherProfileData() {
|
|
||||||
this.adminService
|
|
||||||
.gatherProfileData()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
|
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
|
||||||
this.setReadOnlyMode(aEvent.checked);
|
this.putAdminSetting({
|
||||||
|
key: PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
value: aEvent.checked ? true : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
|
||||||
|
this.putAdminSetting({
|
||||||
|
key: PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||||
|
value: aEvent.checked ? undefined : false
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onSetSystemMessage() {
|
public onSetSystemMessage() {
|
||||||
const systemMessage = prompt($localize`Please set your system message:`);
|
const systemMessage = prompt($localize`Please set your system message:`);
|
||||||
|
|
||||||
if (systemMessage) {
|
if (systemMessage) {
|
||||||
this.putSystemMessage(systemMessage);
|
this.putAdminSetting({
|
||||||
|
key: PROPERTY_SYSTEM_MESSAGE,
|
||||||
|
value: systemMessage
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -236,49 +220,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
return couponCode;
|
return couponCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
private putCoupons(aCoupons: Coupon[]) {
|
private putAdminSetting({ key, value }: { key: string; value: any }) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putAdminSetting(PROPERTY_COUPONS, {
|
.putAdminSetting(key, {
|
||||||
value: JSON.stringify(aCoupons)
|
value: value || value === false ? JSON.stringify(value) : undefined
|
||||||
})
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private putCurrencies(aCurrencies: string[]) {
|
|
||||||
this.dataService
|
|
||||||
.putAdminSetting(PROPERTY_CURRENCIES, {
|
|
||||||
value: JSON.stringify(aCurrencies)
|
|
||||||
})
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private putSystemMessage(aSystemMessage: string) {
|
|
||||||
this.dataService
|
|
||||||
.putAdminSetting(PROPERTY_SYSTEM_MESSAGE, {
|
|
||||||
value: aSystemMessage
|
|
||||||
})
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private setReadOnlyMode(aValue: boolean) {
|
|
||||||
this.dataService
|
|
||||||
.putAdminSetting(PROPERTY_IS_READ_ONLY_MODE, {
|
|
||||||
value: aValue ? 'true' : ''
|
|
||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
|
@ -5,61 +5,25 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>User Count</div>
|
<div class="w-50" i18n>User Count</div>
|
||||||
<div class="w-50">{{ userCount }}</div>
|
<div class="w-50">
|
||||||
|
<gf-value
|
||||||
|
precision="0"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="userCount"
|
||||||
|
></gf-value>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Activity Count</div>
|
<div class="w-50" i18n>Activity Count</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<ng-container *ngIf="transactionCount">
|
<gf-value
|
||||||
{{ transactionCount }} ({{ transactionCount / userCount | number
|
precision="0"
|
||||||
: '1.2-2' }} <span i18n>per User</span>)
|
[locale]="user?.settings?.locale"
|
||||||
</ng-container>
|
[value]="transactionCount"
|
||||||
</div>
|
></gf-value>
|
||||||
</div>
|
<div *ngIf="transactionCount && userCount">
|
||||||
<div class="d-flex my-3">
|
{{ transactionCount / userCount | number : '1.2-2' }}
|
||||||
<div class="w-50" i18n>Data Management</div>
|
<span i18n>per User</span>
|
||||||
<div class="w-50">
|
|
||||||
<div class="overflow-hidden">
|
|
||||||
<div class="mb-2">
|
|
||||||
<button
|
|
||||||
color="accent"
|
|
||||||
mat-flat-button
|
|
||||||
(click)="onGather7Days()"
|
|
||||||
>
|
|
||||||
<ion-icon
|
|
||||||
class="mr-1"
|
|
||||||
name="cloud-download-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<span i18n>Gather Recent Data</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<button
|
|
||||||
color="accent"
|
|
||||||
mat-flat-button
|
|
||||||
(click)="onGatherMax()"
|
|
||||||
>
|
|
||||||
<ion-icon
|
|
||||||
class="mr-1"
|
|
||||||
name="cloud-download-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<span i18n>Gather All Data</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
class="mb-2 mr-2"
|
|
||||||
color="accent"
|
|
||||||
mat-flat-button
|
|
||||||
(click)="onGatherProfileData()"
|
|
||||||
>
|
|
||||||
<ion-icon
|
|
||||||
class="mr-1"
|
|
||||||
name="cloud-download-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<span i18n>Gather Profile Data</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -108,31 +72,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
<div class="align-items-start d-flex my-3">
|
||||||
<div class="w-50" i18n>System Message</div>
|
<div class="w-50" i18n>Benchmarks</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<div *ngIf="info.systemMessage">
|
<table>
|
||||||
<span>{{ info.systemMessage }}</span>
|
<tr *ngFor="let benchmark of info?.benchmarks">
|
||||||
<button
|
<td class="pl-1">{{ benchmark.symbol }}</td>
|
||||||
class="mini-icon mx-1 no-min-width px-2"
|
</tr>
|
||||||
mat-button
|
</table>
|
||||||
(click)="onDeleteSystemMessage()"
|
</div>
|
||||||
>
|
</div>
|
||||||
<ion-icon name="trash-outline"></ion-icon>
|
<div class="d-flex my-3">
|
||||||
</button>
|
<div class="w-50" i18n>User Signup</div>
|
||||||
</div>
|
<div class="w-50">
|
||||||
<button
|
<mat-slide-toggle
|
||||||
*ngIf="!info.systemMessage"
|
color="primary"
|
||||||
color="accent"
|
[checked]="info.globalPermissions.includes(permissions.createUserAccount)"
|
||||||
mat-flat-button
|
(change)="onEnableUserSignupModeChange($event)"
|
||||||
(click)="onSetSystemMessage()"
|
></mat-slide-toggle>
|
||||||
>
|
|
||||||
<ion-icon
|
|
||||||
class="mr-1"
|
|
||||||
name="information-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<span i18n>Set Message</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
||||||
@ -145,6 +102,33 @@
|
|||||||
></mat-slide-toggle>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>System Message</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<div *ngIf="info?.systemMessage">
|
||||||
|
<span>{{ info.systemMessage }}</span>
|
||||||
|
<button
|
||||||
|
class="mini-icon mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
(click)="onDeleteSystemMessage()"
|
||||||
|
>
|
||||||
|
<ion-icon name="trash-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
*ngIf="!info?.systemMessage"
|
||||||
|
color="accent"
|
||||||
|
mat-flat-button
|
||||||
|
(click)="onSetSystemMessage()"
|
||||||
|
>
|
||||||
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="information-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<span i18n>Set Message</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
*ngIf="hasPermissionForSubscription"
|
*ngIf="hasPermissionForSubscription"
|
||||||
class="d-flex my-3 subscription"
|
class="d-flex my-3 subscription"
|
||||||
|
@ -37,29 +37,30 @@
|
|||||||
<gf-premium-indicator
|
<gf-premium-indicator
|
||||||
*ngIf="userItem?.subscription?.type === 'Premium'"
|
*ngIf="userItem?.subscription?.type === 'Premium'"
|
||||||
class="ml-1"
|
class="ml-1"
|
||||||
|
[enableLink]="false"
|
||||||
></gf-premium-indicator>
|
></gf-premium-indicator>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
{{ formatDistanceToNow(userItem.createdAt) }}
|
{{ formatDistanceToNow(userItem.createdAt) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2">
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="align-items-end"
|
class="d-inline-block justify-content-end"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[value]="userItem.accountCount"
|
[value]="userItem.accountCount"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2">
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="align-items-end"
|
class="d-inline-block justify-content-end"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[value]="userItem.transactionCount"
|
[value]="userItem.transactionCount"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2">
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
<gf-value
|
<gf-value
|
||||||
class="align-items-end"
|
class="d-inline-block justify-content-end"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[precision]="0"
|
[precision]="0"
|
||||||
[value]="userItem.engagement"
|
[value]="userItem.engagement"
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
<div class="row">
|
<div class="mb-2 row">
|
||||||
<div class="col-md-6 col-xs-12 d-flex">
|
<div class="col-md-6 col-xs-12 d-flex">
|
||||||
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
|
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
|
||||||
<span i18n>Benchmarks</span>
|
<span i18n>Performance</span>
|
||||||
<sup i18n>Beta</sup>
|
|
||||||
<gf-premium-indicator
|
<gf-premium-indicator
|
||||||
*ngIf="user?.subscription?.type === 'Basic'"
|
*ngIf="user?.subscription?.type === 'Basic'"
|
||||||
class="ml-1"
|
class="ml-1"
|
||||||
@ -14,13 +13,16 @@
|
|||||||
appearance="outline"
|
appearance="outline"
|
||||||
class="w-100 without-hint"
|
class="w-100 without-hint"
|
||||||
color="accent"
|
color="accent"
|
||||||
|
[hidden]="benchmarks?.length === 0"
|
||||||
>
|
>
|
||||||
<mat-label i18n>Compare with...</mat-label>
|
<mat-label i18n>Compare with...</mat-label>
|
||||||
<mat-select
|
<mat-select
|
||||||
name="benchmark"
|
name="benchmark"
|
||||||
|
[disabled]="user?.subscription?.type === 'Basic'"
|
||||||
[value]="benchmark"
|
[value]="benchmark"
|
||||||
(selectionChange)="onChangeBenchmark($event.value)"
|
(selectionChange)="onChangeBenchmark($event.value)"
|
||||||
>
|
>
|
||||||
|
<mat-option [value]="null"></mat-option>
|
||||||
<mat-option
|
<mat-option
|
||||||
*ngFor="let symbolProfile of benchmarks"
|
*ngFor="let symbolProfile of benchmarks"
|
||||||
[value]="symbolProfile.id"
|
[value]="symbolProfile.id"
|
||||||
@ -30,14 +32,6 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="my-2 text-center">
|
|
||||||
<gf-toggle
|
|
||||||
[defaultValue]="user?.settings?.dateRange"
|
|
||||||
[isLoading]="isLoading"
|
|
||||||
[options]="dateRangeOptions"
|
|
||||||
(change)="onChangeDateRange($event.value)"
|
|
||||||
></gf-toggle>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<ngx-skeleton-loader
|
<ngx-skeleton-loader
|
||||||
*ngIf="isLoading"
|
*ngIf="isLoading"
|
||||||
|
@ -10,7 +10,6 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
|
|
||||||
import {
|
import {
|
||||||
getTooltipOptions,
|
getTooltipOptions,
|
||||||
getTooltipPositionerMapTop,
|
getTooltipPositionerMapTop,
|
||||||
@ -24,7 +23,8 @@ import {
|
|||||||
parseDate
|
parseDate
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
|
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { DateRange } from '@ghostfolio/common/types';
|
import { ColorScheme } from '@ghostfolio/common/types';
|
||||||
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
Chart,
|
Chart,
|
||||||
LineController,
|
LineController,
|
||||||
@ -35,7 +35,6 @@ import {
|
|||||||
Tooltip
|
Tooltip
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-benchmark-comparator',
|
selector: 'gf-benchmark-comparator',
|
||||||
@ -47,6 +46,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
@Input() benchmarkDataItems: LineChartItem[] = [];
|
@Input() benchmarkDataItems: LineChartItem[] = [];
|
||||||
@Input() benchmark: string;
|
@Input() benchmark: string;
|
||||||
@Input() benchmarks: Partial<SymbolProfile>[];
|
@Input() benchmarks: Partial<SymbolProfile>[];
|
||||||
|
@Input() colorScheme: ColorScheme;
|
||||||
@Input() daysInMarket: number;
|
@Input() daysInMarket: number;
|
||||||
@Input() isLoading: boolean;
|
@Input() isLoading: boolean;
|
||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
@ -54,12 +54,10 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
@Input() user: User;
|
@Input() user: User;
|
||||||
|
|
||||||
@Output() benchmarkChanged = new EventEmitter<string>();
|
@Output() benchmarkChanged = new EventEmitter<string>();
|
||||||
@Output() dateRangeChanged = new EventEmitter<DateRange>();
|
|
||||||
|
|
||||||
@ViewChild('chartCanvas') chartCanvas;
|
@ViewChild('chartCanvas') chartCanvas;
|
||||||
|
|
||||||
public chart: Chart<any>;
|
public chart: Chart<any>;
|
||||||
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
Chart.register(
|
Chart.register(
|
||||||
@ -86,10 +84,6 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
this.benchmarkChanged.next(symbolProfileId);
|
this.benchmarkChanged.next(symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onChangeDateRange(dateRange: DateRange) {
|
|
||||||
this.dateRangeChanged.next(dateRange);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.chart?.destroy();
|
this.chart?.destroy();
|
||||||
}
|
}
|
||||||
@ -135,7 +129,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
tension: 0
|
tension: 0
|
||||||
},
|
},
|
||||||
point: {
|
point: {
|
||||||
hoverBackgroundColor: getBackgroundColor(),
|
hoverBackgroundColor: getBackgroundColor(this.colorScheme),
|
||||||
hoverRadius: 2,
|
hoverRadius: 2,
|
||||||
radius: 0
|
radius: 0
|
||||||
}
|
}
|
||||||
@ -146,7 +140,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
annotation: {
|
annotation: {
|
||||||
annotations: {
|
annotations: {
|
||||||
yAxis: {
|
yAxis: {
|
||||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
scaleID: 'y',
|
scaleID: 'y',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
@ -159,17 +153,19 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
},
|
},
|
||||||
tooltip: this.getTooltipPluginConfiguration(),
|
tooltip: this.getTooltipPluginConfiguration(),
|
||||||
verticalHoverLine: {
|
verticalHoverLine: {
|
||||||
color: `rgba(${getTextColor()}, 0.1)`
|
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
responsive: true,
|
responsive: true,
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
|
border: {
|
||||||
|
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||||
|
width: 1
|
||||||
|
},
|
||||||
display: true,
|
display: true,
|
||||||
grid: {
|
grid: {
|
||||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||||
borderWidth: 1,
|
|
||||||
color: `rgba(${getTextColor()}, 0.8)`,
|
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
type: 'time',
|
type: 'time',
|
||||||
@ -179,12 +175,14 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
|
border: {
|
||||||
|
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||||
|
display: false
|
||||||
|
},
|
||||||
display: true,
|
display: true,
|
||||||
grid: {
|
grid: {
|
||||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||||
color: `rgba(${getTextColor()}, 0.8)`,
|
display: false
|
||||||
display: false,
|
|
||||||
drawBorder: false
|
|
||||||
},
|
},
|
||||||
position: 'right',
|
position: 'right',
|
||||||
ticks: {
|
ticks: {
|
||||||
@ -198,7 +196,9 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
|
plugins: [
|
||||||
|
getVerticalHoverLinePlugin(this.chartCanvas, this.colorScheme)
|
||||||
|
],
|
||||||
type: 'line'
|
type: 'line'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -208,6 +208,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
private getTooltipPluginConfiguration() {
|
private getTooltipPluginConfiguration() {
|
||||||
return {
|
return {
|
||||||
...getTooltipOptions({
|
...getTooltipOptions({
|
||||||
|
colorScheme: this.colorScheme,
|
||||||
locale: this.locale,
|
locale: this.locale,
|
||||||
unit: '%'
|
unit: '%'
|
||||||
}),
|
}),
|
||||||
|
@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
|
import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
|
||||||
@ -13,7 +13,7 @@ import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
GfToggleModule,
|
GfPremiumIndicatorModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
NgxSkeletonLoaderModule,
|
NgxSkeletonLoaderModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
<mat-toolbar class="px-2">
|
<mat-toolbar class="px-2">
|
||||||
<ng-container *ngIf="user">
|
<ng-container *ngIf="user">
|
||||||
<a
|
<a
|
||||||
[routerLink]="['/']"
|
|
||||||
class="align-items-center d-flex h-100 no-min-width px-2 rounded-0"
|
class="align-items-center d-flex h-100 no-min-width px-2 rounded-0"
|
||||||
mat-button
|
mat-button
|
||||||
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo></gf-logo>
|
<gf-logo></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
@ -289,7 +289,7 @@
|
|||||||
<ng-container i18n>Sign in</ng-container>
|
<ng-container i18n>Sign in</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
|
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
|
||||||
class="d-none d-sm-block"
|
class="d-none d-sm-block"
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
|
@ -38,6 +38,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToAccessAdminControl: boolean;
|
public hasPermissionToAccessAdminControl: boolean;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
|
public hasPermissionToCreateUser: boolean;
|
||||||
public impersonationId: string;
|
public impersonationId: string;
|
||||||
public isMenuOpen: boolean;
|
public isMenuOpen: boolean;
|
||||||
|
|
||||||
@ -79,6 +80,11 @@ export class HeaderComponent implements OnChanges {
|
|||||||
this.info?.globalPermissions,
|
this.info?.globalPermissions,
|
||||||
permissions.enableFearAndGreedIndex
|
permissions.enableFearAndGreedIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToCreateUser = hasPermission(
|
||||||
|
this.info?.globalPermissions,
|
||||||
|
permissions.createUserAccount
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public impersonateAccount(aId: string) {
|
public impersonateAccount(aId: string) {
|
||||||
|
@ -127,6 +127,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
baseCurrency: this.user?.settings?.baseCurrency,
|
baseCurrency: this.user?.settings?.baseCurrency,
|
||||||
|
colorScheme: this.user?.settings?.colorScheme,
|
||||||
deviceType: this.deviceType,
|
deviceType: this.deviceType,
|
||||||
hasImpersonationId: this.hasImpersonationId,
|
hasImpersonationId: this.hasImpersonationId,
|
||||||
hasPermissionToReportDataGlitch: hasPermission(
|
hasPermissionToReportDataGlitch: hasPermission(
|
||||||
|
@ -24,7 +24,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
public fearLabel = $localize`Fear`;
|
public fearLabel = $localize`Fear`;
|
||||||
public greedLabel = $localize`Greed`;
|
public greedLabel = $localize`Greed`;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public historicalData: HistoricalDataItem[];
|
public historicalDataItems: HistoricalDataItem[];
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
public readonly numberOfDays = 180;
|
public readonly numberOfDays = 180;
|
||||||
@ -67,7 +67,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ historicalData, marketPrice }) => {
|
.subscribe(({ historicalData, marketPrice }) => {
|
||||||
this.fearAndGreedIndex = marketPrice;
|
this.fearAndGreedIndex = marketPrice;
|
||||||
this.historicalData = [
|
this.historicalDataItems = [
|
||||||
...historicalData,
|
...historicalData,
|
||||||
{
|
{
|
||||||
date: resetHours(new Date()).toISOString(),
|
date: resetHours(new Date()).toISOString(),
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user