Compare commits
272 Commits
Author | SHA1 | Date | |
---|---|---|---|
7d34fba7c1 | |||
c434b730a8 | |||
2d23c566f1 | |||
ba220eaee9 | |||
09023214ce | |||
1ceabb6e6b | |||
421072c7fa | |||
0d421e7181 | |||
f5180ce88f | |||
aabf27dc96 | |||
421809ae95 | |||
d3234f9e77 | |||
a40be2f744 | |||
e62da06c5c | |||
b7f635bdfc | |||
0a465f125d | |||
c02e390bc1 | |||
f9bec0d793 | |||
2f44748f79 | |||
97504756be | |||
6a802a62a0 | |||
51ca26bb4d | |||
2ecc8dbc4e | |||
c0e0e2401e | |||
1a30c180bc | |||
39d4f80f36 | |||
3693091ad6 | |||
bf52f1137d | |||
54ea6c84b4 | |||
689e50ae1a | |||
677757fdf0 | |||
58d9816f01 | |||
5f3d445f1d | |||
fce6caebc2 | |||
d0a4f5c000 | |||
b5e2a3aa91 | |||
f47883fb0b | |||
2932744a68 | |||
73c0f02e06 | |||
382fe24f29 | |||
908876ca6e | |||
99cf9f8802 | |||
7444ff97fc | |||
834a48466e | |||
a9526430c2 | |||
fce3b2084e | |||
f5a50a95de | |||
06dfb91f82 | |||
be36050d76 | |||
7931e6950d | |||
04eb452e04 | |||
6f7e370fca | |||
b4a126280f | |||
2d009aacc4 | |||
9116443305 | |||
0adaf12a01 | |||
b6562b6e2c | |||
b0a4b09ef5 | |||
ad8b9ad333 | |||
809956f210 | |||
6077bfa754 | |||
09498bd804 | |||
fd84f4ec14 | |||
c711a11d6e | |||
8232b05f62 | |||
0ea66aebcb | |||
64087de3fc | |||
7082ff12f8 | |||
1c7d92e15e | |||
a53461d257 | |||
d630fb900d | |||
51e8555fa5 | |||
9db675b955 | |||
45bd8ed029 | |||
707fd31550 | |||
6e5f0086a1 | |||
97bcd8ff49 | |||
1809fc8a80 | |||
beb24f9bd4 | |||
ae57a188f5 | |||
23db85e940 | |||
bd8bb1a36a | |||
c48670ccdc | |||
fc019002e2 | |||
4282cb66b8 | |||
1d0ba5fe4b | |||
24cfb26c5b | |||
26a70aa208 | |||
ab7e050066 | |||
26b1fd6572 | |||
d7e682b65a | |||
f589ccb775 | |||
206b6567fd | |||
6857e0314f | |||
c8682a7393 | |||
144b6b2211 | |||
16a5ace4be | |||
b24ddc30c9 | |||
19333ab084 | |||
7529a7a26c | |||
21ebaae6ef | |||
3bc8b3c836 | |||
bb9415cc15 | |||
b3baeb8a5d | |||
1f393e78f6 | |||
215f5eafa6 | |||
1916e5343d | |||
fa9863fc54 | |||
7bf48ef351 | |||
faef3606fd | |||
d0ccd4d238 | |||
51e3650790 | |||
db29e2b666 | |||
655a68a847 | |||
86296b3591 | |||
73c127f10c | |||
cf4c981cd9 | |||
1b9541b933 | |||
5bca8de44e | |||
136c4bf50b | |||
4d700e3b83 | |||
740fa6fc84 | |||
cdb8dc72c7 | |||
4b3afb5c97 | |||
abf208432a | |||
19e6df4fb2 | |||
7fc3fff431 | |||
edd690850c | |||
302339e1cd | |||
739796bc79 | |||
9c30139b86 | |||
0af528b649 | |||
9636c87a2e | |||
ad46fb6d61 | |||
8e000baef2 | |||
a2e1209196 | |||
ef4a75d1f0 | |||
3db20feb54 | |||
b9ec381ea2 | |||
7d6a74a67d | |||
b923cf7752 | |||
e32e457ff8 | |||
32c1e6b390 | |||
b42c0c8355 | |||
7140ed8512 | |||
27d9b075ce | |||
5249257dd8 | |||
606f6159c4 | |||
2e095603b5 | |||
3a99b81ade | |||
577a487301 | |||
086d43376c | |||
31a4c2ff1f | |||
6a1fad611c | |||
e1892d2870 | |||
8ba15f8f72 | |||
876b66f324 | |||
2c5bfb19d3 | |||
1bb94a04e3 | |||
e3c9316486 | |||
c19984c3d0 | |||
9002c20165 | |||
15c96a9757 | |||
1ca3792a4b | |||
90fe467114 | |||
e61b3b34a7 | |||
1326418ffc | |||
a5f0f48ddb | |||
e500ccb61b | |||
4090b03406 | |||
431d1d5fec | |||
d74d79198b | |||
623a284ba4 | |||
f79c36edbb | |||
f4c748f67a | |||
672d8dfab2 | |||
0464adccce | |||
c3df6c3194 | |||
29d53c7df4 | |||
7b77dc044a | |||
67e758365f | |||
475231ffd8 | |||
513a564e2c | |||
cddea0401f | |||
3dafbf7fef | |||
fcd75414be | |||
c1b5bfff8c | |||
3c322cca0d | |||
e965d12e31 | |||
3daf55a0dd | |||
aafedd5f75 | |||
32956ae04c | |||
bfd0241b2d | |||
5eff8402db | |||
ffa020ee2a | |||
80a3668aa9 | |||
7378900050 | |||
9be457943c | |||
93454c6c15 | |||
fccbd76993 | |||
922876a893 | |||
654446f068 | |||
947460abdd | |||
c5635b0050 | |||
8a3a6308a3 | |||
290a07fe79 | |||
4c907d56f0 | |||
56b437ca74 | |||
e23ff33e6f | |||
f2d206262e | |||
1ed5690b33 | |||
4451514ec5 | |||
8f73f85276 | |||
9a5d7b664b | |||
7d2d1d971a | |||
d111493eed | |||
e975f92a96 | |||
739cb4242d | |||
a37eebc9f1 | |||
e92730879e | |||
464973f9b0 | |||
f6228c099f | |||
a57fdfb2bb | |||
24716f0561 | |||
3453100afd | |||
84de2c0c68 | |||
1b7b082003 | |||
1928c2c2cc | |||
52e7a7886d | |||
36298b217e | |||
9bce57894e | |||
9d6bb325cd | |||
a5f833c612 | |||
732b14c6ab | |||
b74a042da8 | |||
d55c052f57 | |||
864f585efa | |||
6d56146054 | |||
2c9f29a3c6 | |||
9bef2e960c | |||
17b8c41673 | |||
f0afbd7346 | |||
5dc7429f6a | |||
7b39b32293 | |||
e5b5a9e7e9 | |||
1f3511368a | |||
b37df2c84f | |||
f92ba54060 | |||
a3bbd4030e | |||
4b30da2d92 | |||
93d082afbb | |||
0c85380dbf | |||
fb576376dc | |||
ff111d4c6c | |||
bc6e9a8b68 | |||
bd1963ec26 | |||
a0bec9e97f | |||
c45df20d88 | |||
fa1d669633 | |||
1009b462e9 | |||
b404858904 | |||
7ec033577f | |||
c8ca82b803 | |||
5db2faa17d | |||
1605fb8d48 | |||
b6a7804a26 | |||
de31381fd9 | |||
0d92b8d8bb | |||
7c6ff776d9 | |||
e37a34ed6c | |||
c4d9c00f92 | |||
3af8be89e3 |
@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
|||||||
|
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
ALPHA_VANTAGE_API_KEY=
|
ALPHA_VANTAGE_API_KEY=
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"root": true,
|
||||||
"ignorePatterns": ["**/*"],
|
"ignorePatterns": ["**/*"],
|
||||||
"plugins": ["@nrwl/nx"],
|
"plugins": ["@nx"],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@nrwl/nx/enforce-module-boundaries": [
|
"@nx/enforce-module-boundaries": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"enforceBuildableLibDependency": true,
|
"enforceBuildableLibDependency": true,
|
||||||
@ -23,12 +23,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx"],
|
"files": ["*.ts", "*.tsx"],
|
||||||
"extends": ["plugin:@nrwl/nx/typescript"],
|
"extends": ["plugin:@nx/typescript"],
|
||||||
"rules": {}
|
"rules": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.js", "*.jsx"],
|
"files": ["*.js", "*.jsx"],
|
||||||
"extends": ["plugin:@nrwl/nx/javascript"],
|
"extends": ["plugin:@nx/javascript"],
|
||||||
"rules": {}
|
"rules": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -113,5 +113,6 @@
|
|||||||
"radix": "error"
|
"radix": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"extends": [null, "plugin:storybook/recommended"]
|
||||||
}
|
}
|
||||||
|
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,7 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
**Bug Description**
|
**Bug Description**
|
||||||
|
|
||||||
@ -36,6 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
|||||||
|
|
||||||
<!-- Please complete the following information -->
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
|
- Cloud or Self-hosted
|
||||||
- Ghostfolio Version X.Y.Z
|
- Ghostfolio Version X.Y.Z
|
||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node_version:
|
node_version:
|
||||||
- 16
|
- 18
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
// uncomment the property below if you want to apply some webpack config globally
|
|
||||||
// webpackFinal: async (config, { configType }) => {
|
|
||||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
|
||||||
// // Return the altered config
|
|
||||||
// return config;
|
|
||||||
// },
|
|
||||||
};
|
|
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../tsconfig.base.json",
|
|
||||||
"exclude": [
|
|
||||||
"../**/*.spec.js",
|
|
||||||
"../**/*.spec.ts",
|
|
||||||
"../**/*.spec.tsx",
|
|
||||||
"../**/*.spec.jsx"
|
|
||||||
],
|
|
||||||
"include": ["../**/*"]
|
|
||||||
}
|
|
27
.vscode/launch.json
vendored
27
.vscode/launch.json
vendored
@ -2,32 +2,33 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Debug Jest File",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
|
||||||
"args": [
|
"args": [
|
||||||
"test",
|
"test",
|
||||||
"--codeCoverage=false",
|
"--codeCoverage=false",
|
||||||
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
|
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
|
||||||
],
|
],
|
||||||
|
"console": "internalConsole",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"console": "internalConsole"
|
"name": "Debug Jest",
|
||||||
|
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"envFile": "${workspaceFolder}/.env",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Launch Program",
|
|
||||||
"program": "${workspaceFolder}/apps/api/src/main.ts",
|
|
||||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
|
||||||
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
|
|
||||||
"autoAttachChildProcesses": true,
|
"autoAttachChildProcesses": true,
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"cwd": "${workspaceFolder}/apps/api",
|
||||||
|
"envFile": "${workspaceFolder}/.env",
|
||||||
|
"name": "Debug API",
|
||||||
|
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
|
||||||
|
"program": "${workspaceFolder}/apps/api/src/main.ts",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"${workspaceFolder}/node_modules/**/*.js",
|
"${workspaceFolder}/node_modules/**/*.js",
|
||||||
"<node_internals>/**/*.js"
|
"<node_internals>/**/*.js"
|
||||||
],
|
],
|
||||||
"console": "integratedTerminal"
|
"type": "node"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
578
CHANGELOG.md
578
CHANGELOG.md
@ -5,6 +5,579 @@ 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.287.0 - 2023-07-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hid the average buy price in the position detail chart if there is no holding
|
||||||
|
- Improved the language localization for French (`fr`)
|
||||||
|
- Refactored the blog articles to standalone components
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the sorting by currency in the activities table
|
||||||
|
|
||||||
|
## 1.286.0 - 2023-07-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the creation of (wealth) items and liabilities
|
||||||
|
|
||||||
|
## 1.285.0 - 2023-07-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Exploring the Path to Financial Independence and Retiring Early (FIRE)_
|
||||||
|
- Added pagination to the historical market data table of the admin control panel
|
||||||
|
- Added the attribute `headers` to the scraper configuration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the asset profile details dialog in the admin control panel by the scraper configuration
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.284.0 - 2023-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the currency to the cash balance in the create or update account dialog
|
||||||
|
- Added the ability to add an index for benchmarks as an asset profile in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded the _Internet Identity_ dependencies from version `0.15.1` to `0.15.7`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the clone functionality of a transaction caused by the symbol search component
|
||||||
|
|
||||||
|
## 1.283.5 - 2023-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the caching for current market prices
|
||||||
|
- Added a loading indicator to the import dividends dialog
|
||||||
|
- Set up the `helmet` middleware to protect the app from web vulnerabilities by setting HTTP headers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the selected item of the holding selector in the import dividends dialog
|
||||||
|
- Extended the symbol search component by asset sub classes
|
||||||
|
|
||||||
|
## 1.282.0 - 2023-06-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an icon to the external links in the footer navigation
|
||||||
|
- Added the ability to add an asset profile in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the use of permissions on the about page
|
||||||
|
- Harmonized the use of permissions on the landing page
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Improved the language localization for Portuguese (`pt`)
|
||||||
|
- Updated the binary targets of `linux-arm64-openssl` for `prisma`
|
||||||
|
|
||||||
|
## 1.281.0 - 2023-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the feature overview page by liabilities
|
||||||
|
- Set up the language localization for Portuguese (`pt`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extracted the symbol search to a dedicated component
|
||||||
|
- Improved the column headers in the holdings table for mobile
|
||||||
|
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
|
||||||
|
|
||||||
|
## 1.280.1 - 2023-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for liabilities
|
||||||
|
|
||||||
|
## 1.279.0 - 2023-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported a note for accounts
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for French (`fr`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the value nullification related to the investment streaks
|
||||||
|
- Fixed an issue in the public page related to the impersonation service
|
||||||
|
|
||||||
|
## 1.278.0 - 2023-06-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the clone functionality of a transaction by the quantity
|
||||||
|
- Changed the direction of the ellipsis icon in various tables
|
||||||
|
- Extracted the license to a dedicated tab on the about page
|
||||||
|
- Displayed the link to the markets overview in the footer based on a permission
|
||||||
|
- Improved the spacing in the benchmark comparator
|
||||||
|
- Refreshed the cryptocurrencies list
|
||||||
|
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
|
||||||
|
|
||||||
|
## 1.277.0 - 2023-06-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the investment streaks to the analysis page
|
||||||
|
- Added support for a unit in the value component
|
||||||
|
- Added a semantic list structure to the header navigation
|
||||||
|
- Added a default value for the `includeHistoricalData` attribute in the symbol data endpoint
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the date format parsing in the activities import
|
||||||
|
|
||||||
|
## 1.276.0 - 2023-06-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added tabs to the about page
|
||||||
|
- Added the `changefreq` attribute to the sitemap
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the routes of the tabs
|
||||||
|
- Enforced a stricter date format in the activities import: `dd-MM-yyyy` instead of `dd-MM-yy`
|
||||||
|
- Updated the URL of the Ghostfolio Slack channel
|
||||||
|
- Removed the _Ghostfolio in Numbers_ section from the about page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the price when creating a `Subscription`
|
||||||
|
|
||||||
|
## 1.275.0 - 2023-05-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the footer navigation by the localized Ghostfolio versions
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the exchange rate service for a specific date (indirect calculation via base currency) used in activities with a manual currency
|
||||||
|
|
||||||
|
## 1.274.0 - 2023-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the footer by a navigation
|
||||||
|
- Extended the testimonial section on the landing page
|
||||||
|
- Added localized meta descriptions
|
||||||
|
- Added support for localized routes in Spanish (`es`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the activities import dialog
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.273.0 - 2023-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a stepper to the activities import dialog
|
||||||
|
- Added a link to manage the benchmarks to the benchmark comparator
|
||||||
|
- Added support for localized routes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the data source transformation
|
||||||
|
|
||||||
|
## 1.272.0 - 2023-05-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to set an asset profile as a benchmark
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Decreased the density of the `@angular/material` tables
|
||||||
|
- Improved the portfolio proportion chart component by supporting case insensitive names
|
||||||
|
- Improved the breadcrumb navigation style in the blog post pages for mobile
|
||||||
|
- Improved the error handling in the delete user endpoint
|
||||||
|
- Improved the style of the _Changelog & License_ button on the about page
|
||||||
|
- Upgraded `ionicons` from version `6.1.2` to `7.1.0`
|
||||||
|
|
||||||
|
## 1.271.0 - 2023-05-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the historical data and search functionality for the `FINANCIAL_MODELING_PREP` data source type
|
||||||
|
- Added a blog post: _Unlock your Financial Potential with Ghostfolio_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the local number formatting in the value component
|
||||||
|
- Changed the uptime to the last 90 days on the _Open Startup_ (`/open`) page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the vertical alignment in the toggle component
|
||||||
|
|
||||||
|
## 1.270.1 - 2023-05-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the cash balance and the value of equity to the account detail dialog
|
||||||
|
- Added a check for duplicates to the preview step of the import dividends dialog
|
||||||
|
- Added an error message for duplicates to the preview step of the activities import
|
||||||
|
- Added a connection timeout to the environment variable `DATABASE_URL`
|
||||||
|
- Introduced the _Open Startup_ (`/open`) page with aggregated key metrics including uptime
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the mobile layout of the portfolio summary tab on the home page
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `prisma` from version `4.13.0` to `4.14.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the _Select all_ activities checkbox state after importing activities including a duplicate
|
||||||
|
- Fixed an issue with the data source transformation in the import dividends dialog
|
||||||
|
- Fixed the _Storybook_ setup
|
||||||
|
|
||||||
|
## 1.269.0 - 2023-05-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `FINANCIAL_MODELING_PREP` as a new data source type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the market price on the first buy date in the chart of the position detail dialog
|
||||||
|
- Restructured the admin control panel with a new settings tab
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an error that occurred while editing an activity caused by the cash balance update
|
||||||
|
|
||||||
|
## 1.268.0 - 2023-05-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `depends_on` and `healthcheck` for the _Postgres_ and _Redis_ services to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the preview step of the activities import by unchecking duplicates
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.3.10` to `2.4.1`
|
||||||
|
|
||||||
|
## 1.267.0 - 2023-05-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the _Stripe_ checkout to the pricing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the management of platforms in the admin control panel
|
||||||
|
- Improved the style of the interstitial for the subscription
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `Nx` from version `15.9.2` to `16.0.3`
|
||||||
|
|
||||||
|
## 1.266.0 - 2023-05-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced the option to update the cash balance of an account when adding an activity
|
||||||
|
- Added support for the management of platforms in the admin control panel
|
||||||
|
- Added _DEV Community_ to the _As seen in_ section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `class-transformer` from version `0.3.2` to `0.5.1`
|
||||||
|
- Upgraded `class-validator` from version `0.13.1` to `0.14.0`
|
||||||
|
- Upgraded `prisma` from version `4.12.0` to `4.13.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added a fallback to use `quoteSummary(symbol)` if `quote(symbols)` fails in the _Yahoo Finance_ service
|
||||||
|
- Added the missing `dataSource` attribute to the activities import
|
||||||
|
|
||||||
|
## 1.265.0 - 2023-05-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the tooltip of the portfolio proportion chart component
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the missing platform name in the allocations by platform chart on the allocations page
|
||||||
|
|
||||||
|
## 1.264.0 - 2023-05-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced the allocations by platform chart on the allocations page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Deprecated the use of the environment variable `BASE_CURRENCY`
|
||||||
|
- Cleaned up initial values from the _X-ray_ section
|
||||||
|
|
||||||
|
## 1.263.0 - 2023-04-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Split the environment variable `DATA_SOURCE_PRIMARY` in `DATA_SOURCE_EXCHANGE_RATES` and `DATA_SOURCE_IMPORT`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the exception on the accounts page
|
||||||
|
|
||||||
|
## 1.262.0 - 2023-04-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the labels to the tabs to increase the usability
|
||||||
|
- Extended the support of the impersonation mode for local development
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the queue jobs implementation by adding / updating historical market data in bulk
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the holdings table by showing the cash position also when the filter contains the accounts, so that we can see the total allocation for that account
|
||||||
|
|
||||||
|
## 1.261.0 - 2023-04-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced a new button to delete all activities from the portfolio activities page
|
||||||
|
- Added `state` to the `MarketData` database schema to distinguish `CLOSE` and `INTRADAY` in the data gathering
|
||||||
|
- Added the distance to now to the subscription expiration date in the users table of the admin control panel
|
||||||
|
|
||||||
|
## 1.260.0 - 2023-04-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `dataSource` as a unique constraint to the `MarketData` database schema
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Removed the unnecessary sort header of the comment column in the historical market data table of the admin control panel
|
||||||
|
|
||||||
|
## 1.259.0 - 2023-04-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a fallback to historical market data if a data provider does not provide live data
|
||||||
|
- Added a general health check endpoint
|
||||||
|
- Added health check endpoints for data providers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Persisted today's market data continuously
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the alignment of the performance column header in the holdings table
|
||||||
|
- Removed the unnecessary sort header of the comment column in the activities table
|
||||||
|
- Fixed the targets in `proxy.conf.json` from `http://localhost:3333` to `http://0.0.0.0:3333` for local development
|
||||||
|
|
||||||
|
## 1.258.0 - 2023-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced a data source mapping
|
||||||
|
|
||||||
|
## 1.257.0 - 2023-04-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced the allocations by ETF provider chart on the allocations page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the global heat map component caused by manipulating an input property
|
||||||
|
- Fixed an issue with the currency inconsistency in the _EOD Historical Data_ service (convert from `GBX` to `GBp`)
|
||||||
|
|
||||||
|
## 1.256.0 - 2023-04-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the _Yahoo Finance_ data enhancer for countries, sectors and urls
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Enabled the configuration to immediately remove queue jobs on complete
|
||||||
|
- Refactored the implementation of removing queue jobs
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the unique job ids of the gather asset profile process
|
||||||
|
- Fixed the style of the button to fetch the current market price
|
||||||
|
|
||||||
|
## 1.255.0 - 2023-04-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Made the system message expandable
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Skipped creating queue jobs for asset profiles with `MANUAL` data source not having a scraper configuration
|
||||||
|
- Reduced the execution interval of the data gathering to every hour
|
||||||
|
- Upgraded `prisma` from version `4.11.0` to `4.12.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the style of the system message
|
||||||
|
|
||||||
|
## 1.254.0 - 2023-04-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the queue jobs implementation by adding in bulk
|
||||||
|
- Improved the queue jobs implementation by introducing unique job ids
|
||||||
|
- Reverted the execution interval of the data gathering from every 12 hours to every 4 hours
|
||||||
|
|
||||||
|
## 1.253.0 - 2023-04-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reduced the execution interval of the data gathering to every 12 hours
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the background color of dialogs in dark mode
|
||||||
|
|
||||||
|
## 1.252.2 - 2023-04-11
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Deprecated the `auth` endpoint of the login with _Security Token_ (`GET`)
|
||||||
|
|
||||||
|
## 1.252.1 - 2023-04-10
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the slide toggles to checkboxes on the account page
|
||||||
|
- Changed the slide toggles to checkboxes in the admin control panel
|
||||||
|
- Increased the density of the theme
|
||||||
|
- Migrated the style of various components to `@angular/material` `15` (mdc)
|
||||||
|
- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6`
|
||||||
|
- Upgraded `bull` from version `4.10.2` to `4.10.4`
|
||||||
|
|
||||||
|
## 1.251.0 - 2023-04-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the activities import for `csv` files exported by _Interactive Brokers_
|
||||||
|
- Improved the rendering of the chart ticks (`0.5K` → `500`)
|
||||||
|
- Increased the historical market data gathering of currency pairs to 10+ years
|
||||||
|
- Improved the content of the Frequently Asked Questions (FAQ) page
|
||||||
|
- Improved the content of the pricing page
|
||||||
|
- Changed the `auth` endpoint of the login with _Security Token_ from `GET` to `POST`
|
||||||
|
- Changed the `auth` endpoint of the _Internet Identity_ login provider from `GET` to `POST`
|
||||||
|
- Migrated the style of the `libs` components to `@angular/material` `15` (mdc)
|
||||||
|
- `ActivitiesFilterComponent`
|
||||||
|
- `ActivitiesTableComponent`
|
||||||
|
- `BenchmarkComponent`
|
||||||
|
- `HoldingsTableComponent`
|
||||||
|
- Upgraded `angular` from version `15.1.5` to `15.2.5`
|
||||||
|
- Upgraded `Nx` from version `15.7.2` to `15.9.2`
|
||||||
|
|
||||||
|
## 1.250.0 - 2023-04-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for multiple subscription offers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the portfolio evolution chart (ignore first item)
|
||||||
|
- Improved the accounts import by handling the platform
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with more than 50 activities in the activities import (`dryRun`)
|
||||||
|
|
||||||
|
## 1.249.0 - 2023-03-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the testimonial section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the loading state of the value component on the allocations page
|
||||||
|
- Improved the value component by always showing the label (also while loading)
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the algebraic sign in the value component
|
||||||
|
|
||||||
|
## 1.248.0 - 2023-03-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Ghostfolio reaches 1’000 Stars on GitHub_
|
||||||
|
- Added a breadcrumb navigation to the blog post pages
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the calculation of the chart
|
||||||
|
- Hid the platform selector if no platforms are available in the create or update account dialog
|
||||||
|
- Upgraded `ng-extract-i18n-merge` from version `2.5.0` to `2.6.0`
|
||||||
|
|
||||||
|
## 1.247.0 - 2023-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the asset and asset sub class to the search functionality
|
||||||
|
- Added the subscription expiration date to the users table of the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated the URL of the Ghostfolio Slack channel
|
||||||
|
- Upgraded `prisma` from version `4.10.1` to `4.11.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the total amount calculation in the portfolio evolution chart
|
||||||
|
|
||||||
|
## 1.246.0 - 2023-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for asset and asset sub class to the `EOD_HISTORICAL_DATA` data source type
|
||||||
|
- Added `isin` to the asset profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the _Trackinsight_ data enhancer for asset profile data by `isin`
|
||||||
|
- Improved the language localization for _Gather Data_
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the border color in the _FIRE_ calculator (dark mode)
|
||||||
|
|
||||||
|
## 1.245.0 - 2023-03-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the search functionality for the `EOD_HISTORICAL_DATA` data source type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the _FIRE_ calculator
|
||||||
|
- Improved the exchange rate service for a specific date used in activities with a manual currency
|
||||||
|
- Upgraded `ngx-device-detector` from version `3.0.0` to `5.0.1`
|
||||||
|
|
||||||
## 1.244.0 - 2023-03-09
|
## 1.244.0 - 2023-03-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -166,7 +739,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added support to export accounts
|
- Added support to export accounts
|
||||||
- Added suport to import accounts
|
- Added support to import accounts
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -313,7 +886,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Added support for the dividend timeline grouped by year
|
- Added support for the dividend timeline grouped by year
|
||||||
- Added support for the investment timeline grouped by year
|
- Added support for the investment timeline grouped by year
|
||||||
- Set up the language localization for Français (`fr`)
|
- Set up the language localization for Français (`fr`)
|
||||||
- Set up the language localization for Português (`pt`)
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -593,7 +1165,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Added support to change the appearance (dark mode) in user settings
|
- Added support to change the appearance (dark mode) in user settings
|
||||||
- Added the total amount chart to the investment timeline
|
- Added the total amount chart to the investment timeline
|
||||||
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
|
- Set up the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
@ -18,7 +18,13 @@
|
|||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
#### Create schema migration (local)
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
|
Run `yarn database:push`
|
||||||
|
|
||||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||||
|
|
||||||
|
#### Create schema migration
|
||||||
|
|
||||||
Run `yarn prisma migrate dev --name added_job_title`
|
Run `yarn prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
|
|||||||
RUN yarn database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:16-slim
|
FROM node:18-slim
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
11
README.md
11
README.md
@ -145,7 +145,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- Create a local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
@ -200,7 +200,9 @@ Set the header for each request as follows:
|
|||||||
"Authorization": "Bearer eyJh..."
|
"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>`.
|
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
||||||
|
|
||||||
|
Deprecated: `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
|
||||||
|
|
||||||
@ -230,6 +232,7 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------- | ------------------- | -------------------------------------------------- |
|
| ---------- | ------------------- | -------------------------------------------------- |
|
||||||
| accountId | string (`optional`) | Id of the account |
|
| accountId | string (`optional`) | Id of the account |
|
||||||
|
| comment | string (`optional`) | Comment of the activity |
|
||||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
| date | string | Date in the format `ISO-8601` |
|
| date | string | Date in the format `ISO-8601` |
|
||||||
@ -266,12 +269,12 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
|||||||
|
|
||||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
|
|
||||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2023 [Ghostfolio](https://ghostfol.io)
|
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
@ -2,13 +2,14 @@
|
|||||||
export default {
|
export default {
|
||||||
displayName: 'api',
|
displayName: 'api',
|
||||||
|
|
||||||
globals: {
|
globals: {},
|
||||||
'ts-jest': {
|
|
||||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.[tj]s$': 'ts-jest'
|
'^.+\\.[tj]s$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
"outputs": ["{options.outputPath}"]
|
"outputs": ["{options.outputPath}"]
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nrwl/node:node",
|
"executor": "@nx/node:node",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "api:build"
|
"buildTarget": "api:build"
|
||||||
}
|
}
|
||||||
@ -45,7 +45,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"executor": "@nrwl/jest:jest",
|
"executor": "@nx/jest:jest",
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "apps/api/jest.config.ts",
|
"jestConfig": "apps/api/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccessController } from './access.controller';
|
import { AccessController } from './access.controller';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Access, Prisma } from '@prisma/client';
|
import { Access, Prisma } from '@prisma/client';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -87,10 +87,7 @@ export class AccountController {
|
|||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||||
): Promise<Accounts> {
|
): Promise<Accounts> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
impersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.portfolioService.getAccountsWithAggregations({
|
return this.portfolioService.getAccountsWithAggregations({
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
@ -106,10 +103,7 @@ export class AccountController {
|
|||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountWithValue> {
|
): Promise<AccountWithValue> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
impersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const accountsWithAggregations =
|
const accountsWithAggregations =
|
||||||
await this.portfolioService.getAccountsWithAggregations({
|
await this.portfolioService.getAccountsWithAggregations({
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccountController } from './account.controller';
|
import { AccountController } from './account.controller';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
@ -172,4 +172,47 @@ export class AccountService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateAccountBalance({
|
||||||
|
accountId,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
date,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
accountId: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
date: Date;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
const { balance, currency: currencyOfAccount } = await this.account({
|
||||||
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const amountInCurrencyOfAccount =
|
||||||
|
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
currencyOfAccount,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
if (amountInCurrencyOfAccount) {
|
||||||
|
await this.prismaService.account.update({
|
||||||
|
data: {
|
||||||
|
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -6,6 +7,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
ValidateIf
|
ValidateIf
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -14,6 +16,13 @@ export class CreateAccountDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -6,6 +7,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
ValidateIf
|
ValidateIf
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -14,6 +16,13 @@ export class UpdateAccountDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
@ -26,11 +28,12 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import { isDate } from 'date-fns';
|
import { isDate } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@ -100,16 +103,21 @@ export class AdminController {
|
|||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
await this.dataGatheringService.addJobToQueue(
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
return {
|
||||||
{
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
}
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
@ -131,16 +139,21 @@ export class AdminController {
|
|||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
await this.dataGatheringService.addJobToQueue(
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
return {
|
||||||
{
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
}
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@ -161,14 +174,17 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
await this.dataGatheringService.addJobToQueue({
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
data: {
|
||||||
{
|
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}`
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@ -232,7 +248,11 @@ export class AdminController {
|
|||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
|
@Query('skip') skip?: number,
|
||||||
|
@Query('sortColumn') sortColumn?: string,
|
||||||
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
|
@Query('take') take?: number
|
||||||
): Promise<AdminMarketData> {
|
): Promise<AdminMarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
@ -257,7 +277,13 @@ export class AdminController {
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
return this.adminService.getMarketData(filters);
|
return this.adminService.getMarketData({
|
||||||
|
filters,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
|
take: isNaN(take) ? undefined : take
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@ -304,9 +330,10 @@ export class AdminController {
|
|||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
return this.marketDataService.updateMarketData({
|
return this.marketDataService.updateMarketData({
|
||||||
data: { ...data, dataSource },
|
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
||||||
where: {
|
where: {
|
||||||
date_symbol: {
|
dataSource_date_symbol: {
|
||||||
|
dataSource,
|
||||||
date,
|
date,
|
||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
@ -314,6 +341,28 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async addProfileData(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<SymbolProfile | never> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteProfileData(
|
public async deleteProfileData(
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
|
@ -1,21 +1,24 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
PROPERTY_CURRENCIES
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
|
||||||
Filter,
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@ -25,6 +28,7 @@ export class AdminService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
@ -35,6 +39,38 @@ export class AdminService {
|
|||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async addAssetProfile({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<SymbolProfile | never> {
|
||||||
|
try {
|
||||||
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfiles[symbol]?.currency) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile not found for ${symbol} (${dataSource})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.symbolProfileService.add(
|
||||||
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile of ${symbol} (${dataSource}) already exists`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||||
@ -65,7 +101,21 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
public async getMarketData({
|
||||||
|
filters,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
skip,
|
||||||
|
take = DEFAULT_PAGE_SIZE
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
skip?: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
take?: number;
|
||||||
|
}): Promise<AdminMarketData> {
|
||||||
|
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
|
||||||
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
@ -75,41 +125,33 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.groupBy({
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
|
||||||
|
|
||||||
if (filtersByAssetSubClass) {
|
if (filtersByAssetSubClass) {
|
||||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
} else {
|
|
||||||
currencyPairsToGather = this.exchangeRateDataService
|
|
||||||
.getCurrencyPairs()
|
|
||||||
.map(({ dataSource, symbol }) => {
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketData.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === dataSource &&
|
|
||||||
marketDataItem.symbol === symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
marketDataItemCount,
|
|
||||||
symbol,
|
|
||||||
countriesCount: 0,
|
|
||||||
sectorsCount: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
if (sortColumn) {
|
||||||
await this.prismaService.symbolProfile.findMany({
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
|
||||||
|
if (sortColumn === 'activitiesCount') {
|
||||||
|
orderBy = {
|
||||||
|
Order: {
|
||||||
|
_count: sortDirection
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [assetProfiles, count] = await Promise.all([
|
||||||
|
this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
where,
|
where,
|
||||||
orderBy: [{ symbol: 'asc' }],
|
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Order: true }
|
select: { Order: true }
|
||||||
@ -128,38 +170,48 @@ export class AdminService {
|
|||||||
sectors: true,
|
sectors: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
).map((symbolProfile) => {
|
this.prismaService.symbolProfile.count({ where })
|
||||||
const countriesCount = symbolProfile.countries
|
]);
|
||||||
? Object.keys(symbolProfile.countries).length
|
|
||||||
: 0;
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketData.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
|
||||||
marketDataItem.symbol === symbolProfile.symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
const sectorsCount = symbolProfile.sectors
|
|
||||||
? Object.keys(symbolProfile.sectors).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
countriesCount,
|
|
||||||
marketDataItemCount,
|
|
||||||
sectorsCount,
|
|
||||||
activitiesCount: symbolProfile._count.Order,
|
|
||||||
assetClass: symbolProfile.assetClass,
|
|
||||||
assetSubClass: symbolProfile.assetSubClass,
|
|
||||||
comment: symbolProfile.comment,
|
|
||||||
dataSource: symbolProfile.dataSource,
|
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
|
||||||
symbol: symbolProfile.symbol
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
count,
|
||||||
|
marketData: assetProfiles.map(
|
||||||
|
({
|
||||||
|
_count,
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
comment,
|
||||||
|
countries,
|
||||||
|
dataSource,
|
||||||
|
Order,
|
||||||
|
sectors,
|
||||||
|
symbol
|
||||||
|
}) => {
|
||||||
|
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketDataItems.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
comment,
|
||||||
|
countriesCount,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
marketDataItemCount,
|
||||||
|
sectorsCount,
|
||||||
|
activitiesCount: _count.Order,
|
||||||
|
date: Order?.[0]?.date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,20 +238,25 @@ export class AdminService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assetProfile,
|
marketData,
|
||||||
marketData
|
assetProfile: assetProfile ?? {
|
||||||
|
symbol,
|
||||||
|
currency: '-'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async patchAssetProfileData({
|
public async patchAssetProfileData({
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
await this.symbolProfileService.updateSymbolProfile({
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { QueueController } from './queue.controller';
|
import { QueueController } from './queue.controller';
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JobStatus, Queue } from 'bull';
|
import { JobStatus, Queue } from 'bull';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -23,14 +23,11 @@ export class QueueService {
|
|||||||
}: {
|
}: {
|
||||||
status?: JobStatus[];
|
status?: JobStatus[];
|
||||||
}) {
|
}) {
|
||||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
for (const statusItem of status) {
|
||||||
|
await this.dataGatheringQueue.clean(
|
||||||
for (const job of jobs) {
|
300,
|
||||||
try {
|
statusItem === 'waiting' ? 'wait' : statusItem
|
||||||
await job.remove();
|
);
|
||||||
} catch (error) {
|
|
||||||
Logger.warn(error, 'QueueService');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,18 +41,23 @@ export class QueueService {
|
|||||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
const jobsWithState = await Promise.all(
|
const jobsWithState = await Promise.all(
|
||||||
jobs.slice(0, limit).map(async (job) => {
|
jobs
|
||||||
return {
|
.filter((job) => {
|
||||||
attemptsMade: job.attemptsMade + 1,
|
return job;
|
||||||
data: job.data,
|
})
|
||||||
finishedOn: job.finishedOn,
|
.slice(0, limit)
|
||||||
id: job.id,
|
.map(async (job) => {
|
||||||
name: job.name,
|
return {
|
||||||
stacktrace: job.stacktrace,
|
attemptsMade: job.attemptsMade + 1,
|
||||||
state: await job.getState(),
|
data: job.data,
|
||||||
timestamp: job.timestamp
|
finishedOn: job.finishedOn,
|
||||||
};
|
id: job.id,
|
||||||
})
|
name: job.name,
|
||||||
|
stacktrace: job.stacktrace,
|
||||||
|
state: await job.getState(),
|
||||||
|
timestamp: job.timestamp
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
@ -5,6 +6,10 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
scraperConfiguration?: Prisma.InputJsonObject;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
symbolMapping?: {
|
symbolMapping?: {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
|
||||||
import { ConfigurationModule } from '../services/configuration.module';
|
|
||||||
import { CronService } from '../services/cron.service';
|
|
||||||
import { DataGatheringModule } from '../services/data-gathering.module';
|
|
||||||
import { DataProviderModule } from '../services/data-provider/data-provider.module';
|
|
||||||
import { ExchangeRateDataModule } from '../services/exchange-rate-data.module';
|
|
||||||
import { PrismaModule } from '../services/prisma.module';
|
|
||||||
import { TwitterBotModule } from '../services/twitter-bot/twitter-bot.module';
|
|
||||||
import { AccessModule } from './access/access.module';
|
import { AccessModule } from './access/access.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
@ -24,10 +24,12 @@ import { CacheModule } from './cache/cache.module';
|
|||||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { FrontendMiddleware } from './frontend.middleware';
|
import { FrontendMiddleware } from './frontend.middleware';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
import { LogoModule } from './logo/logo.module';
|
import { LogoModule } from './logo/logo.module';
|
||||||
import { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
|
import { PlatformModule } from './platform/platform.module';
|
||||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||||
import { SubscriptionModule } from './subscription/subscription.module';
|
import { SubscriptionModule } from './subscription/subscription.module';
|
||||||
@ -57,10 +59,12 @@ import { UserModule } from './user/user.module';
|
|||||||
ExchangeRateModule,
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
|
HealthModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
LogoModule,
|
LogoModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PlatformModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AuthDevice, Prisma } from '@prisma/client';
|
import { AuthDevice, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
@ -33,8 +33,11 @@ export class AuthController {
|
|||||||
private readonly webAuthService: WebAuthService
|
private readonly webAuthService: WebAuthService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Get('anonymous/:accessToken')
|
@Get('anonymous/:accessToken')
|
||||||
public async accessTokenLogin(
|
public async accessTokenLoginGet(
|
||||||
@Param('accessToken') accessToken: string
|
@Param('accessToken') accessToken: string
|
||||||
): Promise<OAuthResponse> {
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
@ -50,6 +53,23 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('anonymous')
|
||||||
|
public async accessTokenLogin(
|
||||||
|
@Body() body: { accessToken: string }
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
|
try {
|
||||||
|
const authToken = await this.authService.validateAnonymousLogin(
|
||||||
|
body.accessToken
|
||||||
|
);
|
||||||
|
return { authToken };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get('google')
|
@Get('google')
|
||||||
@UseGuards(AuthGuard('google'))
|
@UseGuards(AuthGuard('google'))
|
||||||
public googleLogin() {
|
public googleLogin() {
|
||||||
@ -81,13 +101,13 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('internet-identity/:principalId')
|
@Post('internet-identity')
|
||||||
public async internetIdentityLogin(
|
public async internetIdentityLogin(
|
||||||
@Param('principalId') principalId: string
|
@Body() body: { principalId: string }
|
||||||
): Promise<OAuthResponse> {
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateInternetIdentityLogin(
|
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||||
principalId
|
body.principalId
|
||||||
);
|
);
|
||||||
return { authToken };
|
return { authToken };
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -2,8 +2,8 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
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/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
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 { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
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/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.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';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
@ -1,6 +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/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
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/configuration.service';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
|
@ -1,24 +1,36 @@
|
|||||||
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 {
|
import type {
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkResponse
|
BenchmarkResponse,
|
||||||
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { BenchmarkService } from './benchmark.service';
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
@Controller('benchmark')
|
@Controller('benchmark')
|
||||||
export class BenchmarkController {
|
export class BenchmarkController {
|
||||||
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@ -45,4 +57,41 @@ export class BenchmarkController {
|
|||||||
symbol
|
symbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const benchmark = await this.benchmarkService.addBenchmark({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!benchmark) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmark;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { BenchmarkController } from './benchmark.controller';
|
import { BenchmarkController } from './benchmark.controller';
|
||||||
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
|
@ -4,7 +4,15 @@ describe('BenchmarkService', () => {
|
|||||||
let benchmarkService: BenchmarkService;
|
let benchmarkService: BenchmarkService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
|
benchmarkService = new BenchmarkService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculateChangeInPercentage', async () => {
|
it('calculateChangeInPercentage', async () => {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
MAX_CHART_ITEMS,
|
MAX_CHART_ITEMS,
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
|
BenchmarkProperty,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -18,6 +20,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -27,6 +30,7 @@ export class BenchmarkService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
private readonly symbolProfileService: SymbolProfileService,
|
private readonly symbolProfileService: SymbolProfileService,
|
||||||
@ -116,9 +120,9 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||||
const symbolProfileIds: string[] = (
|
const symbolProfileIds: string[] = (
|
||||||
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
((await this.propertyService.getByKey(
|
||||||
symbolProfileId: string;
|
PROPERTY_BENCHMARKS
|
||||||
}[]) ?? []
|
)) as BenchmarkProperty[]) ?? []
|
||||||
).map(({ symbolProfileId }) => {
|
).map(({ symbolProfileId }) => {
|
||||||
return symbolProfileId;
|
return symbolProfileId;
|
||||||
});
|
});
|
||||||
@ -204,6 +208,43 @@ export class BenchmarkService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async addBenchmark({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||||
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let benchmarks =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
)) as BenchmarkProperty[]) ?? [];
|
||||||
|
|
||||||
|
benchmarks.push({ symbolProfileId: assetProfile.id });
|
||||||
|
|
||||||
|
benchmarks = uniqBy(benchmarks, 'symbolProfileId');
|
||||||
|
|
||||||
|
await this.propertyService.put({
|
||||||
|
key: PROPERTY_BENCHMARKS,
|
||||||
|
value: JSON.stringify(benchmarks)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
id: assetProfile.id,
|
||||||
|
name: assetProfile.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private getMarketCondition(aPerformanceInPercent: number) {
|
private getMarketCondition(aPerformanceInPercent: number) {
|
||||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||||
}
|
}
|
||||||
|
10
apps/api/src/app/cache/cache.module.ts
vendored
10
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,10 +1,10 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExchangeRateController } from './exchange-rate.controller';
|
import { ExchangeRateController } from './exchange-rate.controller';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController } from './export.controller';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@ -21,6 +21,7 @@ export class ExportService {
|
|||||||
select: {
|
select: {
|
||||||
accountType: true,
|
accountType: true,
|
||||||
balance: true,
|
balance: true,
|
||||||
|
comment: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
id: true,
|
id: true,
|
||||||
isExcluded: true,
|
isExcluded: true,
|
||||||
|
@ -2,7 +2,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
@ -19,6 +19,9 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
public indexHtmlNl = '';
|
public indexHtmlNl = '';
|
||||||
public indexHtmlPt = '';
|
public indexHtmlPt = '';
|
||||||
|
|
||||||
|
private static readonly DEFAULT_DESCRIPTION =
|
||||||
|
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
) {
|
) {
|
||||||
@ -87,6 +90,25 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
) {
|
) {
|
||||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
||||||
title = `Ghostfolio meets Umbrel - ${title}`;
|
title = `Ghostfolio meets Umbrel - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
|
||||||
|
title = `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/20230520.jpg';
|
||||||
|
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/20230701.jpg';
|
||||||
|
title = `Exploring the Path to FIRE - ${title}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -102,6 +124,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
currentDate,
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
title,
|
title,
|
||||||
|
description:
|
||||||
|
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
|
||||||
languageCode: 'de',
|
languageCode: 'de',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -113,6 +137,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
currentDate,
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
title,
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
|
||||||
languageCode: 'es',
|
languageCode: 'es',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -121,7 +147,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||||
response.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlFr, {
|
this.interpolate(this.indexHtmlFr, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
|
||||||
languageCode: 'fr',
|
languageCode: 'fr',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -133,6 +163,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
currentDate,
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
title,
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
|
||||||
languageCode: 'it',
|
languageCode: 'it',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -144,6 +176,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
currentDate,
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
title,
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
|
||||||
languageCode: 'nl',
|
languageCode: 'nl',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -152,7 +186,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||||
response.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlPt, {
|
this.interpolate(this.indexHtmlPt, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
|
||||||
languageCode: 'pt',
|
languageCode: 'pt',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -164,6 +202,7 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
currentDate,
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
title,
|
title,
|
||||||
|
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
|
||||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
44
apps/api/src/app/health/health.controller.ts
Normal file
44
apps/api/src/app/health/health.controller.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Param,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
public constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
public async getHealth() {}
|
||||||
|
|
||||||
|
@Get('data-provider/:dataSource')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getHealthOfDataProvider(
|
||||||
|
@Param('dataSource') dataSource: DataSource
|
||||||
|
) {
|
||||||
|
if (!DataSource[dataSource]) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasResponse = await this.healthService.hasResponseFromDataProvider(
|
||||||
|
dataSource
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasResponse !== true) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||||
|
StatusCodes.SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/health/health.module.ts
Normal file
13
apps/api/src/app/health/health.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
imports: [ConfigurationModule, DataProviderModule],
|
||||||
|
providers: [HealthService]
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
14
apps/api/src/app/health/health.service.ts
Normal file
14
apps/api/src/app/health/health.service.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
public constructor(
|
||||||
|
private readonly dataProviderService: DataProviderService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||||
|
return this.dataProviderService.checkQuote(aDataSource);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
import { ImportResponse } 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';
|
||||||
@ -35,6 +35,8 @@ export class ImportController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async import(
|
public async import(
|
||||||
@Body() importData: ImportDataDto,
|
@Body() importData: ImportDataDto,
|
||||||
@Query('dryRun') isDryRun?: boolean
|
@Query('dryRun') isDryRun?: boolean
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
@ -24,6 +25,7 @@ import { ImportService } from './import.service';
|
|||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PlatformModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
import {
|
||||||
|
Activity,
|
||||||
|
ActivityError
|
||||||
|
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
@ -14,7 +18,7 @@ import {
|
|||||||
OrderWithAccount
|
OrderWithAccount
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@ -26,6 +30,7 @@ export class ImportService {
|
|||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
|
private readonly platformService: PlatformService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
@ -69,8 +74,25 @@ export class ImportService {
|
|||||||
|
|
||||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||||
|
|
||||||
|
const isDuplicate = orders.some((activity) => {
|
||||||
|
return (
|
||||||
|
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||||
|
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||||
|
isSameDay(activity.date, parseDate(dateString)) &&
|
||||||
|
activity.quantity === quantity &&
|
||||||
|
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||||
|
activity.type === 'DIVIDEND' &&
|
||||||
|
activity.unitPrice === marketPrice
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const error: ActivityError = isDuplicate
|
||||||
|
? { code: 'IS_DUPLICATE' }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Account,
|
Account,
|
||||||
|
error,
|
||||||
quantity,
|
quantity,
|
||||||
value,
|
value,
|
||||||
accountId: Account?.id,
|
accountId: Account?.id,
|
||||||
@ -118,15 +140,18 @@ export class ImportService {
|
|||||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||||
|
|
||||||
if (!isDryRun && accountsDto?.length) {
|
if (!isDryRun && accountsDto?.length) {
|
||||||
const existingAccounts = await this.accountService.accounts({
|
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||||
where: {
|
this.accountService.accounts({
|
||||||
id: {
|
where: {
|
||||||
in: accountsDto.map(({ id }) => {
|
id: {
|
||||||
return id;
|
in: accountsDto.map(({ id }) => {
|
||||||
})
|
return id;
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}),
|
||||||
});
|
this.platformService.getPlatforms()
|
||||||
|
]);
|
||||||
|
|
||||||
for (const account of accountsDto) {
|
for (const account of accountsDto) {
|
||||||
// Check if there is any existing account with the same ID
|
// Check if there is any existing account with the same ID
|
||||||
@ -146,19 +171,24 @@ export class ImportService {
|
|||||||
delete account.id;
|
delete account.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newAccountObject = {
|
let accountObject: Prisma.AccountCreateInput = {
|
||||||
...account,
|
...account,
|
||||||
User: { connect: { id: userId } }
|
User: { connect: { id: userId } }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (platformId) {
|
if (
|
||||||
Object.assign(newAccountObject, {
|
existingPlatforms.some(({ id }) => {
|
||||||
|
return id === platformId;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
accountObject = {
|
||||||
|
...accountObject,
|
||||||
Platform: { connect: { id: platformId } }
|
Platform: { connect: { id: platformId } }
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const newAccount = await this.accountService.createAccount(
|
const newAccount = await this.accountService.createAccount(
|
||||||
newAccountObject,
|
accountObject,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -172,10 +202,11 @@ export class ImportService {
|
|||||||
|
|
||||||
for (const activity of activitiesDto) {
|
for (const activity of activitiesDto) {
|
||||||
if (!activity.dataSource) {
|
if (!activity.dataSource) {
|
||||||
if (activity.type === 'ITEM') {
|
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||||
activity.dataSource = 'MANUAL';
|
activity.dataSource = DataSource.MANUAL;
|
||||||
} else {
|
} else {
|
||||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
activity.dataSource =
|
||||||
|
this.dataProviderService.getDataSourceForImport();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,9 +224,14 @@ export class ImportService {
|
|||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
|
activitiesDto,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
const accounts = (await this.accountService.getAccounts(userId)).map(
|
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||||
(account) => {
|
({ id, name }) => {
|
||||||
return { id: account.id, name: account.name };
|
return { id, name };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -210,16 +246,14 @@ export class ImportService {
|
|||||||
for (const {
|
for (const {
|
||||||
accountId,
|
accountId,
|
||||||
comment,
|
comment,
|
||||||
currency,
|
date,
|
||||||
dataSource,
|
error,
|
||||||
date: dateString,
|
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
SymbolProfile: assetProfile,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
} of activitiesDto) {
|
} of activitiesExtendedWithErrors) {
|
||||||
const date = parseISO(<string>(<unknown>dateString));
|
|
||||||
const validatedAccount = accounts.find(({ id }) => {
|
const validatedAccount = accounts.find(({ id }) => {
|
||||||
return id === accountId;
|
return id === accountId;
|
||||||
});
|
});
|
||||||
@ -245,28 +279,33 @@ export class ImportService {
|
|||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
isDraft: isAfter(date, endOfToday()),
|
isDraft: isAfter(date, endOfToday()),
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
currency,
|
assetClass: assetProfile.assetClass,
|
||||||
dataSource,
|
assetSubClass: assetProfile.assetSubClass,
|
||||||
symbol,
|
comment: assetProfile.comment,
|
||||||
assetClass: null,
|
countries: assetProfile.countries,
|
||||||
assetSubClass: null,
|
createdAt: assetProfile.createdAt,
|
||||||
comment: null,
|
currency: assetProfile.currency,
|
||||||
countries: null,
|
dataSource: assetProfile.dataSource,
|
||||||
createdAt: undefined,
|
id: assetProfile.id,
|
||||||
id: undefined,
|
isin: assetProfile.isin,
|
||||||
name: null,
|
name: assetProfile.name,
|
||||||
scraperConfiguration: null,
|
scraperConfiguration: assetProfile.scraperConfiguration,
|
||||||
sectors: null,
|
sectors: assetProfile.sectors,
|
||||||
symbolMapping: null,
|
symbol: assetProfile.currency,
|
||||||
updatedAt: undefined,
|
symbolMapping: assetProfile.symbolMapping,
|
||||||
url: null,
|
updatedAt: assetProfile.updatedAt,
|
||||||
...assetProfiles[symbol]
|
url: assetProfile.url,
|
||||||
|
...assetProfiles[assetProfile.symbol]
|
||||||
},
|
},
|
||||||
Account: validatedAccount,
|
Account: validatedAccount,
|
||||||
symbolProfileId: undefined,
|
symbolProfileId: undefined,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
if (error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
order = await this.orderService.createOrder({
|
order = await this.orderService.createOrder({
|
||||||
comment,
|
comment,
|
||||||
date,
|
date,
|
||||||
@ -279,18 +318,19 @@ export class ImportService {
|
|||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
currency,
|
currency: assetProfile.currency,
|
||||||
dataSource,
|
dataSource: assetProfile.dataSource,
|
||||||
symbol
|
symbol: assetProfile.symbol
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
dataSource,
|
dataSource: assetProfile.dataSource,
|
||||||
symbol
|
symbol: assetProfile.symbol
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
updateAccountBalance: false,
|
||||||
User: { connect: { id: userId } }
|
User: { connect: { id: userId } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -300,15 +340,16 @@ export class ImportService {
|
|||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
activities.push({
|
activities.push({
|
||||||
...order,
|
...order,
|
||||||
|
error,
|
||||||
value,
|
value,
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
fee,
|
fee,
|
||||||
currency,
|
assetProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
currency,
|
assetProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
@ -317,6 +358,82 @@ export class ImportService {
|
|||||||
return activities;
|
return activities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async extendActivitiesWithErrors({
|
||||||
|
activitiesDto,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
userId: string;
|
||||||
|
}): Promise<Partial<Activity>[]> {
|
||||||
|
const existingActivities = await this.orderService.orders({
|
||||||
|
include: { SymbolProfile: true },
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return activitiesDto.map(
|
||||||
|
({
|
||||||
|
accountId,
|
||||||
|
comment,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
date: dateString,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
symbol,
|
||||||
|
type,
|
||||||
|
unitPrice
|
||||||
|
}) => {
|
||||||
|
const date = parseISO(<string>(<unknown>dateString));
|
||||||
|
const isDuplicate = existingActivities.some((activity) => {
|
||||||
|
return (
|
||||||
|
activity.SymbolProfile.currency === currency &&
|
||||||
|
activity.SymbolProfile.dataSource === dataSource &&
|
||||||
|
isSameDay(activity.date, date) &&
|
||||||
|
activity.fee === fee &&
|
||||||
|
activity.quantity === quantity &&
|
||||||
|
activity.SymbolProfile.symbol === symbol &&
|
||||||
|
activity.type === type &&
|
||||||
|
activity.unitPrice === unitPrice
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const error: ActivityError = isDuplicate
|
||||||
|
? { code: 'IS_DUPLICATE' }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
comment,
|
||||||
|
date,
|
||||||
|
error,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
SymbolProfile: {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
assetClass: null,
|
||||||
|
assetSubClass: null,
|
||||||
|
comment: null,
|
||||||
|
countries: null,
|
||||||
|
createdAt: undefined,
|
||||||
|
id: undefined,
|
||||||
|
isin: null,
|
||||||
|
name: null,
|
||||||
|
scraperConfiguration: null,
|
||||||
|
sectors: null,
|
||||||
|
symbolMapping: null,
|
||||||
|
updatedAt: undefined,
|
||||||
|
url: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
||||||
const uniqueAccountIds = new Set<string>();
|
const uniqueAccountIds = new Set<string>();
|
||||||
|
|
||||||
@ -343,33 +460,11 @@ export class ImportService {
|
|||||||
const assetProfiles: {
|
const assetProfiles: {
|
||||||
[symbol: string]: Partial<SymbolProfile>;
|
[symbol: string]: Partial<SymbolProfile>;
|
||||||
} = {};
|
} = {};
|
||||||
const existingActivities = await this.orderService.orders({
|
|
||||||
include: { SymbolProfile: true },
|
|
||||||
orderBy: { date: 'desc' },
|
|
||||||
where: { userId }
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
{ currency, dataSource, symbol }
|
||||||
] of activitiesDto.entries()) {
|
] of activitiesDto.entries()) {
|
||||||
const duplicateActivity = existingActivities.find((activity) => {
|
|
||||||
return (
|
|
||||||
activity.SymbolProfile.currency === currency &&
|
|
||||||
activity.SymbolProfile.dataSource === dataSource &&
|
|
||||||
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
|
|
||||||
activity.fee === fee &&
|
|
||||||
activity.quantity === quantity &&
|
|
||||||
activity.SymbolProfile.symbol === symbol &&
|
|
||||||
activity.type === type &&
|
|
||||||
activity.unitPrice === unitPrice
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (duplicateActivity) {
|
|
||||||
throw new Error(`activities.${index} is a duplicate activity`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataSource !== 'MANUAL') {
|
if (dataSource !== 'MANUAL') {
|
||||||
const assetProfile = (
|
const assetProfile = (
|
||||||
await this.dataProviderService.getAssetProfiles([
|
await this.dataProviderService.getAssetProfiles([
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||||
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
@ -26,6 +27,7 @@ import { InfoService } from './info.service';
|
|||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
}),
|
}),
|
||||||
|
PlatformModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||||
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
|
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
PROPERTY_DEMO_USER_ID,
|
PROPERTY_DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
@ -15,18 +17,22 @@ import {
|
|||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
encodeDataSource,
|
encodeDataSource,
|
||||||
extractNumberFromString
|
extractNumberFromString
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
InfoItem,
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
Statistics,
|
||||||
|
Subscription
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
@ -37,6 +43,7 @@ export class InfoService {
|
|||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly platformService: PlatformService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
@ -46,9 +53,12 @@ export class InfoService {
|
|||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const info: Partial<InfoItem> = {};
|
const info: Partial<InfoItem> = {};
|
||||||
let isReadOnlyMode: boolean;
|
let isReadOnlyMode: boolean;
|
||||||
const platforms = await this.prismaService.platform.findMany({
|
const platforms = (
|
||||||
orderBy: { name: 'asc' },
|
await this.platformService.getPlatforms({
|
||||||
select: { id: true, name: true }
|
orderBy: { name: 'asc' }
|
||||||
|
})
|
||||||
|
).map(({ id, name }) => {
|
||||||
|
return { id, name };
|
||||||
});
|
});
|
||||||
let systemMessage: string;
|
let systemMessage: string;
|
||||||
|
|
||||||
@ -109,19 +119,28 @@ export class InfoService {
|
|||||||
globalPermissions.push(permissions.createUserAccount);
|
globalPermissions.push(permissions.createUserAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] =
|
||||||
|
await Promise.all([
|
||||||
|
this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||||
|
this.getDemoAuthToken(),
|
||||||
|
this.getStatistics(),
|
||||||
|
this.getSubscriptions(),
|
||||||
|
this.tagService.get()
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...info,
|
...info,
|
||||||
|
benchmarks,
|
||||||
|
demoAuthToken,
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
isReadOnlyMode,
|
isReadOnlyMode,
|
||||||
platforms,
|
platforms,
|
||||||
|
statistics,
|
||||||
|
subscriptions,
|
||||||
systemMessage,
|
systemMessage,
|
||||||
|
tags,
|
||||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
currencies: this.exchangeRateDataService.getCurrencies()
|
||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
|
||||||
demoAuthToken: await this.getDemoAuthToken(),
|
|
||||||
statistics: await this.getStatistics(),
|
|
||||||
subscriptions: await this.getSubscriptions(),
|
|
||||||
tags: await this.tagService.get()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -285,6 +304,7 @@ export class InfoService {
|
|||||||
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();
|
||||||
|
const uptime = await this.getUptime();
|
||||||
|
|
||||||
statistics = {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
@ -293,7 +313,8 @@ export class InfoService {
|
|||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d,
|
newUsers30d,
|
||||||
slackCommunityUsers
|
slackCommunityUsers,
|
||||||
|
uptime
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
@ -304,19 +325,49 @@ export class InfoService {
|
|||||||
return statistics;
|
return statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSubscriptions(): Promise<Subscription[]> {
|
private async getSubscriptions(): Promise<{
|
||||||
|
[offer in SubscriptionOffer]: Subscription;
|
||||||
|
}> {
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscriptions: Subscription[] = [];
|
|
||||||
|
|
||||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||||
})) ?? { value: '{}' };
|
})) ?? { value: '{}' };
|
||||||
|
|
||||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
return JSON.parse(stripeConfig.value);
|
||||||
|
}
|
||||||
|
|
||||||
return subscriptions;
|
private async getUptime(): Promise<number> {
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
const monitorId = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||||
|
)) as string;
|
||||||
|
|
||||||
|
const get = bent(
|
||||||
|
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
|
subDays(new Date(), 90),
|
||||||
|
DATE_FORMAT
|
||||||
|
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Authorization: `Bearer ${this.configurationService.get(
|
||||||
|
'BETTER_UPTIME_API_KEY'
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = await get();
|
||||||
|
return data.attributes.availability / 100;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { LogoController } from './logo.controller';
|
import { LogoController } from './logo.controller';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -64,4 +65,8 @@ export class CreateOrderDto {
|
|||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
updateAccountBalance?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,14 @@ export interface Activities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends OrderWithAccount {
|
||||||
|
error?: ActivityError;
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
|
updateAccountBalance?: boolean;
|
||||||
value: number;
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityError {
|
||||||
|
code: 'IS_DUPLICATE';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
@ -2,7 +2,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -41,6 +41,23 @@ export class OrderController {
|
|||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteOrders(): Promise<number> {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.orderService.deleteOrders({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||||
@ -79,10 +96,7 @@ export class OrderController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
impersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
const activities = await this.orderService.getOrders({
|
const activities = await this.orderService.getOrders({
|
||||||
|
@ -3,13 +3,13 @@ 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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { OrderController } from './order.controller';
|
import { OrderController } from './order.controller';
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
@ -73,6 +73,7 @@ export class OrderService {
|
|||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
|
updateAccountBalance?: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
@ -89,12 +90,16 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountId = data.accountId;
|
||||||
|
let currency = data.currency;
|
||||||
const tags = data.tags ?? [];
|
const tags = data.tags ?? [];
|
||||||
|
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||||
|
const userId = data.userId;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||||
const dataSource: DataSource = 'MANUAL';
|
const dataSource: DataSource = 'MANUAL';
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||||
@ -112,16 +117,22 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
await this.dataGatheringService.addJobToQueue({
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
data: {
|
||||||
{
|
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
const isDraft =
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
? false
|
||||||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
// Gather symbol data of order in the background, if not draft
|
// Gather symbol data of order in the background, if not draft
|
||||||
@ -146,11 +157,12 @@ export class OrderService {
|
|||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
|
delete data.updateAccountBalance;
|
||||||
delete data.userId;
|
delete data.userId;
|
||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
return this.prismaService.order.create({
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
...orderData,
|
...orderData,
|
||||||
Account,
|
Account,
|
||||||
@ -162,6 +174,27 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updateAccountBalance === true) {
|
||||||
|
let amount = new Big(data.unitPrice)
|
||||||
|
.mul(data.quantity)
|
||||||
|
.plus(data.fee)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
if (data.type === 'BUY') {
|
||||||
|
amount = new Big(amount).mul(-1).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
accountId,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
userId,
|
||||||
|
date: data.date as Date
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteOrder(
|
public async deleteOrder(
|
||||||
@ -171,13 +204,21 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.type === 'ITEM') {
|
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
|
||||||
|
const { count } = await this.prismaService.order.deleteMany({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
@ -282,7 +323,11 @@ export class OrderService {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
return (
|
||||||
|
withExcludedAccounts ||
|
||||||
|
!order.Account ||
|
||||||
|
order.Account?.isExcluded === false
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map((order) => {
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
@ -330,7 +375,7 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
|
9
apps/api/src/app/platform/create-platform.dto.ts
Normal file
9
apps/api/src/app/platform/create-platform.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePlatformDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
url: string;
|
||||||
|
}
|
114
apps/api/src/app/platform/platform.controller.ts
Normal file
114
apps/api/src/app/platform/platform.controller.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Platform } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { CreatePlatformDto } from './create-platform.dto';
|
||||||
|
import { PlatformService } from './platform.service';
|
||||||
|
import { UpdatePlatformDto } from './update-platform.dto';
|
||||||
|
|
||||||
|
@Controller('platform')
|
||||||
|
export class PlatformController {
|
||||||
|
public constructor(
|
||||||
|
private readonly platformService: PlatformService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getPlatforms() {
|
||||||
|
return this.platformService.getPlatformsWithAccountCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async createPlatform(
|
||||||
|
@Body() data: CreatePlatformDto
|
||||||
|
): Promise<Platform> {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.createPlatform)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.platformService.createPlatform(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async updatePlatform(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() data: UpdatePlatformDto
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalPlatform) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.platformService.updatePlatform({
|
||||||
|
data: {
|
||||||
|
...data
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deletePlatform(@Param('id') id: string) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalPlatform) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.platformService.deletePlatform({ id });
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/platform/platform.module.ts
Normal file
13
apps/api/src/app/platform/platform.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PlatformController } from './platform.controller';
|
||||||
|
import { PlatformService } from './platform.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PlatformController],
|
||||||
|
exports: [PlatformService],
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [PlatformService]
|
||||||
|
})
|
||||||
|
export class PlatformModule {}
|
83
apps/api/src/app/platform/platform.service.ts
Normal file
83
apps/api/src/app/platform/platform.service.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Platform, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlatformService {
|
||||||
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async getPlatform(
|
||||||
|
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
||||||
|
): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.findUnique({
|
||||||
|
where: platformWhereUniqueInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPlatforms({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
cursor?: Prisma.PlatformWhereUniqueInput;
|
||||||
|
orderBy?: Prisma.PlatformOrderByWithRelationInput;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
where?: Prisma.PlatformWhereInput;
|
||||||
|
} = {}) {
|
||||||
|
return this.prismaService.platform.findMany({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPlatformsWithAccountCount() {
|
||||||
|
const platformsWithAccountCount =
|
||||||
|
await this.prismaService.platform.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { Account: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return platformsWithAccountCount.map(({ _count, id, name, url }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
accountCount: _count.Account
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
||||||
|
return this.prismaService.platform.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updatePlatform({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
data: Prisma.PlatformUpdateInput;
|
||||||
|
where: Prisma.PlatformWhereUniqueInput;
|
||||||
|
}): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.update({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePlatform(
|
||||||
|
where: Prisma.PlatformWhereUniqueInput
|
||||||
|
): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.delete({ where });
|
||||||
|
}
|
||||||
|
}
|
12
apps/api/src/app/platform/update-platform.dto.ts
Normal file
12
apps/api/src/app/platform/update-platform.dto.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdatePlatformDto {
|
||||||
|
@IsString()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
url: string;
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
|
||||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
function mockGetValue(symbol: string, date: Date) {
|
function mockGetValue(symbol: string, date: Date) {
|
||||||
@ -49,11 +49,9 @@ export const CurrentRateServiceMock = {
|
|||||||
getValues: ({
|
getValues: ({
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery
|
dateQuery
|
||||||
}: GetValuesParams): Promise<{
|
}: GetValuesParams): Promise<GetValuesObject> => {
|
||||||
dataProviderInfos: DataProviderInfo[];
|
|
||||||
values: GetValueObject[];
|
|
||||||
}> => {
|
|
||||||
const values: GetValueObject[] = [];
|
const values: GetValueObject[] = [];
|
||||||
|
|
||||||
if (dateQuery.lt) {
|
if (dateQuery.lt) {
|
||||||
for (
|
for (
|
||||||
let date = resetHours(dateQuery.gte);
|
let date = resetHours(dateQuery.gte);
|
||||||
@ -85,6 +83,7 @@ export const CurrentRateServiceMock = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.resolve({ values, dataProviderInfos: [] });
|
|
||||||
|
return Promise.resolve({ values, dataProviderInfos: [], errors: [] });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
||||||
return {
|
return {
|
||||||
MarketDataService: jest.fn().mockImplementation(() => {
|
MarketDataService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
@ -18,7 +18,8 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
createdAt: date,
|
createdAt: date,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: DataSource.YAHOO,
|
||||||
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||||
marketPrice: 1847.839966
|
marketPrice: 1847.839966,
|
||||||
|
state: 'CLOSE'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getRange: ({
|
getRange: ({
|
||||||
@ -37,6 +38,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
date: dateRangeStart,
|
date: dateRangeStart,
|
||||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||||
marketPrice: 1841.823902,
|
marketPrice: 1841.823902,
|
||||||
|
state: 'CLOSE',
|
||||||
symbol: symbols[0]
|
symbol: symbols[0]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -45,6 +47,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
date: dateRangeEnd,
|
date: dateRangeEnd,
|
||||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||||
marketPrice: 1847.839966,
|
marketPrice: 1847.839966,
|
||||||
|
state: 'CLOSE',
|
||||||
symbol: symbols[0]
|
symbol: symbols[0]
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@ -54,14 +57,27 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
initialize: () => Promise.resolve(),
|
||||||
|
toCurrency: (value: number) => {
|
||||||
|
return 1 * value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/services/property/property.service', () => {
|
||||||
return {
|
return {
|
||||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
PropertyService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
initialize: () => Promise.resolve(),
|
getByKey: (key: string) => Promise.resolve({})
|
||||||
toCurrency: (value: number) => {
|
|
||||||
return 1 * value;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@ -72,9 +88,19 @@ describe('CurrentRateService', () => {
|
|||||||
let dataProviderService: DataProviderService;
|
let dataProviderService: DataProviderService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
let marketDataService: MarketDataService;
|
let marketDataService: MarketDataService;
|
||||||
|
let propertyService: PropertyService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataProviderService = new DataProviderService(null, [], null);
|
propertyService = new PropertyService(null);
|
||||||
|
|
||||||
|
dataProviderService = new DataProviderService(
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
propertyService,
|
||||||
|
null
|
||||||
|
);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@ -104,21 +130,14 @@ describe('CurrentRateService', () => {
|
|||||||
},
|
},
|
||||||
userCurrency: 'CHF'
|
userCurrency: 'CHF'
|
||||||
})
|
})
|
||||||
).toMatchObject<{
|
).toMatchObject<GetValuesObject>({
|
||||||
dataProviderInfos: DataProviderInfo[];
|
|
||||||
values: GetValueObject[];
|
|
||||||
}>({
|
|
||||||
dataProviderInfos: [],
|
dataProviderInfos: [],
|
||||||
|
errors: [],
|
||||||
values: [
|
values: [
|
||||||
{
|
{
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPriceInBaseCurrency: 1841.823902,
|
marketPriceInBaseCurrency: 1841.823902,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
},
|
|
||||||
{
|
|
||||||
date: undefined,
|
|
||||||
marketPriceInBaseCurrency: 1847.839966,
|
|
||||||
symbol: 'AMZN'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
import { flatten } from 'lodash';
|
import { flatten, isEmpty, uniqBy } from 'lodash';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -23,10 +24,7 @@ export class CurrentRateService {
|
|||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery,
|
dateQuery,
|
||||||
userCurrency
|
userCurrency
|
||||||
}: GetValuesParams): Promise<{
|
}: GetValuesParams): Promise<GetValuesObject> {
|
||||||
dataProviderInfos: DataProviderInfo[];
|
|
||||||
values: GetValueObject[];
|
|
||||||
}> {
|
|
||||||
const dataProviderInfos: DataProviderInfo[] = [];
|
const dataProviderInfos: DataProviderInfo[] = [];
|
||||||
const includeToday =
|
const includeToday =
|
||||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
@ -34,9 +32,10 @@ export class CurrentRateService {
|
|||||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||||
|
|
||||||
const promises: Promise<GetValueObject[]>[] = [];
|
const promises: Promise<GetValueObject[]>[] = [];
|
||||||
|
const quoteErrors: ResponseError['errors'] = [];
|
||||||
|
const today = resetHours(new Date());
|
||||||
|
|
||||||
if (includeToday) {
|
if (includeToday) {
|
||||||
const today = resetHours(new Date());
|
|
||||||
promises.push(
|
promises.push(
|
||||||
this.dataProviderService
|
this.dataProviderService
|
||||||
.getQuotes(dataGatheringItems)
|
.getQuotes(dataGatheringItems)
|
||||||
@ -51,18 +50,26 @@ export class CurrentRateService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push({
|
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
||||||
date: today,
|
result.push({
|
||||||
marketPriceInBaseCurrency:
|
date: today,
|
||||||
this.exchangeRateDataService.toCurrency(
|
marketPriceInBaseCurrency:
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]
|
this.exchangeRateDataService.toCurrency(
|
||||||
?.marketPrice ?? 0,
|
dataResultProvider?.[dataGatheringItem.symbol]
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
?.marketPrice,
|
||||||
userCurrency
|
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||||
),
|
userCurrency
|
||||||
symbol: dataGatheringItem.symbol
|
),
|
||||||
});
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
quoteErrors.push({
|
||||||
|
dataSource: dataGatheringItem.dataSource,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -94,10 +101,60 @@ export class CurrentRateService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const values = flatten(await Promise.all(promises));
|
||||||
|
|
||||||
|
const response: GetValuesObject = {
|
||||||
dataProviderInfos,
|
dataProviderInfos,
|
||||||
values: flatten(await Promise.all(promises))
|
errors: quoteErrors.map(({ dataSource, symbol }) => {
|
||||||
|
return { dataSource, symbol };
|
||||||
|
}),
|
||||||
|
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isEmpty(quoteErrors)) {
|
||||||
|
for (const { symbol } of quoteErrors) {
|
||||||
|
try {
|
||||||
|
// If missing quote, fallback to the latest available historical market price
|
||||||
|
let value: GetValueObject = response.values.find((currentValue) => {
|
||||||
|
return currentValue.symbol === symbol && isToday(currentValue.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
value = {
|
||||||
|
symbol,
|
||||||
|
date: today,
|
||||||
|
marketPriceInBaseCurrency: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
response.values.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [latestValue] = response.values
|
||||||
|
.filter((currentValue) => {
|
||||||
|
return (
|
||||||
|
currentValue.symbol === symbol &&
|
||||||
|
currentValue.marketPriceInBaseCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.date < b.date) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.date > b.date) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
value.marketPriceInBaseCurrency =
|
||||||
|
latestValue.marketPriceInBaseCurrency;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private containsToday(dates: Date[]): boolean {
|
private containsToday(dates: Date[]): boolean {
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
import { GetValueObject } from './get-value-object.interface';
|
||||||
|
|
||||||
|
export interface GetValuesObject {
|
||||||
|
dataProviderInfos: DataProviderInfo[];
|
||||||
|
errors: ResponseError['errors'];
|
||||||
|
values: GetValueObject[];
|
||||||
|
}
|
@ -105,6 +105,40 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
expect(investmentsByMonth).toEqual([
|
expect(investmentsByMonth).toEqual([
|
||||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||||
|
{ date: '2015-02-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-03-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-04-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-05-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-06-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-07-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-08-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-09-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-10-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-11-01', investment: new Big('0') },
|
||||||
|
{ date: '2015-12-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-01-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-02-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-03-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-04-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-05-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-06-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-07-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-08-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-09-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-10-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-11-01', investment: new Big('0') },
|
||||||
|
{ date: '2016-12-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-01-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-02-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-03-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-04-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-05-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-06-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-07-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-08-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-09-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-10-01', investment: new Big('0') },
|
||||||
|
{ date: '2017-11-01', investment: new Big('0') },
|
||||||
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -86,7 +86,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
netPerformanceInPercentage: 13.100263852242744,
|
netPerformanceInPercentage: 13.100263852242744,
|
||||||
netPerformance: 19.86,
|
netPerformance: 19.86,
|
||||||
totalInvestment: 0,
|
totalInvestment: 0,
|
||||||
value: 19.86
|
value: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
|
@ -24,9 +24,10 @@ import {
|
|||||||
isSameYear,
|
isSameYear,
|
||||||
max,
|
max,
|
||||||
min,
|
min,
|
||||||
set
|
set,
|
||||||
|
subDays
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { first, flatten, isNumber, last, sortBy } from 'lodash';
|
import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||||
@ -182,10 +183,10 @@ export class PortfolioCalculator {
|
|||||||
return isBefore(parseDate(transactionPoint.date), end);
|
return isBefore(parseDate(transactionPoint.date), end);
|
||||||
}) ?? [];
|
}) ?? [];
|
||||||
|
|
||||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
const currencies: { [symbol: string]: string } = {};
|
||||||
const dates: Date[] = [];
|
const dates: Date[] = [];
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
const currencies: { [symbol: string]: string } = {};
|
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
|
|
||||||
let day = start;
|
let day = start;
|
||||||
|
|
||||||
@ -235,87 +236,100 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const netPerformanceValuesBySymbol: {
|
const valuesByDate: {
|
||||||
[symbol: string]: { [date: string]: Big };
|
[date: string]: {
|
||||||
|
maxTotalInvestmentValue: Big;
|
||||||
|
totalCurrentValue: Big;
|
||||||
|
totalInvestmentValue: Big;
|
||||||
|
totalNetPerformanceValue: Big;
|
||||||
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const investmentValuesBySymbol: {
|
const valuesBySymbol: {
|
||||||
[symbol: string]: { [date: string]: Big };
|
[symbol: string]: {
|
||||||
|
currentValues: { [date: string]: Big };
|
||||||
|
investmentValues: { [date: string]: Big };
|
||||||
|
maxInvestmentValues: { [date: string]: Big };
|
||||||
|
netPerformanceValues: { [date: string]: Big };
|
||||||
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const maxInvestmentValuesBySymbol: {
|
|
||||||
[symbol: string]: { [date: string]: Big };
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
const totalNetPerformanceValues: { [date: string]: Big } = {};
|
|
||||||
const totalInvestmentValues: { [date: string]: Big } = {};
|
|
||||||
const maxTotalInvestmentValues: { [date: string]: Big } = {};
|
|
||||||
|
|
||||||
for (const symbol of Object.keys(symbols)) {
|
for (const symbol of Object.keys(symbols)) {
|
||||||
const { investmentValues, maxInvestmentValues, netPerformanceValues } =
|
const {
|
||||||
this.getSymbolMetrics({
|
currentValues,
|
||||||
end,
|
investmentValues,
|
||||||
marketSymbolMap,
|
maxInvestmentValues,
|
||||||
start,
|
netPerformanceValues
|
||||||
step,
|
} = this.getSymbolMetrics({
|
||||||
symbol,
|
end,
|
||||||
isChartMode: true
|
marketSymbolMap,
|
||||||
});
|
start,
|
||||||
|
step,
|
||||||
|
symbol,
|
||||||
|
isChartMode: true
|
||||||
|
});
|
||||||
|
|
||||||
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
valuesBySymbol[symbol] = {
|
||||||
investmentValuesBySymbol[symbol] = investmentValues;
|
currentValues,
|
||||||
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
|
investmentValues,
|
||||||
|
maxInvestmentValues,
|
||||||
|
netPerformanceValues
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const currentDate of dates) {
|
for (const currentDate of dates) {
|
||||||
const dateString = format(currentDate, DATE_FORMAT);
|
const dateString = format(currentDate, DATE_FORMAT);
|
||||||
|
|
||||||
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
|
for (const symbol of Object.keys(valuesBySymbol)) {
|
||||||
totalNetPerformanceValues[dateString] =
|
const symbolValues = valuesBySymbol[symbol];
|
||||||
totalNetPerformanceValues[dateString] ?? new Big(0);
|
|
||||||
|
|
||||||
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
|
const currentValue =
|
||||||
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
|
symbolValues.currentValues?.[dateString] ?? new Big(0);
|
||||||
dateString
|
const investmentValue =
|
||||||
].add(netPerformanceValuesBySymbol[symbol][dateString]);
|
symbolValues.investmentValues?.[dateString] ?? new Big(0);
|
||||||
}
|
const maxInvestmentValue =
|
||||||
|
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
|
||||||
|
const netPerformanceValue =
|
||||||
|
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
totalInvestmentValues[dateString] =
|
valuesByDate[dateString] = {
|
||||||
totalInvestmentValues[dateString] ?? new Big(0);
|
totalCurrentValue: (
|
||||||
|
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||||
maxTotalInvestmentValues[dateString] =
|
).add(currentValue),
|
||||||
maxTotalInvestmentValues[dateString] ?? new Big(0);
|
totalInvestmentValue: (
|
||||||
|
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
|
||||||
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
).add(investmentValue),
|
||||||
totalInvestmentValues[dateString] = totalInvestmentValues[
|
maxTotalInvestmentValue: (
|
||||||
dateString
|
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
|
||||||
].add(investmentValuesBySymbol[symbol][dateString]);
|
).add(maxInvestmentValue),
|
||||||
}
|
totalNetPerformanceValue: (
|
||||||
|
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
|
||||||
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
|
).add(netPerformanceValue)
|
||||||
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
|
};
|
||||||
dateString
|
|
||||||
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
return Object.entries(valuesByDate).map(([date, values]) => {
|
||||||
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
|
const {
|
||||||
|
maxTotalInvestmentValue,
|
||||||
|
totalCurrentValue,
|
||||||
|
totalInvestmentValue,
|
||||||
|
totalNetPerformanceValue
|
||||||
|
} = values;
|
||||||
|
|
||||||
|
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
|
||||||
? 0
|
? 0
|
||||||
: totalNetPerformanceValues[date]
|
: totalNetPerformanceValue
|
||||||
.div(maxTotalInvestmentValues[date])
|
.div(maxTotalInvestmentValue)
|
||||||
.mul(100)
|
.mul(100)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
netPerformanceInPercentage,
|
netPerformanceInPercentage,
|
||||||
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||||
totalInvestment: totalInvestmentValues[date].toNumber(),
|
totalInvestment: totalInvestmentValue.toNumber(),
|
||||||
value: totalInvestmentValues[date]
|
value: totalCurrentValue.toNumber()
|
||||||
.plus(totalNetPerformanceValues[date])
|
|
||||||
.toNumber()
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -347,7 +361,7 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
let firstTransactionPoint: TransactionPoint = null;
|
let firstTransactionPoint: TransactionPoint = null;
|
||||||
let firstIndex = transactionPointsBeforeEndDate.length;
|
let firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
const dates = [];
|
let dates = [];
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
const currencies: { [symbol: string]: string } = {};
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
|
||||||
@ -376,15 +390,37 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
dates.push(resetHours(end));
|
dates.push(resetHours(end));
|
||||||
|
|
||||||
const { dataProviderInfos, values: marketSymbols } =
|
// Add dates of last week for fallback
|
||||||
await this.currentRateService.getValues({
|
dates.push(subDays(resetHours(new Date()), 7));
|
||||||
currencies,
|
dates.push(subDays(resetHours(new Date()), 6));
|
||||||
dataGatheringItems,
|
dates.push(subDays(resetHours(new Date()), 5));
|
||||||
dateQuery: {
|
dates.push(subDays(resetHours(new Date()), 4));
|
||||||
in: dates
|
dates.push(subDays(resetHours(new Date()), 3));
|
||||||
},
|
dates.push(subDays(resetHours(new Date()), 2));
|
||||||
userCurrency: this.currency
|
dates.push(subDays(resetHours(new Date()), 1));
|
||||||
});
|
dates.push(resetHours(new Date()));
|
||||||
|
|
||||||
|
dates = uniq(
|
||||||
|
dates.map((date) => {
|
||||||
|
return date.getTime();
|
||||||
|
})
|
||||||
|
).map((timestamp) => {
|
||||||
|
return new Date(timestamp);
|
||||||
|
});
|
||||||
|
dates.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
|
||||||
|
const {
|
||||||
|
dataProviderInfos,
|
||||||
|
errors: currentRateErrors,
|
||||||
|
values: marketSymbols
|
||||||
|
} = await this.currentRateService.getValues({
|
||||||
|
currencies,
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
},
|
||||||
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
this.dataProviderInfos = dataProviderInfos;
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
@ -459,7 +495,13 @@ export class PortfolioCalculator {
|
|||||||
transactionCount: item.transactionCount
|
transactionCount: item.transactionCount
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasErrors && item.investment.gt(0)) {
|
if (
|
||||||
|
(hasErrors ||
|
||||||
|
currentRateErrors.find(({ dataSource, symbol }) => {
|
||||||
|
return dataSource === item.dataSource && symbol === item.symbol;
|
||||||
|
})) &&
|
||||||
|
item.investment.gt(0)
|
||||||
|
) {
|
||||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -502,7 +544,7 @@ export class PortfolioCalculator {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const investments = [];
|
const investments: { date: string; investment: Big }[] = [];
|
||||||
let currentDate: Date;
|
let currentDate: Date;
|
||||||
let investmentByGroup = new Big(0);
|
let investmentByGroup = new Big(0);
|
||||||
|
|
||||||
@ -512,13 +554,11 @@ export class PortfolioCalculator {
|
|||||||
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||||
) {
|
) {
|
||||||
// Same group: Add up investments
|
// Same group: Add up investments
|
||||||
|
|
||||||
investmentByGroup = investmentByGroup.plus(
|
investmentByGroup = investmentByGroup.plus(
|
||||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// New group: Store previous group and reset
|
// New group: Store previous group and reset
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(
|
date: format(
|
||||||
@ -553,7 +593,39 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return investments;
|
// Fill in the missing dates with investment = 0
|
||||||
|
const startDate = parseDate(first(this.orders).date);
|
||||||
|
const endDate = parseDate(last(this.orders).date);
|
||||||
|
|
||||||
|
const allDates: string[] = [];
|
||||||
|
currentDate = startDate;
|
||||||
|
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
allDates.push(
|
||||||
|
format(
|
||||||
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
)
|
||||||
|
);
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const date of allDates) {
|
||||||
|
const existingInvestment = investments.find((investment) => {
|
||||||
|
return investment.date === date;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingInvestment) {
|
||||||
|
investments.push({ date, investment: new Big(0) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(investments, (investment) => {
|
||||||
|
return investment.date;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async calculateTimeline(
|
public async calculateTimeline(
|
||||||
@ -709,7 +781,7 @@ export class PortfolioCalculator {
|
|||||||
);
|
);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
Logger.warn(
|
Logger.warn(
|
||||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
`Missing historical market data for symbol ${currentPosition.symbol}`,
|
||||||
'PortfolioCalculator'
|
'PortfolioCalculator'
|
||||||
);
|
);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
@ -906,12 +978,16 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return {
|
return {
|
||||||
|
currentValues: {},
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
initialValue: new Big(0),
|
initialValue: new Big(0),
|
||||||
|
investmentValues: {},
|
||||||
|
maxInvestmentValues: {},
|
||||||
netPerformance: new Big(0),
|
netPerformance: new Big(0),
|
||||||
netPerformancePercentage: new Big(0),
|
netPerformancePercentage: new Big(0),
|
||||||
grossPerformance: new Big(0),
|
netPerformanceValues: {}
|
||||||
grossPerformancePercentage: new Big(0)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -946,6 +1022,7 @@ export class PortfolioCalculator {
|
|||||||
let grossPerformanceFromSells = new Big(0);
|
let grossPerformanceFromSells = new Big(0);
|
||||||
let initialValue: Big;
|
let initialValue: Big;
|
||||||
let investmentAtStartDate: Big;
|
let investmentAtStartDate: Big;
|
||||||
|
const currentValues: { [date: string]: Big } = {};
|
||||||
const investmentValues: { [date: string]: Big } = {};
|
const investmentValues: { [date: string]: Big } = {};
|
||||||
const maxInvestmentValues: { [date: string]: Big } = {};
|
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||||
let lastAveragePrice = new Big(0);
|
let lastAveragePrice = new Big(0);
|
||||||
@ -1164,6 +1241,7 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isChartMode && i > indexOfStartOrder) {
|
if (isChartMode && i > indexOfStartOrder) {
|
||||||
|
currentValues[order.date] = valueOfInvestment;
|
||||||
netPerformanceValues[order.date] = grossPerformance
|
netPerformanceValues[order.date] = grossPerformance
|
||||||
.minus(grossPerformanceAtStartDate)
|
.minus(grossPerformanceAtStartDate)
|
||||||
.minus(fees.minus(feesAtStartDate));
|
.minus(fees.minus(feesAtStartDate));
|
||||||
@ -1261,15 +1339,16 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialValue,
|
currentValues,
|
||||||
grossPerformancePercentage,
|
grossPerformancePercentage,
|
||||||
|
initialValue,
|
||||||
investmentValues,
|
investmentValues,
|
||||||
maxInvestmentValues,
|
maxInvestmentValues,
|
||||||
netPerformancePercentage,
|
netPerformancePercentage,
|
||||||
netPerformanceValues,
|
netPerformanceValues,
|
||||||
|
grossPerformance: totalGrossPerformance,
|
||||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||||
netPerformance: totalNetPerformance,
|
netPerformance: totalNetPerformance
|
||||||
grossPerformance: totalGrossPerformance
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,8 +8,8 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
@ -91,6 +91,7 @@ export class PortfolioController {
|
|||||||
filteredValueInPercentage,
|
filteredValueInPercentage,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
holdings,
|
holdings,
|
||||||
|
platforms,
|
||||||
summary,
|
summary,
|
||||||
totalValueInBaseCurrency
|
totalValueInBaseCurrency
|
||||||
} = await this.portfolioService.getDetails({
|
} = await this.portfolioService.getDetails({
|
||||||
@ -136,9 +137,12 @@ export class PortfolioController {
|
|||||||
portfolioPosition.value / totalValue;
|
portfolioPosition.value / totalValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
|
||||||
accounts[name].current = current / totalValue;
|
accounts[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
||||||
accounts[name].original = original / totalInvestment;
|
}
|
||||||
|
|
||||||
|
for (const [name, { valueInBaseCurrency }] of Object.entries(platforms)) {
|
||||||
|
platforms[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,6 +162,7 @@ export class PortfolioController {
|
|||||||
'excludedAccountsAndActivities',
|
'excludedAccountsAndActivities',
|
||||||
'fees',
|
'fees',
|
||||||
'items',
|
'items',
|
||||||
|
'liabilities',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
'totalSell'
|
'totalSell'
|
||||||
@ -182,6 +187,7 @@ export class PortfolioController {
|
|||||||
filteredValueInPercentage,
|
filteredValueInPercentage,
|
||||||
hasError,
|
hasError,
|
||||||
holdings,
|
holdings,
|
||||||
|
platforms,
|
||||||
totalValueInBaseCurrency,
|
totalValueInBaseCurrency,
|
||||||
summary: portfolioSummary
|
summary: portfolioSummary
|
||||||
};
|
};
|
||||||
@ -253,11 +259,12 @@ export class PortfolioController {
|
|||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
let investments = await this.portfolioService.getInvestments({
|
let { investments, streaks } = await this.portfolioService.getInvestments({
|
||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
groupBy,
|
groupBy,
|
||||||
impersonationId
|
impersonationId,
|
||||||
|
savingsRate: this.request.user?.Settings?.settings.savingsRate
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -273,6 +280,11 @@ export class PortfolioController {
|
|||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment / maxInvestment
|
investment: item.investment / maxInvestment
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
streaks = nullifyValuesInObject(streaks, [
|
||||||
|
'currentStreak',
|
||||||
|
'longestStreak'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -282,9 +294,14 @@ export class PortfolioController {
|
|||||||
investments = investments.map((item) => {
|
investments = investments.map((item) => {
|
||||||
return nullifyValuesInObject(item, ['investment']);
|
return nullifyValuesInObject(item, ['investment']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
streaks = nullifyValuesInObject(streaks, [
|
||||||
|
'currentStreak',
|
||||||
|
'longestStreak'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { investments };
|
return { investments, streaks };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
|
@ -3,14 +3,14 @@ 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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
@ -7,18 +7,15 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
|
|||||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||||
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
|
||||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
||||||
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-initial-investment';
|
|
||||||
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
||||||
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
|
|
||||||
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
EMERGENCY_FUND_TAG_ID,
|
EMERGENCY_FUND_TAG_ID,
|
||||||
MAX_CHART_ITEMS,
|
MAX_CHART_ITEMS,
|
||||||
@ -31,14 +28,14 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
Position,
|
Position,
|
||||||
TimelinePosition,
|
TimelinePosition,
|
||||||
UserSettings,
|
UserSettings
|
||||||
UserWithSettings
|
|
||||||
} 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 {
|
||||||
@ -47,7 +44,8 @@ import type {
|
|||||||
GroupBy,
|
GroupBy,
|
||||||
Market,
|
Market,
|
||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
RequestWithUser
|
RequestWithUser,
|
||||||
|
UserWithSettings
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
@ -149,7 +147,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0;
|
const valueInBaseCurrency =
|
||||||
|
details.accounts[account.id]?.valueInBaseCurrency ?? 0;
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
...account,
|
...account,
|
||||||
@ -254,13 +253,15 @@ export class PortfolioService {
|
|||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
groupBy,
|
groupBy,
|
||||||
impersonationId
|
impersonationId,
|
||||||
|
savingsRate
|
||||||
}: {
|
}: {
|
||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
groupBy?: GroupBy;
|
groupBy?: GroupBy;
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
}): Promise<InvestmentItem[]> {
|
savingsRate: number;
|
||||||
|
}): Promise<PortfolioInvestments> {
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
@ -278,7 +279,10 @@ export class PortfolioService {
|
|||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
if (transactionPoints.length === 0) {
|
if (transactionPoints.length === 0) {
|
||||||
return [];
|
return {
|
||||||
|
investments: [],
|
||||||
|
streaks: { currentStreak: 0, longestStreak: 0 }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let investments: InvestmentItem[];
|
let investments: InvestmentItem[];
|
||||||
@ -348,9 +352,23 @@ export class PortfolioService {
|
|||||||
parseDate(investments[0]?.date)
|
parseDate(investments[0]?.date)
|
||||||
);
|
);
|
||||||
|
|
||||||
return investments.filter(({ date }) => {
|
investments = investments.filter(({ date }) => {
|
||||||
return !isBefore(parseDate(date), startDate);
|
return !isBefore(parseDate(date), startDate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let streaks: PortfolioInvestments['streaks'];
|
||||||
|
|
||||||
|
if (savingsRate) {
|
||||||
|
streaks = this.getStreaks({
|
||||||
|
investments,
|
||||||
|
savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
investments,
|
||||||
|
streaks
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChart({
|
public async getChart({
|
||||||
@ -462,10 +480,18 @@ export class PortfolioService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const holdings: PortfolioDetails['holdings'] = {};
|
const holdings: PortfolioDetails['holdings'] = {};
|
||||||
const totalInvestmentInBaseCurrency = currentPositions.totalInvestment.plus(
|
const totalValueInBaseCurrency = currentPositions.currentValue.plus(
|
||||||
cashDetails.balanceInBaseCurrency
|
cashDetails.balanceInBaseCurrency
|
||||||
);
|
);
|
||||||
let filteredValueInBaseCurrency = currentPositions.currentValue;
|
|
||||||
|
const isFilteredByAccount =
|
||||||
|
filters?.some((filter) => {
|
||||||
|
return filter.type === 'ACCOUNT';
|
||||||
|
}) ?? false;
|
||||||
|
|
||||||
|
let filteredValueInBaseCurrency = isFilteredByAccount
|
||||||
|
? totalValueInBaseCurrency
|
||||||
|
: currentPositions.currentValue;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
filters?.length === 0 ||
|
filters?.length === 0 ||
|
||||||
@ -484,13 +510,10 @@ export class PortfolioService {
|
|||||||
symbol: position.symbol
|
symbol: position.symbol
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const symbols = currentPositions.positions.map(
|
|
||||||
(position) => position.symbol
|
|
||||||
);
|
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.getQuotes(dataGatheringItems),
|
this.dataProviderService.getQuotes(dataGatheringItems),
|
||||||
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
|
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||||
@ -564,12 +587,11 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const isFilteredByCash = filters?.some((filter) => {
|
||||||
filters?.length === 0 ||
|
return filter.type === 'ASSET_CLASS' && filter.id === 'CASH';
|
||||||
(filters?.length === 1 &&
|
});
|
||||||
filters[0].type === 'ASSET_CLASS' &&
|
|
||||||
filters[0].id === 'CASH')
|
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
|
||||||
) {
|
|
||||||
const cashPositions = await this.getCashPositions({
|
const cashPositions = await this.getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
@ -581,7 +603,7 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await this.getValueOfAccounts({
|
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
|
||||||
filters,
|
filters,
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
@ -595,7 +617,7 @@ export class PortfolioService {
|
|||||||
filters[0].id === EMERGENCY_FUND_TAG_ID &&
|
filters[0].id === EMERGENCY_FUND_TAG_ID &&
|
||||||
filters[0].type === 'TAG'
|
filters[0].type === 'TAG'
|
||||||
) {
|
) {
|
||||||
const cashPositions = await this.getCashPositions({
|
const emergencyFundCashPositions = await this.getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
value: filteredValueInBaseCurrency
|
value: filteredValueInBaseCurrency
|
||||||
@ -614,13 +636,12 @@ export class PortfolioService {
|
|||||||
accounts[UNKNOWN_KEY] = {
|
accounts[UNKNOWN_KEY] = {
|
||||||
balance: 0,
|
balance: 0,
|
||||||
currency: userCurrency,
|
currency: userCurrency,
|
||||||
current: emergencyFundInCash,
|
|
||||||
name: UNKNOWN_KEY,
|
name: UNKNOWN_KEY,
|
||||||
original: emergencyFundInCash
|
valueInBaseCurrency: emergencyFundInCash
|
||||||
};
|
};
|
||||||
|
|
||||||
holdings[userCurrency] = {
|
holdings[userCurrency] = {
|
||||||
...cashPositions[userCurrency],
|
...emergencyFundCashPositions[userCurrency],
|
||||||
investment: emergencyFundInCash,
|
investment: emergencyFundInCash,
|
||||||
value: emergencyFundInCash
|
value: emergencyFundInCash
|
||||||
};
|
};
|
||||||
@ -640,6 +661,7 @@ export class PortfolioService {
|
|||||||
return {
|
return {
|
||||||
accounts,
|
accounts,
|
||||||
holdings,
|
holdings,
|
||||||
|
platforms,
|
||||||
summary,
|
summary,
|
||||||
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
|
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
|
||||||
filteredValueInPercentage: summary.netWorth
|
filteredValueInPercentage: summary.netWorth
|
||||||
@ -792,16 +814,6 @@ export class PortfolioService {
|
|||||||
let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
|
let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
|
||||||
let minPrice = Math.min(orders[0].unitPrice, marketPrice);
|
let minPrice = Math.min(orders[0].unitPrice, marketPrice);
|
||||||
|
|
||||||
if (!historicalData?.[aSymbol]?.[firstBuyDate]) {
|
|
||||||
// Add historical entry for buy date, if no historical data available
|
|
||||||
historicalDataArray.push({
|
|
||||||
averagePrice: orders[0].unitPrice,
|
|
||||||
date: firstBuyDate,
|
|
||||||
marketPrice: orders[0].unitPrice,
|
|
||||||
quantity: orders[0].quantity
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (historicalData[aSymbol]) {
|
if (historicalData[aSymbol]) {
|
||||||
let j = -1;
|
let j = -1;
|
||||||
for (const [date, { marketPrice }] of Object.entries(
|
for (const [date, { marketPrice }] of Object.entries(
|
||||||
@ -813,11 +825,16 @@ export class PortfolioService {
|
|||||||
) {
|
) {
|
||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentAveragePrice = 0;
|
let currentAveragePrice = 0;
|
||||||
let currentQuantity = 0;
|
let currentQuantity = 0;
|
||||||
|
|
||||||
const currentSymbol = transactionPoints[j].items.find(
|
const currentSymbol = transactionPoints[j].items.find(
|
||||||
(item) => item.symbol === aSymbol
|
({ symbol }) => {
|
||||||
|
return symbol === aSymbol;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentSymbol) {
|
if (currentSymbol) {
|
||||||
currentAveragePrice = currentSymbol.quantity.eq(0)
|
currentAveragePrice = currentSymbol.quantity.eq(0)
|
||||||
? 0
|
? 0
|
||||||
@ -827,14 +844,25 @@ export class PortfolioService {
|
|||||||
|
|
||||||
historicalDataArray.push({
|
historicalDataArray.push({
|
||||||
date,
|
date,
|
||||||
marketPrice,
|
|
||||||
averagePrice: currentAveragePrice,
|
averagePrice: currentAveragePrice,
|
||||||
|
marketPrice:
|
||||||
|
historicalDataArray.length > 0
|
||||||
|
? marketPrice
|
||||||
|
: currentAveragePrice,
|
||||||
quantity: currentQuantity
|
quantity: currentQuantity
|
||||||
});
|
});
|
||||||
|
|
||||||
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
||||||
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
|
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Add historical entry for buy date, if no historical data available
|
||||||
|
historicalDataArray.push({
|
||||||
|
averagePrice: orders[0].unitPrice,
|
||||||
|
date: firstBuyDate,
|
||||||
|
marketPrice: orders[0].unitPrice,
|
||||||
|
quantity: orders[0].quantity
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -979,11 +1007,13 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const symbols = positions.map((position) => position.symbol);
|
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.getQuotes(dataGatheringItem),
|
this.dataProviderService.getQuotes(dataGatheringItem),
|
||||||
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
|
this.symbolProfileService.getSymbolProfiles(
|
||||||
|
positions.map(({ dataSource, symbol }) => {
|
||||||
|
return { dataSource, symbol };
|
||||||
|
})
|
||||||
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||||
@ -1168,7 +1198,7 @@ export class PortfolioService {
|
|||||||
portfolioItemsNow[position.symbol] = position;
|
portfolioItemsNow[position.symbol] = position;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await this.getValueOfAccounts({
|
const { accounts } = await this.getValueOfAccountsAndPlatforms({
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
@ -1179,10 +1209,6 @@ export class PortfolioService {
|
|||||||
rules: {
|
rules: {
|
||||||
accountClusterRisk: await this.rulesService.evaluate(
|
accountClusterRisk: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
new AccountClusterRiskInitialInvestment(
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
accounts
|
|
||||||
),
|
|
||||||
new AccountClusterRiskCurrentInvestment(
|
new AccountClusterRiskCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
accounts
|
accounts
|
||||||
@ -1196,18 +1222,10 @@ export class PortfolioService {
|
|||||||
),
|
),
|
||||||
currencyClusterRisk: await this.rulesService.evaluate(
|
currencyClusterRisk: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
positions
|
|
||||||
),
|
|
||||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
positions
|
positions
|
||||||
),
|
),
|
||||||
new CurrencyClusterRiskInitialInvestment(
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
positions
|
|
||||||
),
|
|
||||||
new CurrencyClusterRiskCurrentInvestment(
|
new CurrencyClusterRiskCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
positions
|
positions
|
||||||
@ -1284,12 +1302,11 @@ export class PortfolioService {
|
|||||||
}: {
|
}: {
|
||||||
activities: OrderWithAccount[];
|
activities: OrderWithAccount[];
|
||||||
date?: Date;
|
date?: Date;
|
||||||
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
}) {
|
}) {
|
||||||
return activities
|
return activities
|
||||||
.filter((activity) => {
|
.filter((activity) => {
|
||||||
// Filter out all activities before given date and type dividend
|
// Filter out all activities before given date (drafts) and type dividend
|
||||||
return (
|
return (
|
||||||
isBefore(date, new Date(activity.date)) &&
|
isBefore(date, new Date(activity.date)) &&
|
||||||
activity.type === TypeOfOrder.DIVIDEND
|
activity.type === TypeOfOrder.DIVIDEND
|
||||||
@ -1413,7 +1430,7 @@ export class PortfolioService {
|
|||||||
}) {
|
}) {
|
||||||
return activities
|
return activities
|
||||||
.filter((activity) => {
|
.filter((activity) => {
|
||||||
// Filter out all activities before given date
|
// Filter out all activities before given date (drafts)
|
||||||
return isBefore(date, new Date(activity.date));
|
return isBefore(date, new Date(activity.date));
|
||||||
})
|
})
|
||||||
.map(({ fee, SymbolProfile }) => {
|
.map(({ fee, SymbolProfile }) => {
|
||||||
@ -1460,19 +1477,37 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
|
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
|
||||||
return orders
|
return activities
|
||||||
.filter((order) => {
|
.filter((activity) => {
|
||||||
// Filter out all orders before given date and type item
|
// Filter out all activities before given date (drafts) and type item
|
||||||
return (
|
return (
|
||||||
isBefore(date, new Date(order.date)) &&
|
isBefore(date, new Date(activity.date)) &&
|
||||||
order.type === TypeOfOrder.ITEM
|
activity.type === TypeOfOrder.ITEM
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
new Big(quantity).mul(unitPrice).toNumber(),
|
||||||
order.SymbolProfile.currency,
|
SymbolProfile.currency,
|
||||||
|
this.request.user.Settings.settings.baseCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLiabilities(activities: OrderWithAccount[]) {
|
||||||
|
return activities
|
||||||
|
.filter(({ type }) => {
|
||||||
|
return type === TypeOfOrder.LIABILITY;
|
||||||
|
})
|
||||||
|
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
new Big(quantity).mul(unitPrice).toNumber(),
|
||||||
|
SymbolProfile.currency,
|
||||||
this.request.user.Settings.settings.baseCurrency
|
this.request.user.Settings.settings.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@ -1512,6 +1547,28 @@ export class PortfolioService {
|
|||||||
return portfolioStart;
|
return portfolioStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getStreaks({
|
||||||
|
investments,
|
||||||
|
savingsRate
|
||||||
|
}: {
|
||||||
|
investments: InvestmentItem[];
|
||||||
|
savingsRate: number;
|
||||||
|
}) {
|
||||||
|
let currentStreak = 0;
|
||||||
|
let longestStreak = 0;
|
||||||
|
|
||||||
|
for (const { investment } of investments) {
|
||||||
|
if (investment >= savingsRate) {
|
||||||
|
currentStreak++;
|
||||||
|
longestStreak = Math.max(longestStreak, currentStreak);
|
||||||
|
} else {
|
||||||
|
currentStreak = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentStreak, longestStreak };
|
||||||
|
}
|
||||||
|
|
||||||
private async getSummary({
|
private async getSummary({
|
||||||
balanceInBaseCurrency,
|
balanceInBaseCurrency,
|
||||||
emergencyFundPositionsValueInBaseCurrency,
|
emergencyFundPositionsValueInBaseCurrency,
|
||||||
@ -1561,6 +1618,7 @@ export class PortfolioService {
|
|||||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||||
const firstOrderDate = activities[0]?.date;
|
const firstOrderDate = activities[0]?.date;
|
||||||
const items = this.getItems(activities).toNumber();
|
const items = this.getItems(activities).toNumber();
|
||||||
|
const liabilities = this.getLiabilities(activities).toNumber();
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
||||||
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
||||||
@ -1593,6 +1651,7 @@ export class PortfolioService {
|
|||||||
.plus(performanceInformation.performance.currentValue)
|
.plus(performanceInformation.performance.currentValue)
|
||||||
.plus(items)
|
.plus(items)
|
||||||
.plus(excludedAccountsAndActivities)
|
.plus(excludedAccountsAndActivities)
|
||||||
|
.minus(liabilities)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||||
@ -1619,6 +1678,7 @@ export class PortfolioService {
|
|||||||
fees,
|
fees,
|
||||||
firstOrderDate,
|
firstOrderDate,
|
||||||
items,
|
items,
|
||||||
|
liabilities,
|
||||||
netWorth,
|
netWorth,
|
||||||
totalBuy,
|
totalBuy,
|
||||||
totalSell,
|
totalSell,
|
||||||
@ -1700,7 +1760,7 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getValueOfAccounts({
|
private async getValueOfAccountsAndPlatforms({
|
||||||
filters = [],
|
filters = [],
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
@ -1724,6 +1784,7 @@ export class PortfolioService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const accounts: PortfolioDetails['accounts'] = {};
|
const accounts: PortfolioDetails['accounts'] = {};
|
||||||
|
const platforms: PortfolioDetails['platforms'] = {};
|
||||||
|
|
||||||
let currentAccounts: (Account & {
|
let currentAccounts: (Account & {
|
||||||
Order?: Order[];
|
Order?: Order[];
|
||||||
@ -1734,6 +1795,7 @@ export class PortfolioService {
|
|||||||
currentAccounts = await this.accountService.getAccounts(userId);
|
currentAccounts = await this.accountService.getAccounts(userId);
|
||||||
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
|
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
|
||||||
currentAccounts = await this.accountService.accounts({
|
currentAccounts = await this.accountService.accounts({
|
||||||
|
include: { Platform: true },
|
||||||
where: { id: filters[0].id }
|
where: { id: filters[0].id }
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -1744,6 +1806,7 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
currentAccounts = await this.accountService.accounts({
|
currentAccounts = await this.accountService.accounts({
|
||||||
|
include: { Platform: true },
|
||||||
where: { id: { in: accountIds } }
|
where: { id: { in: accountIds } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1768,65 +1831,76 @@ export class PortfolioService {
|
|||||||
accounts[account.id] = {
|
accounts[account.id] = {
|
||||||
balance: account.balance,
|
balance: account.balance,
|
||||||
currency: account.currency,
|
currency: account.currency,
|
||||||
current: this.exchangeRateDataService.toCurrency(
|
|
||||||
account.balance,
|
|
||||||
account.currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
name: account.name,
|
name: account.name,
|
||||||
original: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
account.balance,
|
account.balance,
|
||||||
account.currency,
|
account.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (platforms[account.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||||
|
platforms[account.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
userCurrency
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
platforms[account.Platform?.id || UNKNOWN_KEY] = {
|
||||||
|
balance: account.balance,
|
||||||
|
currency: account.currency,
|
||||||
|
name: account.Platform?.name,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
for (const order of ordersByAccount) {
|
for (const order of ordersByAccount) {
|
||||||
let currentValueOfSymbolInBaseCurrency =
|
let currentValueOfSymbolInBaseCurrency =
|
||||||
order.quantity *
|
order.quantity *
|
||||||
(portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ??
|
(portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ??
|
||||||
order.unitPrice ??
|
order.unitPrice ??
|
||||||
0);
|
0);
|
||||||
let originalValueOfSymbolInBaseCurrency =
|
|
||||||
this.exchangeRateDataService.toCurrency(
|
|
||||||
order.quantity * order.unitPrice,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
);
|
|
||||||
|
|
||||||
if (order.type === 'SELL') {
|
if (order.type === 'SELL') {
|
||||||
currentValueOfSymbolInBaseCurrency *= -1;
|
currentValueOfSymbolInBaseCurrency *= -1;
|
||||||
originalValueOfSymbolInBaseCurrency *= -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
if (accounts[order.Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||||
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
accounts[order.Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||||
currentValueOfSymbolInBaseCurrency;
|
currentValueOfSymbolInBaseCurrency;
|
||||||
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
|
||||||
originalValueOfSymbolInBaseCurrency;
|
|
||||||
} else {
|
} else {
|
||||||
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||||
balance: 0,
|
balance: 0,
|
||||||
currency: order.Account?.currency,
|
currency: order.Account?.currency,
|
||||||
current: currentValueOfSymbolInBaseCurrency,
|
|
||||||
name: account.name,
|
name: account.name,
|
||||||
original: originalValueOfSymbolInBaseCurrency
|
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
platforms[order.Account?.Platform?.id || UNKNOWN_KEY]
|
||||||
|
?.valueInBaseCurrency
|
||||||
|
) {
|
||||||
|
platforms[
|
||||||
|
order.Account?.Platform?.id || UNKNOWN_KEY
|
||||||
|
].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency;
|
||||||
|
} else {
|
||||||
|
platforms[order.Account?.Platform?.id || UNKNOWN_KEY] = {
|
||||||
|
balance: 0,
|
||||||
|
currency: order.Account?.currency,
|
||||||
|
name: account.Platform?.name,
|
||||||
|
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts;
|
return { accounts, platforms };
|
||||||
}
|
|
||||||
|
|
||||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
|
||||||
const impersonationUserId =
|
|
||||||
await this.impersonationService.validateImpersonationId(
|
|
||||||
aImpersonationId,
|
|
||||||
aUserId
|
|
||||||
);
|
|
||||||
|
|
||||||
return impersonationUserId || aUserId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTotalByType(
|
private getTotalByType(
|
||||||
@ -1855,4 +1929,11 @@ export class PortfolioService {
|
|||||||
this.baseCurrency
|
this.baseCurrency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||||
|
const impersonationUserId =
|
||||||
|
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
||||||
|
|
||||||
|
return impersonationUserId || aUserId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
||||||
import { Cache } from 'cache-manager';
|
import { Cache } from 'cache-manager';
|
||||||
|
|
||||||
@ -13,6 +14,10 @@ export class RedisCacheService {
|
|||||||
return await this.cache.get(key);
|
return await this.cache.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
return `quote-${dataSource}-${symbol}`;
|
||||||
|
}
|
||||||
|
|
||||||
public async remove(key: string) {
|
public async remove(key: string) {
|
||||||
await this.cache.del(key);
|
await this.cache.del(key);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
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';
|
||||||
|
|
||||||
|
@ -1,12 +1,8 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import {
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
DEFAULT_LANGUAGE_CODE,
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
PROPERTY_STRIPE_CONFIG
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { UserWithSettings } from '@ghostfolio/common/interfaces';
|
|
||||||
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
import { addMilliseconds, isBefore } from 'date-fns';
|
import { addMilliseconds, isBefore } from 'date-fns';
|
||||||
@ -101,19 +97,8 @@ export class SubscriptionService {
|
|||||||
aCheckoutSessionId
|
aCheckoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
let subscriptions: SubscriptionInterface[] = [];
|
|
||||||
|
|
||||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
|
||||||
})) ?? { value: '{}' };
|
|
||||||
|
|
||||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
|
||||||
|
|
||||||
const coupon = subscriptions[0]?.coupon ?? 0;
|
|
||||||
const price = subscriptions[0]?.price ?? 0;
|
|
||||||
|
|
||||||
await this.createSubscription({
|
await this.createSubscription({
|
||||||
price: price - coupon,
|
price: session.amount_total / 100,
|
||||||
userId: session.client_reference_id
|
userId: session.client_reference_id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -123,7 +108,9 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSubscription(aSubscriptions: Subscription[]) {
|
public getSubscription(
|
||||||
|
aSubscriptions: Subscription[]
|
||||||
|
): UserWithSettings['subscription'] {
|
||||||
if (aSubscriptions.length > 0) {
|
if (aSubscriptions.length > 0) {
|
||||||
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
||||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||||
@ -131,12 +118,14 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
expiresAt: latestSubscription.expiresAt,
|
expiresAt: latestSubscription.expiresAt,
|
||||||
|
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
|
||||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||||
? SubscriptionType.Premium
|
? SubscriptionType.Premium
|
||||||
: SubscriptionType.Basic
|
: SubscriptionType.Basic
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
|
offer: 'default',
|
||||||
type: SubscriptionType.Basic
|
type: SubscriptionType.Basic
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { DataSource } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface LookupItem {
|
export interface LookupItem {
|
||||||
|
assetClass: AssetClass;
|
||||||
|
assetSubClass: AssetSubClass;
|
||||||
currency: string;
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -21,7 +24,10 @@ import { SymbolService } from './symbol.service';
|
|||||||
|
|
||||||
@Controller('symbol')
|
@Controller('symbol')
|
||||||
export class SymbolController {
|
export class SymbolController {
|
||||||
public constructor(private readonly symbolService: SymbolService) {}
|
public constructor(
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly symbolService: SymbolService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be before /:symbol
|
* Must be before /:symbol
|
||||||
@ -30,10 +36,15 @@ export class SymbolController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async lookupSymbol(
|
public async lookupSymbol(
|
||||||
@Query() { query = '' }
|
@Query('includeIndices') includeIndices: boolean = false,
|
||||||
|
@Query('query') query = ''
|
||||||
): Promise<{ items: LookupItem[] }> {
|
): Promise<{ items: LookupItem[] }> {
|
||||||
try {
|
try {
|
||||||
return this.symbolService.lookup(query.toLowerCase());
|
return this.symbolService.lookup({
|
||||||
|
includeIndices,
|
||||||
|
query: query.toLowerCase(),
|
||||||
|
user: this.request.user
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
@ -51,7 +62,7 @@ export class SymbolController {
|
|||||||
public async getSymbolData(
|
public async getSymbolData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@Query('includeHistoricalData') includeHistoricalData?: number
|
@Query('includeHistoricalData') includeHistoricalData = 0
|
||||||
): Promise<SymbolItem> {
|
): Promise<SymbolItem> {
|
||||||
if (!DataSource[dataSource]) {
|
if (!DataSource[dataSource]) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { SymbolController } from './symbol.controller';
|
import { SymbolController } from './symbol.controller';
|
||||||
|
@ -3,9 +3,10 @@ import {
|
|||||||
IDataGatheringItem,
|
IDataGatheringItem,
|
||||||
IDataProviderHistoricalResponse
|
IDataProviderHistoricalResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/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 { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
@ -79,15 +80,27 @@ export class SymbolService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async lookup({
|
||||||
|
includeIndices = false,
|
||||||
|
query,
|
||||||
|
user
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
user: UserWithSettings;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
const results: { items: LookupItem[] } = { items: [] };
|
const results: { items: LookupItem[] } = { items: [] };
|
||||||
|
|
||||||
if (!aQuery) {
|
if (!query) {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { items } = await this.dataProviderService.search(aQuery);
|
const { items } = await this.dataProviderService.search({
|
||||||
|
includeIndices,
|
||||||
|
query,
|
||||||
|
user
|
||||||
|
});
|
||||||
results.items = items;
|
results.items = items;
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -3,17 +3,20 @@ import type {
|
|||||||
DateRange,
|
DateRange,
|
||||||
ViewMode
|
ViewMode
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
|
IsIn,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingDto {
|
export class UpdateUserSettingDto {
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
annualInterestRate?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
baseCurrency?: string;
|
baseCurrency?: string;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
@ -1,19 +1,17 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
||||||
import {
|
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
User as IUser,
|
|
||||||
UserSettings,
|
|
||||||
UserWithSettings
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasRole,
|
hasRole,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, Role, User } from '@prisma/client';
|
import { Prisma, Role, User } from '@prisma/client';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
@ -168,7 +166,7 @@ export class UserService {
|
|||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Analytics?.activityCount % 25 === 0 &&
|
Analytics?.activityCount % 10 === 0 &&
|
||||||
user.subscription?.type === 'Basic'
|
user.subscription?.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||||
@ -199,6 +197,10 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!environment.production && role === 'ADMIN') {
|
||||||
|
currentPermissions.push(permissions.impersonateAllUsers);
|
||||||
|
}
|
||||||
|
|
||||||
user.Account = sortBy(user.Account, (account) => {
|
user.Account = sortBy(user.Account, (account) => {
|
||||||
return account.name;
|
return account.name;
|
||||||
});
|
});
|
||||||
@ -302,21 +304,29 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
|
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
|
||||||
await this.prismaService.access.deleteMany({
|
try {
|
||||||
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
await this.prismaService.access.deleteMany({
|
||||||
});
|
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
await this.prismaService.account.deleteMany({
|
try {
|
||||||
where: { userId: where.id }
|
await this.prismaService.account.deleteMany({
|
||||||
});
|
where: { userId: where.id }
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
await this.prismaService.analytics.delete({
|
try {
|
||||||
where: { userId: where.id }
|
await this.prismaService.analytics.delete({
|
||||||
});
|
where: { userId: where.id }
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
await this.prismaService.order.deleteMany({
|
try {
|
||||||
where: { userId: where.id }
|
await this.prismaService.order.deleteMany({
|
||||||
});
|
where: { userId: where.id }
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.prismaService.settings.delete({
|
await this.prismaService.settings.delete({
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -16,9 +16,11 @@ export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
|||||||
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
|
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
|
||||||
const object = cloneDeep(aObject);
|
const object = cloneDeep(aObject);
|
||||||
|
|
||||||
keys.forEach((key) => {
|
if (object) {
|
||||||
object[key] = null;
|
keys.forEach((key) => {
|
||||||
});
|
object[key] = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { decodeDataSource } from '@ghostfolio/common/helper';
|
import { decodeDataSource } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
@ -5,10 +6,9 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor
|
NestInterceptor
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { ConfigurationService } from '../services/configuration.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformDataSourceInRequestInterceptor<T>
|
export class TransformDataSourceInRequestInterceptor<T>
|
||||||
implements NestInterceptor<T, any>
|
implements NestInterceptor<T, any>
|
||||||
@ -25,11 +25,24 @@ export class TransformDataSourceInRequestInterceptor<T>
|
|||||||
const request = http.getRequest();
|
const request = http.getRequest();
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
if (request.body.dataSource) {
|
if (request.body.activities) {
|
||||||
|
request.body.activities = request.body.activities.map((activity) => {
|
||||||
|
if (DataSource[activity.dataSource]) {
|
||||||
|
return activity;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...activity,
|
||||||
|
dataSource: decodeDataSource(activity.dataSource)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.body.dataSource && !DataSource[request.body.dataSource]) {
|
||||||
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.dataSource) {
|
if (request.params.dataSource && !DataSource[request.params.dataSource]) {
|
||||||
request.params.dataSource = decodeDataSource(request.params.dataSource);
|
request.params.dataSource = decodeDataSource(request.params.dataSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
@ -10,8 +11,6 @@ import { DataSource } from '@prisma/client';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
import { ConfigurationService } from '../services/configuration.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformDataSourceInResponseInterceptor<T>
|
export class TransformDataSourceInResponseInterceptor<T>
|
||||||
implements NestInterceptor<T, any>
|
implements NestInterceptor<T, any>
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import * as bodyParser from 'body-parser';
|
import * as bodyParser from 'body-parser';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
import { environment } from './environments/environment';
|
import { environment } from './environments/environment';
|
||||||
@ -10,11 +12,12 @@ async function bootstrap() {
|
|||||||
const configApp = await NestFactory.create(AppModule);
|
const configApp = await NestFactory.create(AppModule);
|
||||||
const configService = configApp.get<ConfigService>(ConfigService);
|
const configService = configApp.get<ConfigService>(ConfigService);
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
logger: environment.production
|
logger: environment.production
|
||||||
? ['error', 'log', 'warn']
|
? ['error', 'log', 'warn']
|
||||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||||
});
|
});
|
||||||
|
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
app.enableVersioning({
|
app.enableVersioning({
|
||||||
defaultVersion: '1',
|
defaultVersion: '1',
|
||||||
@ -32,12 +35,39 @@ async function bootstrap() {
|
|||||||
// Support 10mb csv/json files for importing activities
|
// Support 10mb csv/json files for importing activities
|
||||||
app.use(bodyParser.json({ limit: '10mb' }));
|
app.use(bodyParser.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
|
||||||
|
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
|
||||||
|
}
|
||||||
|
},
|
||||||
|
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
|
||||||
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||||
const PORT = configService.get<number>('PORT') || 3333;
|
const PORT = configService.get<number>('PORT') || 3333;
|
||||||
|
|
||||||
await app.listen(PORT, HOST, () => {
|
await app.listen(PORT, HOST, () => {
|
||||||
logLogo();
|
logLogo();
|
||||||
Logger.log(`Listening at http://${HOST}:${PORT}`);
|
Logger.log(`Listening at http://${HOST}:${PORT}`);
|
||||||
Logger.log('');
|
Logger.log('');
|
||||||
|
|
||||||
|
if (BASE_CURRENCY) {
|
||||||
|
Logger.warn(
|
||||||
|
`The environment variable "BASE_CURRENCY" is deprecated and will be removed in Ghostfolio 2.0.`
|
||||||
|
);
|
||||||
|
Logger.warn(
|
||||||
|
'Please use the currency converter in the activity dialog instead.'
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
|
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
|
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { IOrder } from '../services/interfaces/interfaces';
|
|
||||||
|
|
||||||
export class Order {
|
export class Order {
|
||||||
private account: Account;
|
private account: Account;
|
||||||
private currency: string;
|
private currency: string;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { groupBy } from '@ghostfolio/common/helper';
|
import { groupBy } from '@ghostfolio/common/helper';
|
||||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
@ -14,7 +14,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
private accounts: PortfolioDetails['accounts']
|
private accounts: PortfolioDetails['accounts']
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Current Investment'
|
name: 'Investment'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
|||||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||||
accounts[accountId] = {
|
accounts[accountId] = {
|
||||||
name: account.name,
|
name: account.name,
|
||||||
investment: account.current
|
investment: account.valueInBaseCurrency
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,88 +0,0 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
|
||||||
import {
|
|
||||||
PortfolioDetails,
|
|
||||||
PortfolioPosition,
|
|
||||||
UserSettings
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
|
||||||
public constructor(
|
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private accounts: PortfolioDetails['accounts']
|
|
||||||
) {
|
|
||||||
super(exchangeRateDataService, {
|
|
||||||
name: 'Initial Investment'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public evaluate(ruleSettings?: Settings) {
|
|
||||||
const accounts: {
|
|
||||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
|
||||||
investment: number;
|
|
||||||
};
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
|
||||||
accounts[accountId] = {
|
|
||||||
name: account.name,
|
|
||||||
investment: account.original
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let maxItem;
|
|
||||||
let totalInvestment = 0;
|
|
||||||
|
|
||||||
for (const account of Object.values(accounts)) {
|
|
||||||
if (!maxItem) {
|
|
||||||
maxItem = account;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total investment
|
|
||||||
totalInvestment += account.investment;
|
|
||||||
|
|
||||||
// Find maximum
|
|
||||||
if (account.investment > maxItem?.investment) {
|
|
||||||
maxItem = account;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
|
||||||
|
|
||||||
if (maxInvestmentRatio > ruleSettings.threshold) {
|
|
||||||
return {
|
|
||||||
evaluation: `Over ${
|
|
||||||
ruleSettings.threshold * 100
|
|
||||||
}% of your initial investment is at ${maxItem.name} (${(
|
|
||||||
maxInvestmentRatio * 100
|
|
||||||
).toPrecision(3)}%)`,
|
|
||||||
value: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
evaluation: `The major part of your initial investment is at ${
|
|
||||||
maxItem.name
|
|
||||||
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
|
|
||||||
ruleSettings.threshold * 100
|
|
||||||
}%`,
|
|
||||||
value: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getSettings(aUserSettings: UserSettings): Settings {
|
|
||||||
return {
|
|
||||||
baseCurrency: aUserSettings.baseCurrency,
|
|
||||||
isActive: true,
|
|
||||||
threshold: 0.5
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Settings extends RuleSettings {
|
|
||||||
baseCurrency: string;
|
|
||||||
isActive: boolean;
|
|
||||||
threshold: number;
|
|
||||||
}
|
|
@ -1,5 +1,5 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
|
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user