Compare commits
183 Commits
Author | SHA1 | Date | |
---|---|---|---|
56c332c59a | |||
423ceec317 | |||
5b4a1785ae | |||
56a5664c87 | |||
823501f43e | |||
331ac7ded2 | |||
92b44ba658 | |||
982ba7377a | |||
4bbd17a37a | |||
6a7def6c48 | |||
ea219f0b88 | |||
23b2e03923 | |||
04e6518226 | |||
72dbe00091 | |||
9834c52739 | |||
c47578bd3e | |||
9e4a49d811 | |||
a3a98c68a5 | |||
21570cca19 | |||
71a3115fc6 | |||
de83dc7b84 | |||
4a0695613e | |||
328d814922 | |||
d23addb673 | |||
fb15cebb64 | |||
9c51a257ae | |||
f9b9dc32cb | |||
e7194ef3ce | |||
ec5523b459 | |||
c8c21a016a | |||
9821b7f8f0 | |||
ed731afc66 | |||
ff15d5cbc4 | |||
3c4949de35 | |||
bd0e53525b | |||
cbdb68e2f8 | |||
8571709014 | |||
e7ef1d426e | |||
39cba0a8eb | |||
a90c314e30 | |||
47d71405e1 | |||
5e9cecc6c1 | |||
fb9e66318f | |||
b8194eb64f | |||
cbb81916ee | |||
9b1e9397a8 | |||
b779964adb | |||
409afac2a9 | |||
e0a4e16ea1 | |||
dc84abdc0a | |||
b031b028f1 | |||
3b7e0a0106 | |||
ea66081073 | |||
602a770a09 | |||
e522722aa6 | |||
03ca5d7663 | |||
136563c949 | |||
948c45c602 | |||
e0be792e46 | |||
c3d010135f | |||
d6a16a6093 | |||
34c13c80ec | |||
f65a108436 | |||
993f066e08 | |||
852902d1ab | |||
ee89822bfe | |||
e0435e5cad | |||
e2c23703dc | |||
1226c26a9d | |||
fdc89f7182 | |||
1e368d6e2d | |||
04e03bd080 | |||
66e7ad3fd2 | |||
b4dc21dd61 | |||
8a482e63b9 | |||
aabfb39e8f | |||
cdc8faff7f | |||
7b696e39de | |||
c88ad2c225 | |||
fbc9269abf | |||
cbe079ae66 | |||
8e4ee7feea | |||
f1b3c61675 | |||
24dc312367 | |||
7ac7442f73 | |||
099571437e | |||
7dac059a55 | |||
48fbeda72d | |||
19007cdc34 | |||
5037393866 | |||
ddf24163b4 | |||
b26521c4bd | |||
cfee6c1ddd | |||
19bcd601d1 | |||
836df69e68 | |||
dd86adcea1 | |||
4f7628921d | |||
88f0cb095d | |||
7538133d09 | |||
50b280c5a6 | |||
67606e4026 | |||
9de56c32ac | |||
5c0f710563 | |||
1c65599a16 | |||
61e667213e | |||
ba47212057 | |||
f0c6517019 | |||
80ba112bc0 | |||
40696b425e | |||
6dbdf23a68 | |||
cdcbe3ab71 | |||
6996e5a140 | |||
be8d60968d | |||
d53e5c4da5 | |||
a3a9957196 | |||
9072cbdba1 | |||
120b691336 | |||
bd4ad76953 | |||
94d56f553f | |||
ecdd325228 | |||
51fbc538ca | |||
39a76f7f40 | |||
e4d325daab | |||
b765df65d6 | |||
c7b7efae3b | |||
be5b58f49a | |||
91c748c7ad | |||
ecfe694f0b | |||
1491bf7f76 | |||
b3b9a051c3 | |||
bf1146bfd6 | |||
0774ca91a1 | |||
f403807f2d | |||
f22991b090 | |||
1135a5b335 | |||
d9ea255c17 | |||
2c19d8c8e7 | |||
db090229ce | |||
fbe590ddb9 | |||
0d65136a9e | |||
dea87cc3cf | |||
a062a3cee4 | |||
5b1b207a6f | |||
63cc7b2871 | |||
3986e8f879 | |||
290e93bbd7 | |||
b08ecd1b18 | |||
92d321a001 | |||
ce2d8d519d | |||
f32bef071e | |||
4aa7365d9b | |||
367f25a975 | |||
9832334da1 | |||
e126f9ec54 | |||
09bbda3502 | |||
ee9a521813 | |||
169c151547 | |||
3a95ec0f81 | |||
ad00cd9d81 | |||
373a2015c0 | |||
66c955ad6c | |||
a2440fc067 | |||
3d7624d997 | |||
0264b592b9 | |||
198eaf57d3 | |||
6783ea2ebb | |||
a35701fe24 | |||
5db90f1787 | |||
81fe538484 | |||
51884913be | |||
8886082dfa | |||
3b12e5b85b | |||
6c1119caec | |||
698d5ec3b7 | |||
e87c942cb8 | |||
f7860a9799 | |||
c519eb0e99 | |||
8314b98f81 | |||
194cf1ddcc | |||
7da6478699 | |||
4f2bbba782 | |||
9eb25f6c9e | |||
f74b00446c |
@ -44,7 +44,7 @@
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/member-ordering": "error",
|
||||
"@typescript-eslint/naming-convention": "error",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-empty-interface": "error",
|
||||
"@typescript-eslint/no-inferrable-types": [
|
||||
|
224
CHANGELOG.md
224
CHANGELOG.md
@ -5,8 +5,218 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.31.1 - 01.08.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the currency conversion in the portfolio calculations
|
||||
|
||||
## 1.31.0 - 01.08.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added more data points to the chart
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored the core engine for the portfolio calculations
|
||||
- Improved the performance of the portfolio calculations
|
||||
|
||||
## 1.30.0 - 31.07.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the date range component to the positions tab
|
||||
- Added a blog
|
||||
|
||||
## 1.29.0 - 26.07.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Introduced tabs on the home page
|
||||
- Changed the menu icon if the menu is open on mobile
|
||||
|
||||
## 1.28.0 - 24.07.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the data management by symbol profile data
|
||||
- Added a currency attribute to the symbol profile model
|
||||
- Added a positions button on the home page which scrolls into the view
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style of the active page in the navigation on desktop
|
||||
- Removed the footer for users
|
||||
- Extended the _Zen Mode_ by positions
|
||||
- Improved the _Create Account_ message in the _Live Demo_
|
||||
|
||||
## 1.27.0 - 18.07.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the onboarding
|
||||
- Flow of creating a new account
|
||||
- Info message to add the first transaction
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the chart on the landing page
|
||||
- Fixed the url to the _Fear & Greed Index_ on the resources page
|
||||
|
||||
## 1.26.0 - 17.07.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the import functionality for transactions
|
||||
- Added the `robots.txt` file
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling of the current pricing plan
|
||||
- Improved the styling of the transaction type badge
|
||||
- Set the public _Stripe_ key dynamically
|
||||
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the warn color (button) of the theme
|
||||
|
||||
## 1.25.0 - 11.07.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the export functionality for transactions
|
||||
|
||||
### Changed
|
||||
|
||||
- Respected the cash balance on the analysis page
|
||||
- Improved the settings selectors on the account page
|
||||
- Harmonized the slogan to "Open Source Wealth Management Software"
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed rendering of currency and platform in dialogs (account and transaction)
|
||||
- Fixed an issue in the calculation of the average buy prices in the position detail chart
|
||||
|
||||
## 1.24.0 - 07.07.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the total value in the create or edit transaction dialog
|
||||
- Added a balance attribute to the account model
|
||||
- Calculated the total balance (cash)
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `@angular/cdk` and `@angular/material` from version `11.0.4` to `12.0.6`
|
||||
- Upgraded `@nestjs` dependencies
|
||||
- Upgraded `angular-material-css-vars` from version `1.2.0` to `2.0.0`
|
||||
- Upgraded `Nx` from version `12.3.6` to `12.5.4`
|
||||
|
||||
## 1.23.1 - 03.07.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the investment chart (drafts)
|
||||
|
||||
## 1.23.0 - 03.07.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for future transactions (drafts)
|
||||
|
||||
## 1.22.0 - 25.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Set the user id in the _Stripe_ callback
|
||||
|
||||
## 1.21.0 - 22.06.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed _Stripe_ mode from `subscription` to `payment`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the base currency on the pricing page
|
||||
|
||||
## 1.20.0 - 21.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Set up _Stripe_ for subscriptions
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style of the _Ghostfolio in Numbers_ section
|
||||
|
||||
## 1.19.0 - 17.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a _Ghostfolio in Numbers_ section to the about page
|
||||
|
||||
## 1.18.0 - 16.06.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the pie chart: Investments by sector
|
||||
- Improved the onboarding for TWA by redirecting to the account registration page
|
||||
|
||||
## 1.17.0 - 15.06.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the error page of the sign in with fingerprint
|
||||
- Disable the sign in with fingerprint selector for the demo user
|
||||
- Upgraded `angular` from version `11.2.4` to `12.0.4`
|
||||
- Upgraded `angular-material-css-vars` from version `1.1.2` to `1.2.0`
|
||||
- Upgraded `chart.js` from version `3.2.1` to `3.3.2`
|
||||
- Upgraded `date-fns` from version `2.19.0` to `2.22.1`
|
||||
- Upgraded `eslint` and `prettier` dependencies
|
||||
- Upgraded `ngx-device-detector` from version `2.0.6` to `2.1.1`
|
||||
- Upgraded `ngx-markdown` from version `11.1.2` to `12.0.1`
|
||||
|
||||
## 1.16.0 - 14.06.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the sign in with fingerprint
|
||||
|
||||
## 1.15.0 - 14.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a counter column to the transactions table
|
||||
- Added a label to indicate the default account in the accounts table
|
||||
- Added an option to limit the items in pie charts
|
||||
- Added sign in with fingerprint
|
||||
|
||||
### Changed
|
||||
|
||||
- Cleaned up the analysis page with an unused chart module
|
||||
- Improved the cell alignment in the users table of the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the last activity column of users in the admin control panel
|
||||
|
||||
## 1.14.0 - 09.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a connect or create symbol profile model logic on creating a new transaction
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the global heat map to visualize investments by country
|
||||
|
||||
## 1.13.0 - 08.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a global heat map to visualize investments by country
|
||||
|
||||
## 1.12.0 - 06.06.2021
|
||||
@ -88,11 +298,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Added an index in the user table of the admin control panel
|
||||
- Added an index in the users table of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the alignment in the user table of the admin control panel
|
||||
- Improved the alignment in the users table of the admin control panel
|
||||
|
||||
## 1.5.0 - 22.05.2021
|
||||
|
||||
@ -224,7 +434,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the user table styling of the admin control panel
|
||||
- Improved the users table styling of the admin control panel
|
||||
- Improved the background colors in the dark mode
|
||||
|
||||
## 0.92.0 - 25.04.2021
|
||||
@ -232,7 +442,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Prepared further for multi accounts support: store account for new transactions
|
||||
- Added a horizontal scrollbar to the user table of the admin control panel
|
||||
- Added a horizontal scrollbar to the users table of the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -259,7 +469,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the user table of the admin control panel
|
||||
- Improved the users table of the admin control panel
|
||||
|
||||
## 0.89.0 - 21.04.2021
|
||||
|
||||
@ -290,7 +500,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the user table of the admin control panel with missing data
|
||||
- Fixed an issue in the users table of the admin control panel with missing data
|
||||
|
||||
## 0.86.1 - 18.04.2021
|
||||
|
||||
@ -305,7 +515,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Changed the about page for the new license
|
||||
- Optimized the data management for historical data
|
||||
- Optimized the exchange rate service
|
||||
- Improved the user table of the admin control panel
|
||||
- Improved the users table of the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
|
45
README.md
45
README.md
@ -1,21 +1,36 @@
|
||||
<div align="center">
|
||||
<a href="https://ghostfol.io">
|
||||
<img
|
||||
alt="Ghostfolio Logo"
|
||||
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
||||
width="100"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<h1>Ghostfolio</h1>
|
||||
<p>
|
||||
<strong>Open Source Portfolio Tracker</strong>
|
||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
|
||||
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow">
|
||||
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
||||
<a href="#contributing">
|
||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
||||
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
|
||||
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
||||
|
||||
## Ghostfolio Premium
|
||||
|
||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
|
||||
|
||||
## Why Ghostfolio?
|
||||
|
||||
@ -79,26 +94,34 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
||||
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
||||
1. Start server and client (see [_Development_](#Development))
|
||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
|
||||
1. Press _Sign out_ and check out the _Live Demo_
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
## Development
|
||||
|
||||
Please make sure you have completed the instructions from [_Setup_](#Setup)
|
||||
Please make sure you have completed the instructions from [_Setup_](#Setup).
|
||||
|
||||
### Start server
|
||||
|
||||
- Debug: Run `yarn watch:server` and click "Launch Program" in _Visual Studio Code_
|
||||
- Serve: Run `yarn start:server`
|
||||
<ol type="a">
|
||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
|
||||
<li>Serve: Run <code>yarn start:server</code></li>
|
||||
</ol>
|
||||
|
||||
### Start client
|
||||
|
||||
- Run `yarn start:client`
|
||||
Run `yarn start:client`
|
||||
|
||||
## Testing
|
||||
|
||||
Run `yarn test`
|
||||
|
||||
## Contributing
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||
|
||||
## License
|
||||
|
||||
© 2021 [Ghostfolio](https://ghostfol.io)
|
||||
|
17
angular.json
17
angular.json
@ -86,7 +86,6 @@
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"aot": true,
|
||||
"assets": [
|
||||
"apps/client/src/assets",
|
||||
{
|
||||
@ -104,6 +103,11 @@
|
||||
"input": "",
|
||||
"output": "./"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
@ -121,7 +125,13 @@
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/lib/marked.js"]
|
||||
"scripts": ["node_modules/marked/lib/marked.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@ -152,7 +162,8 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
|
@ -11,5 +11,6 @@ module.exports = {
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
testTimeout: 10000
|
||||
testTimeout: 10000,
|
||||
testEnvironment: 'node'
|
||||
};
|
||||
|
@ -4,6 +4,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -20,6 +21,7 @@ import { AccountService } from './account.service';
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
PrismaService,
|
||||
|
@ -1,12 +1,15 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Account, Order, Prisma } from '@prisma/client';
|
||||
import { Account, Currency, Order, Prisma } from '@prisma/client';
|
||||
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
import { CashDetails } from './interfaces/cash-details.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
public constructor(
|
||||
private exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
@ -73,6 +76,27 @@ export class AccountService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getCashDetails(
|
||||
aUserId: string,
|
||||
aCurrency: Currency
|
||||
): Promise<CashDetails> {
|
||||
let totalCashBalance = 0;
|
||||
|
||||
const accounts = await this.accounts({
|
||||
where: { userId: aUserId }
|
||||
});
|
||||
|
||||
accounts.forEach((account) => {
|
||||
totalCashBalance += this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
aCurrency
|
||||
);
|
||||
});
|
||||
|
||||
return { accounts, balance: totalCashBalance };
|
||||
}
|
||||
|
||||
public async updateAccount(
|
||||
params: {
|
||||
where: Prisma.AccountWhereUniqueInput;
|
||||
|
@ -1,10 +1,16 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsString, ValidateIf } from 'class-validator';
|
||||
import { AccountType, Currency } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString()
|
||||
accountType: AccountType;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
|
@ -0,0 +1,6 @@
|
||||
import { Account } from '@prisma/client';
|
||||
|
||||
export interface CashDetails {
|
||||
accounts: Account[];
|
||||
balance: number;
|
||||
}
|
@ -1,10 +1,16 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsString, ValidateIf } from 'class-validator';
|
||||
import { AccountType, Currency } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
accountType: AccountType;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
|
@ -61,8 +61,29 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.gatherProfileData();
|
||||
this.dataGatheringService.gatherMax();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@Post('gather/profile-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
@ -21,11 +22,15 @@ import { AdminModule } from './admin/admin.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { CoreModule } from './core/core.module';
|
||||
import { ExperimentalModule } from './experimental/experimental.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { SymbolModule } from './symbol/symbol.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
@ -34,10 +39,14 @@ import { UserModule } from './user/user.module';
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
AccountModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
CoreModule,
|
||||
ExperimentalModule,
|
||||
ExportModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
@ -57,6 +66,7 @@ import { UserModule } from './user/user.module';
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
exclude: ['/api*']
|
||||
}),
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
UserModule
|
||||
],
|
||||
|
44
apps/api/src/app/auth-device/auth-device.controller.ts
Normal file
44
apps/api/src/app/auth-device/auth-device.controller.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('auth-device')
|
||||
export class AuthDeviceController {
|
||||
public constructor(
|
||||
private readonly authDeviceService: AuthDeviceService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteAuthDevice
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
await this.authDeviceService.deleteAuthDevice({ id });
|
||||
}
|
||||
}
|
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface AuthDeviceDto {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
}
|
18
apps/api/src/app/auth-device/auth-device.module.ts
Normal file
18
apps/api/src/app/auth-device/auth-device.module.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthDeviceController],
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
})
|
||||
],
|
||||
providers: [AuthDeviceService, ConfigurationService, PrismaService]
|
||||
})
|
||||
export class AuthDeviceModule {}
|
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal file
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthDevice, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AuthDeviceService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
public async authDevice(
|
||||
where: Prisma.AuthDeviceWhereUniqueInput
|
||||
): Promise<AuthDevice | null> {
|
||||
return this.prisma.authDevice.findUnique({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async authDevices(params: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.AuthDeviceWhereUniqueInput;
|
||||
where?: Prisma.AuthDeviceWhereInput;
|
||||
orderBy?: Prisma.AuthDeviceOrderByInput;
|
||||
}): Promise<AuthDevice[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prisma.authDevice.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy
|
||||
});
|
||||
}
|
||||
|
||||
public async createAuthDevice(
|
||||
data: Prisma.AuthDeviceCreateInput
|
||||
): Promise<AuthDevice> {
|
||||
return this.prisma.authDevice.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async updateAuthDevice(params: {
|
||||
data: Prisma.AuthDeviceUpdateInput;
|
||||
where: Prisma.AuthDeviceWhereUniqueInput;
|
||||
}): Promise<AuthDevice> {
|
||||
const { data, where } = params;
|
||||
|
||||
return this.prisma.authDevice.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAuthDevice(
|
||||
where: Prisma.AuthDeviceWhereUniqueInput
|
||||
): Promise<AuthDevice> {
|
||||
return this.prisma.authDevice.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
@ -1,9 +1,12 @@
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards
|
||||
@ -12,12 +15,17 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
AssertionCredentialJSON,
|
||||
AttestationCredentialJSON
|
||||
} from './interfaces/simplewebauthn';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
public constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly configurationService: ConfigurationService
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly webAuthService: WebAuthService
|
||||
) {}
|
||||
|
||||
@Get('anonymous/:accessToken')
|
||||
@ -53,4 +61,44 @@ export class AuthController {
|
||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('webauthn/generate-attestation-options')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async generateAttestationOptions() {
|
||||
return this.webAuthService.generateAttestationOptions();
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-attestation')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async verifyAttestation(
|
||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||
) {
|
||||
return this.webAuthService.verifyAttestation(
|
||||
body.deviceName,
|
||||
body.credential
|
||||
);
|
||||
}
|
||||
|
||||
@Post('webauthn/generate-assertion-options')
|
||||
public async generateAssertionOptions(@Body() body: { deviceId: string }) {
|
||||
return this.webAuthService.generateAssertionOptions(body.deviceId);
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-assertion')
|
||||
public async verifyAssertion(
|
||||
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }
|
||||
) {
|
||||
try {
|
||||
const authToken = await this.webAuthService.verifyAssertion(
|
||||
body.deviceId,
|
||||
body.credential
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -18,12 +20,14 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
})
|
||||
],
|
||||
providers: [
|
||||
AuthDeviceService,
|
||||
AuthService,
|
||||
ConfigurationService,
|
||||
GoogleStrategy,
|
||||
JwtStrategy,
|
||||
PrismaService,
|
||||
UserService
|
||||
UserService,
|
||||
WebAuthService
|
||||
]
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
@ -1,5 +1,10 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
||||
export interface AuthDeviceDialogParams {
|
||||
authDevice: AuthDeviceDto;
|
||||
}
|
||||
|
||||
export interface ValidateOAuthLoginParams {
|
||||
provider: Provider;
|
||||
thirdPartyId: string;
|
||||
|
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal file
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal file
@ -0,0 +1,226 @@
|
||||
export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
|
||||
readonly authenticatorData: ArrayBuffer;
|
||||
readonly signature: ArrayBuffer;
|
||||
readonly userHandle: ArrayBuffer | null;
|
||||
}
|
||||
export interface AuthenticatorAttestationResponse
|
||||
extends AuthenticatorResponse {
|
||||
readonly attestationObject: ArrayBuffer;
|
||||
}
|
||||
export interface AuthenticationExtensionsClientInputs {
|
||||
appid?: string;
|
||||
appidExclude?: string;
|
||||
credProps?: boolean;
|
||||
uvm?: boolean;
|
||||
}
|
||||
export interface AuthenticationExtensionsClientOutputs {
|
||||
appid?: boolean;
|
||||
credProps?: CredentialPropertiesOutput;
|
||||
uvm?: UvmEntries;
|
||||
}
|
||||
export interface AuthenticatorSelectionCriteria {
|
||||
authenticatorAttachment?: AuthenticatorAttachment;
|
||||
requireResidentKey?: boolean;
|
||||
residentKey?: ResidentKeyRequirement;
|
||||
userVerification?: UserVerificationRequirement;
|
||||
}
|
||||
export interface PublicKeyCredential extends Credential {
|
||||
readonly rawId: ArrayBuffer;
|
||||
readonly response: AuthenticatorResponse;
|
||||
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
|
||||
}
|
||||
export interface PublicKeyCredentialCreationOptions {
|
||||
attestation?: AttestationConveyancePreference;
|
||||
authenticatorSelection?: AuthenticatorSelectionCriteria;
|
||||
challenge: BufferSource;
|
||||
excludeCredentials?: PublicKeyCredentialDescriptor[];
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
pubKeyCredParams: PublicKeyCredentialParameters[];
|
||||
rp: PublicKeyCredentialRpEntity;
|
||||
timeout?: number;
|
||||
user: PublicKeyCredentialUserEntity;
|
||||
}
|
||||
export interface PublicKeyCredentialDescriptor {
|
||||
id: BufferSource;
|
||||
transports?: AuthenticatorTransport[];
|
||||
type: PublicKeyCredentialType;
|
||||
}
|
||||
export interface PublicKeyCredentialParameters {
|
||||
alg: COSEAlgorithmIdentifier;
|
||||
type: PublicKeyCredentialType;
|
||||
}
|
||||
export interface PublicKeyCredentialRequestOptions {
|
||||
allowCredentials?: PublicKeyCredentialDescriptor[];
|
||||
challenge: BufferSource;
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
rpId?: string;
|
||||
timeout?: number;
|
||||
userVerification?: UserVerificationRequirement;
|
||||
}
|
||||
export interface PublicKeyCredentialUserEntity
|
||||
extends PublicKeyCredentialEntity {
|
||||
displayName: string;
|
||||
id: BufferSource;
|
||||
}
|
||||
export interface AuthenticatorResponse {
|
||||
readonly clientDataJSON: ArrayBuffer;
|
||||
}
|
||||
export interface CredentialPropertiesOutput {
|
||||
rk?: boolean;
|
||||
}
|
||||
export interface Credential {
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
}
|
||||
export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity {
|
||||
id?: string;
|
||||
}
|
||||
export interface PublicKeyCredentialEntity {
|
||||
name: string;
|
||||
}
|
||||
export declare type AttestationConveyancePreference =
|
||||
| 'direct'
|
||||
| 'enterprise'
|
||||
| 'indirect'
|
||||
| 'none';
|
||||
export declare type AuthenticatorTransport = 'ble' | 'internal' | 'nfc' | 'usb';
|
||||
export declare type COSEAlgorithmIdentifier = number;
|
||||
export declare type UserVerificationRequirement =
|
||||
| 'discouraged'
|
||||
| 'preferred'
|
||||
| 'required';
|
||||
export declare type UvmEntries = UvmEntry[];
|
||||
export declare type AuthenticatorAttachment = 'cross-platform' | 'platform';
|
||||
export declare type ResidentKeyRequirement =
|
||||
| 'discouraged'
|
||||
| 'preferred'
|
||||
| 'required';
|
||||
export declare type BufferSource = ArrayBufferView | ArrayBuffer;
|
||||
export declare type PublicKeyCredentialType = 'public-key';
|
||||
export declare type UvmEntry = number[];
|
||||
|
||||
export interface PublicKeyCredentialCreationOptionsJSON
|
||||
extends Omit<
|
||||
PublicKeyCredentialCreationOptions,
|
||||
'challenge' | 'user' | 'excludeCredentials'
|
||||
> {
|
||||
user: PublicKeyCredentialUserEntityJSON;
|
||||
challenge: Base64URLString;
|
||||
excludeCredentials: PublicKeyCredentialDescriptorJSON[];
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
}
|
||||
/**
|
||||
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to
|
||||
* (eventually) get passed into navigator.credentials.get(...) in the browser.
|
||||
*/
|
||||
export interface PublicKeyCredentialRequestOptionsJSON
|
||||
extends Omit<
|
||||
PublicKeyCredentialRequestOptions,
|
||||
'challenge' | 'allowCredentials'
|
||||
> {
|
||||
challenge: Base64URLString;
|
||||
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
}
|
||||
export interface PublicKeyCredentialDescriptorJSON
|
||||
extends Omit<PublicKeyCredentialDescriptor, 'id'> {
|
||||
id: Base64URLString;
|
||||
}
|
||||
export interface PublicKeyCredentialUserEntityJSON
|
||||
extends Omit<PublicKeyCredentialUserEntity, 'id'> {
|
||||
id: string;
|
||||
}
|
||||
/**
|
||||
* The value returned from navigator.credentials.create()
|
||||
*/
|
||||
export interface AttestationCredential extends PublicKeyCredential {
|
||||
response: AuthenticatorAttestationResponseFuture;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AttestationCredentialJSON
|
||||
extends Omit<
|
||||
AttestationCredential,
|
||||
'response' | 'rawId' | 'getClientExtensionResults'
|
||||
> {
|
||||
rawId: Base64URLString;
|
||||
response: AuthenticatorAttestationResponseJSON;
|
||||
clientExtensionResults: AuthenticationExtensionsClientOutputs;
|
||||
transports?: AuthenticatorTransport[];
|
||||
}
|
||||
/**
|
||||
* The value returned from navigator.credentials.get()
|
||||
*/
|
||||
export interface AssertionCredential extends PublicKeyCredential {
|
||||
response: AuthenticatorAssertionResponse;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AssertionCredentialJSON
|
||||
extends Omit<
|
||||
AssertionCredential,
|
||||
'response' | 'rawId' | 'getClientExtensionResults'
|
||||
> {
|
||||
rawId: Base64URLString;
|
||||
response: AuthenticatorAssertionResponseJSON;
|
||||
clientExtensionResults: AuthenticationExtensionsClientOutputs;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AuthenticatorAttestationResponseJSON
|
||||
extends Omit<
|
||||
AuthenticatorAttestationResponseFuture,
|
||||
'clientDataJSON' | 'attestationObject'
|
||||
> {
|
||||
clientDataJSON: Base64URLString;
|
||||
attestationObject: Base64URLString;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AuthenticatorAssertionResponseJSON
|
||||
extends Omit<
|
||||
AuthenticatorAssertionResponse,
|
||||
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
|
||||
> {
|
||||
authenticatorData: Base64URLString;
|
||||
clientDataJSON: Base64URLString;
|
||||
signature: Base64URLString;
|
||||
userHandle?: string;
|
||||
}
|
||||
/**
|
||||
* A WebAuthn-compatible device and the information needed to verify assertions by it
|
||||
*/
|
||||
export declare type AuthenticatorDevice = {
|
||||
credentialPublicKey: Buffer;
|
||||
credentialID: Buffer;
|
||||
counter: number;
|
||||
transports?: AuthenticatorTransport[];
|
||||
};
|
||||
/**
|
||||
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
|
||||
*/
|
||||
export declare type Base64URLString = string;
|
||||
/**
|
||||
* AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7).
|
||||
* Maintain an augmented version here so we can implement additional properties as the WebAuthn
|
||||
* spec evolves.
|
||||
*
|
||||
* See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse
|
||||
*
|
||||
* Properties marked optional are not supported in all browsers.
|
||||
*/
|
||||
export interface AuthenticatorAttestationResponseFuture
|
||||
extends AuthenticatorAttestationResponse {
|
||||
getTransports?: () => AuthenticatorTransport[];
|
||||
getAuthenticatorData?: () => ArrayBuffer;
|
||||
getPublicKey?: () => ArrayBuffer;
|
||||
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[];
|
||||
}
|
216
apps/api/src/app/auth/web-auth.service.ts
Normal file
216
apps/api/src/app/auth/web-auth.service.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
GenerateAssertionOptionsOpts,
|
||||
GenerateAttestationOptionsOpts,
|
||||
VerifiedAssertion,
|
||||
VerifiedAttestation,
|
||||
VerifyAssertionResponseOpts,
|
||||
VerifyAttestationResponseOpts,
|
||||
generateAssertionOptions,
|
||||
generateAttestationOptions,
|
||||
verifyAssertionResponse,
|
||||
verifyAttestationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
import {
|
||||
AssertionCredentialJSON,
|
||||
AttestationCredentialJSON
|
||||
} from './interfaces/simplewebauthn';
|
||||
|
||||
@Injectable()
|
||||
export class WebAuthService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly deviceService: AuthDeviceService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly userService: UserService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
get rpID() {
|
||||
return this.configurationService.get('WEB_AUTH_RP_ID');
|
||||
}
|
||||
|
||||
get expectedOrigin() {
|
||||
return this.configurationService.get('ROOT_URL');
|
||||
}
|
||||
|
||||
public async generateAttestationOptions() {
|
||||
const user = this.request.user;
|
||||
|
||||
const opts: GenerateAttestationOptionsOpts = {
|
||||
rpName: 'Ghostfolio',
|
||||
rpID: this.rpID,
|
||||
userID: user.id,
|
||||
userName: user.alias,
|
||||
timeout: 60000,
|
||||
attestationType: 'indirect',
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: 'platform',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'required'
|
||||
}
|
||||
};
|
||||
|
||||
const options = generateAttestationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
authChallenge: options.challenge
|
||||
},
|
||||
where: {
|
||||
id: user.id
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public async verifyAttestation(
|
||||
deviceName: string,
|
||||
credential: AttestationCredentialJSON
|
||||
): Promise<AuthDeviceDto> {
|
||||
const user = this.request.user;
|
||||
const expectedChallenge = user.authChallenge;
|
||||
|
||||
let verification: VerifiedAttestation;
|
||||
try {
|
||||
const opts: VerifyAttestationResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
};
|
||||
verification = await verifyAttestationResponse(opts);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new InternalServerErrorException(error.message);
|
||||
}
|
||||
|
||||
const { verified, attestationInfo } = verification;
|
||||
|
||||
const devices = await this.deviceService.authDevices({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
if (verified && attestationInfo) {
|
||||
const { credentialPublicKey, credentialID, counter } = attestationInfo;
|
||||
|
||||
let existingDevice = devices.find(
|
||||
(device) => device.credentialId === credentialID
|
||||
);
|
||||
|
||||
if (!existingDevice) {
|
||||
/**
|
||||
* Add the returned device to the user's list of devices
|
||||
*/
|
||||
existingDevice = await this.deviceService.createAuthDevice({
|
||||
credentialPublicKey,
|
||||
credentialId: credentialID,
|
||||
counter,
|
||||
User: { connect: { id: user.id } }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
createdAt: existingDevice.createdAt.toISOString(),
|
||||
id: existingDevice.id
|
||||
};
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException('An unknown error occurred');
|
||||
}
|
||||
|
||||
public async generateAssertionOptions(deviceId: string) {
|
||||
const device = await this.deviceService.authDevice({ id: deviceId });
|
||||
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const opts: GenerateAssertionOptionsOpts = {
|
||||
timeout: 60000,
|
||||
allowCredentials: [
|
||||
{
|
||||
id: device.credentialId,
|
||||
type: 'public-key',
|
||||
transports: ['internal']
|
||||
}
|
||||
],
|
||||
userVerification: 'preferred',
|
||||
rpID: this.rpID
|
||||
};
|
||||
|
||||
const options = generateAssertionOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
authChallenge: options.challenge
|
||||
},
|
||||
where: {
|
||||
id: device.userId
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public async verifyAssertion(
|
||||
deviceId: string,
|
||||
credential: AssertionCredentialJSON
|
||||
) {
|
||||
const device = await this.deviceService.authDevice({ id: deviceId });
|
||||
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const user = await this.userService.user({ id: device.userId });
|
||||
|
||||
let verification: VerifiedAssertion;
|
||||
try {
|
||||
const opts: VerifyAssertionResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID,
|
||||
authenticator: {
|
||||
credentialID: device.credentialId,
|
||||
credentialPublicKey: device.credentialPublicKey,
|
||||
counter: device.counter
|
||||
}
|
||||
};
|
||||
verification = verifyAssertionResponse(opts);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new InternalServerErrorException({ error: error.message });
|
||||
}
|
||||
|
||||
const { verified, assertionInfo } = verification;
|
||||
|
||||
if (verified) {
|
||||
device.counter = assertionInfo.newCounter;
|
||||
|
||||
await this.deviceService.updateAuthDevice({
|
||||
data: device,
|
||||
where: { id: device.id }
|
||||
});
|
||||
|
||||
return this.jwtService.sign({
|
||||
id: user.id
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
}
|
30
apps/api/src/app/core/core.module.ts
Normal file
30
apps/api/src/app/core/core.module.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
CurrentRateService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
MarketDataService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class CoreModule {}
|
129
apps/api/src/app/core/current-rate.service.spec.ts
Normal file
129
apps/api/src/app/core/current-rate.service.spec.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { Currency, MarketData } from '@prisma/client';
|
||||
|
||||
import { MarketDataService } from './market-data.service';
|
||||
|
||||
jest.mock('./market-data.service', () => {
|
||||
return {
|
||||
MarketDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
get: (date: Date, symbol: string) => {
|
||||
return Promise.resolve<MarketData>({
|
||||
date,
|
||||
symbol,
|
||||
createdAt: date,
|
||||
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||
marketPrice: 1847.839966
|
||||
});
|
||||
},
|
||||
getRange: ({
|
||||
dateRangeEnd,
|
||||
dateRangeStart,
|
||||
symbols
|
||||
}: {
|
||||
dateRangeEnd: Date;
|
||||
dateRangeStart: Date;
|
||||
symbols: string[];
|
||||
}) => {
|
||||
return Promise.resolve<MarketData[]>([
|
||||
{
|
||||
createdAt: dateRangeStart,
|
||||
date: dateRangeStart,
|
||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||
marketPrice: 1841.823902,
|
||||
symbol: symbols[0]
|
||||
},
|
||||
{
|
||||
createdAt: dateRangeEnd,
|
||||
date: dateRangeEnd,
|
||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||
marketPrice: 1847.839966,
|
||||
symbol: symbols[0]
|
||||
}
|
||||
]);
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
|
||||
return {
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
initialize: () => Promise.resolve(),
|
||||
toCurrency: (value: number) => {
|
||||
return 1 * value;
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('CurrentRateService', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let dataProviderService: DataProviderService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let marketDataService: MarketDataService;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataProviderService = new DataProviderService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
|
||||
currentRateService = new CurrentRateService(
|
||||
dataProviderService,
|
||||
exchangeRateDataService,
|
||||
marketDataService
|
||||
);
|
||||
});
|
||||
|
||||
it('getValue', async () => {
|
||||
expect(
|
||||
await currentRateService.getValue({
|
||||
currency: Currency.USD,
|
||||
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
||||
symbol: 'AMZN',
|
||||
userCurrency: Currency.CHF
|
||||
})
|
||||
).toMatchObject({
|
||||
marketPrice: 1847.839966
|
||||
});
|
||||
});
|
||||
|
||||
it('getValues', async () => {
|
||||
expect(
|
||||
await currentRateService.getValues({
|
||||
currencies: { AMZN: Currency.USD },
|
||||
dateQuery: {
|
||||
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
||||
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
||||
},
|
||||
symbols: ['AMZN'],
|
||||
userCurrency: Currency.CHF
|
||||
})
|
||||
).toMatchObject([
|
||||
{
|
||||
date: undefined,
|
||||
marketPrice: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
date: undefined,
|
||||
marketPrice: 1847.839966,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
128
apps/api/src/app/core/current-rate.service.ts
Normal file
128
apps/api/src/app/core/current-rate.service.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
|
||||
import { GetValueParams } from '@ghostfolio/api/app/core/interfaces/get-value-params.interface';
|
||||
import { GetValuesParams } from '@ghostfolio/api/app/core/interfaces/get-values-params.interface';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
import { MarketDataService } from './market-data.service';
|
||||
|
||||
@Injectable()
|
||||
export class CurrentRateService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService
|
||||
) {}
|
||||
|
||||
public async getValue({
|
||||
currency,
|
||||
date,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: GetValueParams): Promise<GetValueObject> {
|
||||
if (isToday(date)) {
|
||||
const dataProviderResult = await this.dataProviderService.get([symbol]);
|
||||
return {
|
||||
date: resetHours(date),
|
||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
|
||||
symbol: symbol
|
||||
};
|
||||
}
|
||||
|
||||
const marketData = await this.marketDataService.get({
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (marketData) {
|
||||
return {
|
||||
date: marketData.date,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
marketData.marketPrice,
|
||||
currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: marketData.symbol
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
|
||||
}
|
||||
|
||||
public async getValues({
|
||||
currencies,
|
||||
dateQuery,
|
||||
symbols,
|
||||
userCurrency
|
||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||
const includeToday =
|
||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||
|
||||
const promises: Promise<
|
||||
{
|
||||
date: Date;
|
||||
marketPrice: number;
|
||||
symbol: string;
|
||||
}[]
|
||||
>[] = [];
|
||||
|
||||
if (includeToday) {
|
||||
const today = resetHours(new Date());
|
||||
promises.push(
|
||||
this.dataProviderService.get(symbols).then((dataResultProvider) => {
|
||||
const result = [];
|
||||
for (const symbol of symbols) {
|
||||
result.push({
|
||||
symbol,
|
||||
date: today,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[symbol]?.marketPrice ?? 0,
|
||||
dataResultProvider?.[symbol]?.currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
promises.push(
|
||||
this.marketDataService
|
||||
.getRange({
|
||||
dateQuery,
|
||||
symbols
|
||||
})
|
||||
.then((data) => {
|
||||
return data.map((marketDataItem) => {
|
||||
return {
|
||||
date: marketDataItem.date,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
marketDataItem.marketPrice,
|
||||
currencies[marketDataItem.symbol],
|
||||
userCurrency
|
||||
),
|
||||
symbol: marketDataItem.symbol
|
||||
};
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return flatten(await Promise.all(promises));
|
||||
}
|
||||
|
||||
private containsToday(dates: Date[]): boolean {
|
||||
for (const date of dates) {
|
||||
if (isToday(date)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface CurrentPositions {
|
||||
hasErrors: boolean;
|
||||
positions: TimelinePosition[];
|
||||
grossPerformance: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
currentValue: Big;
|
||||
totalInvestment: Big;
|
||||
}
|
5
apps/api/src/app/core/interfaces/date-query.interface.ts
Normal file
5
apps/api/src/app/core/interfaces/date-query.interface.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface DateQuery {
|
||||
gte?: Date;
|
||||
in?: Date[];
|
||||
lt?: Date;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export interface GetValueObject {
|
||||
date: Date;
|
||||
marketPrice: number;
|
||||
symbol: string;
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface GetValueParams {
|
||||
currency: Currency;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
userCurrency: Currency;
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { DateQuery } from '@ghostfolio/api/app/core/interfaces/date-query.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface GetValuesParams {
|
||||
currencies: { [symbol: string]: Currency };
|
||||
dateQuery: DateQuery;
|
||||
symbols: string[];
|
||||
userCurrency: Currency;
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { Currency } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
currency: Currency;
|
||||
date: string;
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
type: OrderType;
|
||||
unitPrice: Big;
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TimelinePeriod {
|
||||
date: string;
|
||||
grossPerformance: Big;
|
||||
investment: Big;
|
||||
value: Big;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export type Accuracy = 'day' | 'month' | 'year';
|
||||
|
||||
export interface TimelineSpecification {
|
||||
accuracy: Accuracy;
|
||||
start: string;
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TransactionPointSymbol {
|
||||
currency: Currency;
|
||||
firstBuyDate: string;
|
||||
investment: Big;
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
transactionCount: number;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
|
||||
|
||||
export interface TransactionPoint {
|
||||
date: string;
|
||||
items: TransactionPointSymbol[];
|
||||
}
|
51
apps/api/src/app/core/market-data.service.ts
Normal file
51
apps/api/src/app/core/market-data.service.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MarketData } from '@prisma/client';
|
||||
|
||||
import { DateQuery } from './interfaces/date-query.interface';
|
||||
|
||||
@Injectable()
|
||||
export class MarketDataService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
|
||||
public async get({
|
||||
date,
|
||||
symbol
|
||||
}: {
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<MarketData> {
|
||||
return await this.prisma.marketData.findFirst({
|
||||
where: {
|
||||
symbol,
|
||||
date: resetHours(date)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getRange({
|
||||
dateQuery,
|
||||
symbols
|
||||
}: {
|
||||
dateQuery: DateQuery;
|
||||
symbols: string[];
|
||||
}): Promise<MarketData[]> {
|
||||
return await this.prisma.marketData.findMany({
|
||||
orderBy: [
|
||||
{
|
||||
date: 'asc'
|
||||
},
|
||||
{
|
||||
symbol: 'asc'
|
||||
}
|
||||
],
|
||||
where: {
|
||||
date: dateQuery,
|
||||
symbol: {
|
||||
in: symbols
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
1830
apps/api/src/app/core/portfolio-calculator.spec.ts
Normal file
1830
apps/api/src/app/core/portfolio-calculator.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
563
apps/api/src/app/core/portfolio-calculator.ts
Normal file
563
apps/api/src/app/core/portfolio-calculator.ts
Normal file
@ -0,0 +1,563 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
|
||||
import { TimelinePeriod } from '@ghostfolio/api/app/core/interfaces/timeline-period.interface';
|
||||
import {
|
||||
Accuracy,
|
||||
TimelineSpecification
|
||||
} from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
|
||||
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addYears,
|
||||
endOfDay,
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
max,
|
||||
min,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
export class PortfolioCalculator {
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
public constructor(
|
||||
private currentRateService: CurrentRateService,
|
||||
private currency: Currency
|
||||
) {}
|
||||
|
||||
public computeTransactionPoints(orders: PortfolioOrder[]) {
|
||||
orders.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
this.transactionPoints = [];
|
||||
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||
|
||||
let lastDate: string = null;
|
||||
let lastTransactionPoint: TransactionPoint = null;
|
||||
for (const order of orders) {
|
||||
const currentDate = order.date;
|
||||
|
||||
let currentTransactionPointItem: TransactionPointSymbol;
|
||||
const oldAccumulatedSymbol = symbols[order.symbol];
|
||||
|
||||
const factor = this.getFactor(order.type);
|
||||
const unitPrice = new Big(order.unitPrice);
|
||||
if (oldAccumulatedSymbol) {
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
investment: unitPrice
|
||||
.mul(order.quantity)
|
||||
.mul(factor)
|
||||
.add(oldAccumulatedSymbol.investment),
|
||||
name: order.name,
|
||||
quantity: order.quantity
|
||||
.mul(factor)
|
||||
.plus(oldAccumulatedSymbol.quantity),
|
||||
symbol: order.symbol,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
};
|
||||
} else {
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
firstBuyDate: order.date,
|
||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||
name: order.name,
|
||||
quantity: order.quantity.mul(factor),
|
||||
symbol: order.symbol,
|
||||
transactionCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
symbols[order.symbol] = currentTransactionPointItem;
|
||||
|
||||
const items = lastTransactionPoint?.items ?? [];
|
||||
const newItems = items.filter(
|
||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||
);
|
||||
if (!currentTransactionPointItem.quantity.eq(0)) {
|
||||
newItems.push(currentTransactionPointItem);
|
||||
} else {
|
||||
delete symbols[order.symbol];
|
||||
}
|
||||
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
||||
lastTransactionPoint = {
|
||||
date: currentDate,
|
||||
items: newItems
|
||||
};
|
||||
this.transactionPoints.push(lastTransactionPoint);
|
||||
} else {
|
||||
lastTransactionPoint.items = newItems;
|
||||
}
|
||||
lastDate = currentDate;
|
||||
}
|
||||
}
|
||||
|
||||
public getTransactionPoints(): TransactionPoint[] {
|
||||
return this.transactionPoints;
|
||||
}
|
||||
|
||||
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
|
||||
this.transactionPoints = transactionPoints;
|
||||
}
|
||||
|
||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
||||
if (!this.transactionPoints?.length) {
|
||||
return {
|
||||
hasErrors: false,
|
||||
positions: [],
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
currentValue: new Big(0),
|
||||
totalInvestment: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
const lastTransactionPoint =
|
||||
this.transactionPoints[this.transactionPoints.length - 1];
|
||||
|
||||
// use Date.now() to use the mock for today
|
||||
const today = new Date(Date.now());
|
||||
|
||||
let firstTransactionPoint: TransactionPoint = null;
|
||||
let firstIndex = this.transactionPoints.length;
|
||||
const dates = [];
|
||||
const symbols = new Set<string>();
|
||||
const currencies: { [symbol: string]: Currency } = {};
|
||||
|
||||
dates.push(resetHours(start));
|
||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
||||
symbols.add(item.symbol);
|
||||
currencies[item.symbol] = item.currency;
|
||||
}
|
||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
||||
if (
|
||||
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
||||
firstTransactionPoint === null
|
||||
) {
|
||||
firstTransactionPoint = this.transactionPoints[i];
|
||||
firstIndex = i;
|
||||
}
|
||||
if (firstTransactionPoint !== null) {
|
||||
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
|
||||
}
|
||||
}
|
||||
|
||||
dates.push(resetHours(today));
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
symbols: Array.from(symbols),
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
const startString = format(start, DATE_FORMAT);
|
||||
|
||||
const holdingPeriodReturns: { [symbol: string]: Big } = {};
|
||||
const grossPerformance: { [symbol: string]: Big } = {};
|
||||
const todayString = format(today, DATE_FORMAT);
|
||||
|
||||
if (firstIndex > 0) {
|
||||
firstIndex--;
|
||||
}
|
||||
const invalidSymbols = [];
|
||||
const lastInvestments: { [symbol: string]: Big } = {};
|
||||
const lastQuantities: { [symbol: string]: Big } = {};
|
||||
const initialValues: { [symbol: string]: Big } = {};
|
||||
|
||||
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
|
||||
const currentDate =
|
||||
i === firstIndex ? startString : this.transactionPoints[i].date;
|
||||
const nextDate =
|
||||
i + 1 < this.transactionPoints.length
|
||||
? this.transactionPoints[i + 1].date
|
||||
: todayString;
|
||||
|
||||
const items = this.transactionPoints[i].items;
|
||||
for (const item of items) {
|
||||
let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol];
|
||||
if (!oldHoldingPeriodReturn) {
|
||||
oldHoldingPeriodReturn = new Big(1);
|
||||
}
|
||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
console.error(
|
||||
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let lastInvestment: Big = new Big(0);
|
||||
let lastQuantity: Big = item.quantity;
|
||||
if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) {
|
||||
lastInvestment = item.investment.minus(lastInvestments[item.symbol]);
|
||||
lastQuantity = lastQuantities[item.symbol];
|
||||
}
|
||||
|
||||
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
|
||||
let initialValue = itemValue?.mul(lastQuantity);
|
||||
let investedValue = itemValue?.mul(item.quantity);
|
||||
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
|
||||
initialValue = item.investment;
|
||||
investedValue = item.investment;
|
||||
}
|
||||
if (i === firstIndex || !initialValues[item.symbol]) {
|
||||
initialValues[item.symbol] = initialValue;
|
||||
}
|
||||
if (!initialValue) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
console.error(
|
||||
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cashFlow = lastInvestment;
|
||||
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
|
||||
item.quantity
|
||||
);
|
||||
|
||||
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
|
||||
holdingPeriodReturns[item.symbol] =
|
||||
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
|
||||
let oldGrossPerformance = grossPerformance[item.symbol];
|
||||
if (!oldGrossPerformance) {
|
||||
oldGrossPerformance = new Big(0);
|
||||
}
|
||||
const currentPerformance = endValue.minus(investedValue);
|
||||
grossPerformance[item.symbol] =
|
||||
oldGrossPerformance.plus(currentPerformance);
|
||||
|
||||
lastInvestments[item.symbol] = item.investment;
|
||||
lastQuantities[item.symbol] = item.quantity;
|
||||
}
|
||||
}
|
||||
|
||||
const positions: TimelinePosition[] = [];
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
|
||||
positions.push({
|
||||
averagePrice: item.investment.div(item.quantity),
|
||||
currency: item.currency,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: isValid
|
||||
? grossPerformance[item.symbol] ?? null
|
||||
: null,
|
||||
grossPerformancePercentage:
|
||||
isValid && holdingPeriodReturns[item.symbol]
|
||||
? holdingPeriodReturns[item.symbol].minus(1)
|
||||
: null,
|
||||
investment: item.investment,
|
||||
marketPrice: marketValue?.toNumber() ?? null,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
transactionCount: item.transactionCount
|
||||
});
|
||||
}
|
||||
const overall = this.calculateOverallGrossPerformance(
|
||||
positions,
|
||||
initialValues
|
||||
);
|
||||
|
||||
return {
|
||||
...overall,
|
||||
positions,
|
||||
hasErrors: hasErrors || overall.hasErrors
|
||||
};
|
||||
}
|
||||
|
||||
public getInvestments(): { date: string; investment: Big }[] {
|
||||
if (this.transactionPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.transactionPoints.map((transactionPoint) => {
|
||||
return {
|
||||
date: transactionPoint.date,
|
||||
investment: transactionPoint.items.reduce(
|
||||
(investment, transactionPointSymbol) =>
|
||||
investment.add(transactionPointSymbol.investment),
|
||||
new Big(0)
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async calculateTimeline(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
endDate: string
|
||||
): Promise<TimelinePeriod[]> {
|
||||
if (timelineSpecification.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const startDate = timelineSpecification[0].start;
|
||||
const start = parseDate(startDate);
|
||||
const end = parseDate(endDate);
|
||||
|
||||
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = [];
|
||||
let i = 0;
|
||||
let j = -1;
|
||||
for (
|
||||
let currentDate = start;
|
||||
!isAfter(currentDate, end);
|
||||
currentDate = this.addToDate(
|
||||
currentDate,
|
||||
timelineSpecification[i].accuracy
|
||||
)
|
||||
) {
|
||||
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
|
||||
i++;
|
||||
}
|
||||
while (
|
||||
j + 1 < this.transactionPoints.length &&
|
||||
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
|
||||
) {
|
||||
j++;
|
||||
}
|
||||
|
||||
let periodEndDate = currentDate;
|
||||
if (timelineSpecification[i].accuracy === 'day') {
|
||||
let nextEndDate = end;
|
||||
if (j + 1 < this.transactionPoints.length) {
|
||||
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
|
||||
}
|
||||
periodEndDate = min([
|
||||
addMonths(currentDate, 3),
|
||||
max([currentDate, nextEndDate])
|
||||
]);
|
||||
}
|
||||
const timePeriodForDates = this.getTimePeriodForDate(
|
||||
j,
|
||||
currentDate,
|
||||
endOfDay(periodEndDate)
|
||||
);
|
||||
currentDate = periodEndDate;
|
||||
if (timePeriodForDates != null) {
|
||||
timelinePeriodPromises.push(timePeriodForDates);
|
||||
}
|
||||
}
|
||||
|
||||
const timelinePeriods: TimelinePeriod[][] = await Promise.all(
|
||||
timelinePeriodPromises
|
||||
);
|
||||
|
||||
return flatten(timelinePeriods);
|
||||
}
|
||||
|
||||
private calculateOverallGrossPerformance(
|
||||
positions: TimelinePosition[],
|
||||
initialValues: { [p: string]: Big }
|
||||
) {
|
||||
let hasErrors = false;
|
||||
let currentValue = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformancePercentage = new Big(0);
|
||||
let completeInitialValue = new Big(0);
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
currentValue = currentValue.add(
|
||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
totalInvestment = totalInvestment.add(currentPosition.investment);
|
||||
if (currentPosition.grossPerformance) {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (
|
||||
currentPosition.grossPerformancePercentage &&
|
||||
initialValues[currentPosition.symbol]
|
||||
) {
|
||||
const currentInitialValue = initialValues[currentPosition.symbol];
|
||||
completeInitialValue = completeInitialValue.plus(currentInitialValue);
|
||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`Initial value is missing for symbol ${currentPosition.symbol}`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
currentValue,
|
||||
grossPerformance,
|
||||
hasErrors,
|
||||
totalInvestment,
|
||||
grossPerformancePercentage:
|
||||
grossPerformancePercentage.div(completeInitialValue)
|
||||
};
|
||||
}
|
||||
|
||||
private async getTimePeriodForDate(
|
||||
j: number,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<TimelinePeriod[]> {
|
||||
let investment: Big = new Big(0);
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
if (j >= 0) {
|
||||
const currencies: { [name: string]: Currency } = {};
|
||||
const symbols: string[] = [];
|
||||
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
currencies[item.symbol] = item.currency;
|
||||
symbols.push(item.symbol);
|
||||
investment = investment.add(item.investment);
|
||||
}
|
||||
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (symbols.length > 0) {
|
||||
try {
|
||||
marketSymbols = await this.currentRateService.getValues({
|
||||
dateQuery: {
|
||||
gte: startDate,
|
||||
lt: endOfDay(endDate)
|
||||
},
|
||||
symbols,
|
||||
currencies,
|
||||
userCurrency: this.currency
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to fetch info for date ${startDate} with exception`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (
|
||||
let currentDate = startDate;
|
||||
isBefore(currentDate, endDate);
|
||||
currentDate = addDays(currentDate, 1)
|
||||
) {
|
||||
let value = new Big(0);
|
||||
const currentDateAsString = format(currentDate, DATE_FORMAT);
|
||||
let invalid = false;
|
||||
if (j >= 0) {
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
if (
|
||||
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
|
||||
) {
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
value = value.add(
|
||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!invalid) {
|
||||
const result = {
|
||||
date: currentDateAsString,
|
||||
grossPerformance: value.minus(investment),
|
||||
investment,
|
||||
value
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private getFactor(type: OrderType) {
|
||||
let factor: number;
|
||||
|
||||
switch (type) {
|
||||
case OrderType.Buy:
|
||||
factor = 1;
|
||||
break;
|
||||
case OrderType.Sell:
|
||||
factor = -1;
|
||||
break;
|
||||
default:
|
||||
factor = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
private addToDate(date: Date, accuracy: Accuracy): Date {
|
||||
switch (accuracy) {
|
||||
case 'day':
|
||||
return addDays(date, 1);
|
||||
case 'month':
|
||||
return addMonths(date, 1);
|
||||
case 'year':
|
||||
return addYears(date, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private isNextItemActive(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
currentDate: Date,
|
||||
i: number
|
||||
) {
|
||||
return (
|
||||
i + 1 < timelineSpecification.length &&
|
||||
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
@ -65,26 +66,4 @@ export class ExperimentalController {
|
||||
|
||||
return marketData;
|
||||
}
|
||||
|
||||
@Post('value/:dateString?')
|
||||
public async getValue(
|
||||
@Body() orders: CreateOrderDto[],
|
||||
@Headers('Authorization') apiToken: string,
|
||||
@Param('dateString') dateString: string
|
||||
): Promise<Data> {
|
||||
if (!isApiTokenAuthorized(apiToken)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let date = new Date();
|
||||
|
||||
if (dateString) {
|
||||
date = parse(dateString, 'yyyy-MM-dd', new Date());
|
||||
}
|
||||
|
||||
return this.experimentalService.getValue(orders, date, baseCurrency);
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
@ -13,9 +15,10 @@ import { ExperimentalController } from './experimental.controller';
|
||||
import { ExperimentalService } from './experimental.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [RedisCacheModule],
|
||||
controllers: [ExperimentalController],
|
||||
providers: [
|
||||
AccountService,
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
|
@ -1,19 +1,14 @@
|
||||
import { Portfolio } from '@ghostfolio/api/models/portfolio';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, Type } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Data } from './interfaces/data.interface';
|
||||
|
||||
@Injectable()
|
||||
export class ExperimentalService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private prisma: PrismaService,
|
||||
@ -27,40 +22,4 @@ export class ExperimentalService {
|
||||
where: { symbol: aSymbol }
|
||||
});
|
||||
}
|
||||
|
||||
public async getValue(
|
||||
aOrders: CreateOrderDto[],
|
||||
aDate: Date,
|
||||
aBaseCurrency: Currency
|
||||
): Promise<Data> {
|
||||
const ordersWithPlatform: OrderWithAccount[] = aOrders.map((order) => {
|
||||
return {
|
||||
...order,
|
||||
accountId: undefined,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
dataSource: undefined,
|
||||
date: parseISO(order.date),
|
||||
fee: 0,
|
||||
id: undefined,
|
||||
platformId: undefined,
|
||||
symbolProfileId: undefined,
|
||||
type: Type.BUY,
|
||||
updatedAt: undefined,
|
||||
userId: undefined
|
||||
};
|
||||
});
|
||||
|
||||
const portfolio = new Portfolio(
|
||||
this.dataProviderService,
|
||||
this.exchangeRateDataService,
|
||||
this.rulesService
|
||||
);
|
||||
await portfolio.setOrders(ordersWithPlatform);
|
||||
|
||||
return {
|
||||
currency: aBaseCurrency,
|
||||
value: portfolio.getValue(aDate)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
23
apps/api/src/app/export/export.controller.ts
Normal file
23
apps/api/src/app/export/export.controller.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Controller('export')
|
||||
export class ExportController {
|
||||
public constructor(
|
||||
private readonly exportService: ExportService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async export(): Promise<Export> {
|
||||
return await this.exportService.export({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
}
|
32
apps/api/src/app/export/export.module.ts
Normal file
32
apps/api/src/app/export/export.module.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
controllers: [ExportController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExportService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class ExportModule {}
|
31
apps/api/src/app/export/export.service.ts
Normal file
31
apps/api/src/app/export/export.service.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
|
||||
public async export({ userId }: { userId: string }): Promise<Export> {
|
||||
const orders = await this.prisma.order.findMany({
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
date: true,
|
||||
fee: true,
|
||||
quantity: true,
|
||||
symbol: true,
|
||||
type: true,
|
||||
unitPrice: true
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
return {
|
||||
meta: { date: new Date().toISOString(), version: environment.version },
|
||||
orders
|
||||
};
|
||||
}
|
||||
}
|
7
apps/api/src/app/import/import-data.dto.ts
Normal file
7
apps/api/src/app/import/import-data.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Order } from '@prisma/client';
|
||||
import { IsArray } from 'class-validator';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsArray()
|
||||
orders: Partial<Order>[];
|
||||
}
|
50
apps/api/src/app/import/import.controller.ts
Normal file
50
apps/api/src/app/import/import.controller.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { ImportDataDto } from './import-data.dto';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Controller('import')
|
||||
export class ImportController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly importService: ImportService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.importService.import({
|
||||
orders: importData.orders,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
34
apps/api/src/app/import/import.module.ts
Normal file
34
apps/api/src/app/import/import.module.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
controllers: [ImportController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
ImportService,
|
||||
OrderService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class ImportModule {}
|
43
apps/api/src/app/import/import.service.ts
Normal file
43
apps/api/src/app/import/import.service.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Order } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(private readonly orderService: OrderService) {}
|
||||
|
||||
public async import({
|
||||
orders,
|
||||
userId
|
||||
}: {
|
||||
orders: Partial<Order>[];
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const {
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
} of orders) {
|
||||
await this.orderService.createOrder(
|
||||
{
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
User: { connect: { id: userId } }
|
||||
},
|
||||
userId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Currency } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
@ -17,6 +20,7 @@ export class InfoService {
|
||||
) {}
|
||||
|
||||
public async get(): Promise<InfoItem> {
|
||||
const info: Partial<InfoItem> = {};
|
||||
const platforms = await this.prisma.platform.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
@ -24,23 +28,87 @@ export class InfoService {
|
||||
|
||||
const globalPermissions: string[] = [];
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
|
||||
globalPermissions.push(permissions.enableBlog);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
globalPermissions.push(permissions.enableImport);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
||||
globalPermissions.push(permissions.enableSocialLogin);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
||||
globalPermissions.push(permissions.enableStatistics);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
globalPermissions.push(permissions.enableSubscription);
|
||||
|
||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
globalPermissions,
|
||||
platforms,
|
||||
currencies: Object.values(Currency),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
lastDataGathering: await this.getLastDataGathering()
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
statistics: await this.getStatistics(),
|
||||
subscriptions: await this.getSubscriptions()
|
||||
};
|
||||
}
|
||||
|
||||
private async countActiveUsers(aDays: number) {
|
||||
return await this.prisma.user.count({
|
||||
orderBy: {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
Analytics: {
|
||||
updatedAt: {
|
||||
gt: subDays(new Date(), aDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async countGitHubStargazers(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||
'GET',
|
||||
'json',
|
||||
200,
|
||||
{
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
);
|
||||
|
||||
const { stargazers_count } = await get();
|
||||
return stargazers_count;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: InfoService.DEMO_USER_ID
|
||||
@ -54,4 +122,36 @@ export class InfoService {
|
||||
|
||||
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
|
||||
}
|
||||
|
||||
private async getStatistics() {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
|
||||
return {
|
||||
activeUsers1d,
|
||||
activeUsers30d,
|
||||
gitHubStargazers
|
||||
};
|
||||
}
|
||||
|
||||
private async getSubscriptions(): Promise<Subscription[]> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stripeConfig = await this.prisma.property.findUnique({
|
||||
where: { key: 'STRIPE_CONFIG' }
|
||||
});
|
||||
|
||||
if (stripeConfig) {
|
||||
return [JSON.parse(stripeConfig.value)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Currency, DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
|
@ -68,10 +68,11 @@ export class OrderController {
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<OrderModel[]> {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let orders = await this.orderService.orders({
|
||||
include: {
|
||||
@ -132,12 +133,26 @@ export class OrderController {
|
||||
return this.orderService.createOrder(
|
||||
{
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
date,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
},
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
|
@ -3,6 +3,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
@ -50,14 +51,18 @@ export class OrderService {
|
||||
): Promise<Order> {
|
||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
||||
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.symbol
|
||||
}
|
||||
]);
|
||||
if (!isAfter(data.date as Date, endOfToday())) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.symbol
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData([data.symbol]);
|
||||
|
||||
await this.cacheService.flush(aUserId);
|
||||
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { Position } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface PortfolioPositions {
|
||||
positions: Position[];
|
||||
}
|
@ -11,6 +11,7 @@ import {
|
||||
PortfolioPosition,
|
||||
PortfolioReport
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
@ -37,6 +38,7 @@ import {
|
||||
HistoricalDataItem,
|
||||
PortfolioPositionDetail
|
||||
} from './interfaces/portfolio-position-detail.interface';
|
||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@Controller('portfolio')
|
||||
@ -48,12 +50,14 @@ export class PortfolioController {
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Get('/investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async findAll(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<PortfolioItem[]> {
|
||||
let portfolio = await this.portfolioService.findAll(impersonationId);
|
||||
): Promise<InvestmentItem[]> {
|
||||
let investments = await this.portfolioService.getInvestments(
|
||||
impersonationId
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationId &&
|
||||
@ -62,25 +66,18 @@ export class PortfolioController {
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
) {
|
||||
portfolio = portfolio.map((portfolioItem) => {
|
||||
Object.keys(portfolioItem.positions).forEach((symbol) => {
|
||||
portfolioItem.positions[symbol].investment =
|
||||
portfolioItem.positions[symbol].investment > 0 ? 1 : 0;
|
||||
portfolioItem.positions[symbol].investmentInOriginalCurrency =
|
||||
portfolioItem.positions[symbol].investmentInOriginalCurrency > 0
|
||||
? 1
|
||||
: 0;
|
||||
portfolioItem.positions[symbol].quantity =
|
||||
portfolioItem.positions[symbol].quantity > 0 ? 1 : 0;
|
||||
});
|
||||
const maxInvestment = investments.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
1
|
||||
);
|
||||
|
||||
portfolioItem.investment = null;
|
||||
|
||||
return portfolioItem;
|
||||
});
|
||||
investments = investments.map((item) => ({
|
||||
date: item.date,
|
||||
investment: item.investment / maxInvestment
|
||||
}));
|
||||
}
|
||||
|
||||
return portfolio;
|
||||
return investments;
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@ -142,17 +139,17 @@ export class PortfolioController {
|
||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
||||
let details: { [symbol: string]: PortfolioPosition } = {};
|
||||
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
const portfolio = await this.portfolioService.createPortfolio(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
try {
|
||||
details = await portfolio.getDetails(range);
|
||||
details = await this.portfolioService.getDetails(
|
||||
impersonationUserId,
|
||||
range
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@ -221,6 +218,7 @@ export class PortfolioController {
|
||||
)
|
||||
) {
|
||||
overview = nullifyValuesInObject(overview, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'fees',
|
||||
'totalBuy',
|
||||
@ -238,21 +236,16 @@ export class PortfolioController {
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPerformance> {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
range
|
||||
);
|
||||
|
||||
const portfolio = await this.portfolioService.createPortfolio(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
let performance = await portfolio.getPerformance(range);
|
||||
|
||||
if (hasNotDefinedValuesInObject(performance)) {
|
||||
if (performanceInformation?.hasErrors) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
let performance = performanceInformation.performance;
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
@ -270,6 +263,25 @@ export class PortfolioController {
|
||||
return <any>res.json(performance);
|
||||
}
|
||||
|
||||
@Get('positions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
if (result?.hasErrors) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
return <any>res.json(result);
|
||||
}
|
||||
|
||||
@Get('position/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@ -306,15 +318,6 @@ export class PortfolioController {
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<PortfolioReport> {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
const portfolio = await this.portfolioService.createPortfolio(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
return await portfolio.getReport();
|
||||
return await this.portfolioService.getReport(impersonationId);
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,10 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/app/core/market-data.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
@ -9,12 +16,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { OrderService } from '../order/order.service';
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@ -22,19 +26,23 @@ import { PortfolioService } from './portfolio.service';
|
||||
imports: [RedisCacheModule],
|
||||
controllers: [PortfolioController],
|
||||
providers: [
|
||||
AccountService,
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
CurrentRateService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
MarketDataService,
|
||||
OrderService,
|
||||
PortfolioService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
RulesService,
|
||||
SymbolProfileService,
|
||||
UserService,
|
||||
YahooFinanceService
|
||||
]
|
||||
|
@ -1,37 +1,59 @@
|
||||
import { Portfolio } from '@ghostfolio/api/models/portfolio';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
|
||||
import { TimelineSpecification } from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
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 { 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 { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
|
||||
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioItem,
|
||||
PortfolioOverview
|
||||
PortfolioOverview,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
Position,
|
||||
TimelinePosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import {
|
||||
DateRange,
|
||||
OrderWithAccount,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Currency, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
add,
|
||||
endOfToday,
|
||||
format,
|
||||
getDate,
|
||||
getMonth,
|
||||
getYear,
|
||||
isAfter,
|
||||
isSameDay,
|
||||
isBefore,
|
||||
max,
|
||||
parse,
|
||||
parseISO,
|
||||
setDate,
|
||||
setMonth,
|
||||
sub
|
||||
setDayOfYear,
|
||||
subDays,
|
||||
subYears
|
||||
} from 'date-fns';
|
||||
import { isEmpty } from 'lodash';
|
||||
import * as roundTo from 'round-to';
|
||||
|
||||
import { OrderService } from '../order/order.service';
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import {
|
||||
HistoricalDataItem,
|
||||
PortfolioPositionDetail
|
||||
@ -40,174 +62,240 @@ import {
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly currentRateService: CurrentRateService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly rulesService: RulesService,
|
||||
private readonly userService: UserService
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async createPortfolio(aUserId: string): Promise<Portfolio> {
|
||||
let portfolio: Portfolio;
|
||||
let stringifiedPortfolio = await this.redisCacheService.get(
|
||||
`${aUserId}.portfolio`
|
||||
public async getInvestments(
|
||||
aImpersonationId: string
|
||||
): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
|
||||
const user = await this.userService.user({ id: aUserId });
|
||||
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (stringifiedPortfolio) {
|
||||
// Get portfolio from redis
|
||||
const {
|
||||
orders,
|
||||
portfolioItems
|
||||
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = JSON.parse(
|
||||
stringifiedPortfolio
|
||||
);
|
||||
|
||||
portfolio = new Portfolio(
|
||||
this.dataProviderService,
|
||||
this.exchangeRateDataService,
|
||||
this.rulesService
|
||||
).createFromData({ orders, portfolioItems, user });
|
||||
} else {
|
||||
// Get portfolio from database
|
||||
const orders = await this.orderService.orders({
|
||||
include: {
|
||||
Account: true,
|
||||
SymbolProfile: true
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
where: { userId: aUserId }
|
||||
});
|
||||
|
||||
portfolio = new Portfolio(
|
||||
this.dataProviderService,
|
||||
this.exchangeRateDataService,
|
||||
this.rulesService
|
||||
);
|
||||
portfolio.setUser(user);
|
||||
await portfolio.setOrders(orders);
|
||||
|
||||
// Cache data for the next time...
|
||||
const portfolioData = {
|
||||
orders: portfolio.getOrders(),
|
||||
portfolioItems: portfolio.getPortfolioItems()
|
||||
return portfolioCalculator.getInvestments().map((item) => {
|
||||
return {
|
||||
date: item.date,
|
||||
investment: item.investment.toNumber()
|
||||
};
|
||||
|
||||
await this.redisCacheService.set(
|
||||
`${aUserId}.portfolio`,
|
||||
JSON.stringify(portfolioData)
|
||||
);
|
||||
}
|
||||
|
||||
// Enrich portfolio with current data
|
||||
return await portfolio.addCurrentPortfolioItems();
|
||||
}
|
||||
|
||||
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
|
||||
try {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
aImpersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
const portfolio = await this.createPortfolio(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
return portfolio.get();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getChart(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<HistoricalDataItem[]> {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
aImpersonationId,
|
||||
this.request.user.id
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
|
||||
const portfolio = await this.createPortfolio(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
if (portfolio.getOrders().length <= 0) {
|
||||
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let portfolioStart = parse(
|
||||
transactionPoints[0].date,
|
||||
DATE_FORMAT,
|
||||
new Date()
|
||||
);
|
||||
portfolioStart = this.getStartDate(aDateRange, portfolioStart);
|
||||
|
||||
const dateRangeDate = this.convertDateRangeToDate(
|
||||
aDateRange,
|
||||
portfolio.getMinDate()
|
||||
const timelineSpecification: TimelineSpecification[] = [
|
||||
{
|
||||
start: format(portfolioStart, DATE_FORMAT),
|
||||
accuracy: 'day'
|
||||
}
|
||||
];
|
||||
|
||||
const timeline = await portfolioCalculator.calculateTimeline(
|
||||
timelineSpecification,
|
||||
format(new Date(), DATE_FORMAT)
|
||||
);
|
||||
|
||||
return portfolio
|
||||
.get()
|
||||
.filter((portfolioItem) => {
|
||||
if (dateRangeDate === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
isSameDay(parseISO(portfolioItem.date), dateRangeDate) ||
|
||||
isAfter(parseISO(portfolioItem.date), dateRangeDate)
|
||||
);
|
||||
})
|
||||
.map((portfolioItem) => {
|
||||
return {
|
||||
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
|
||||
grossPerformancePercent: portfolioItem.grossPerformancePercent,
|
||||
marketPrice: portfolioItem.value ?? null,
|
||||
value: portfolioItem.value - portfolioItem.investment ?? null
|
||||
};
|
||||
});
|
||||
return timeline
|
||||
.filter((timelineItem) => timelineItem !== null)
|
||||
.map((timelineItem) => ({
|
||||
date: timelineItem.date,
|
||||
marketPrice: timelineItem.value,
|
||||
value: timelineItem.grossPerformance.toNumber()
|
||||
}));
|
||||
}
|
||||
|
||||
public async getOverview(
|
||||
aImpersonationId: string
|
||||
): Promise<PortfolioOverview> {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
aImpersonationId,
|
||||
this.request.user.id
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const { balance } = await this.accountService.getCashDetails(
|
||||
userId,
|
||||
currency
|
||||
);
|
||||
const orders = await this.getOrders(userId);
|
||||
const fees = this.getFees(orders);
|
||||
|
||||
const portfolio = await this.createPortfolio(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
const committedFunds = portfolio.getCommittedFunds();
|
||||
const fees = portfolio.getFees();
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
|
||||
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
|
||||
return {
|
||||
committedFunds,
|
||||
committedFunds: totalBuy - totalSell,
|
||||
fees,
|
||||
ordersCount: portfolio.getOrders().length,
|
||||
totalBuy: portfolio.getTotalBuy(),
|
||||
totalSell: portfolio.getTotalSell()
|
||||
cash: balance,
|
||||
ordersCount: orders.length,
|
||||
totalBuy: totalBuy,
|
||||
totalSell: totalSell
|
||||
};
|
||||
}
|
||||
|
||||
public async getDetails(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
userCurrency
|
||||
);
|
||||
|
||||
const { transactionPoints, orders } = await this.getTransactionPoints(
|
||||
userId
|
||||
);
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate
|
||||
);
|
||||
|
||||
if (currentPositions.hasErrors) {
|
||||
throw new Error('Missing information');
|
||||
}
|
||||
|
||||
const result: { [symbol: string]: PortfolioPosition } = {};
|
||||
const totalValue = currentPositions.currentValue;
|
||||
|
||||
const symbols = currentPositions.positions.map(
|
||||
(position) => position.symbol
|
||||
);
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.get(symbols),
|
||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||
]);
|
||||
|
||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||
for (const symbolProfile of symbolProfiles) {
|
||||
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
|
||||
}
|
||||
|
||||
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
|
||||
for (const position of currentPositions.positions) {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency);
|
||||
|
||||
for (const item of currentPositions.positions) {
|
||||
const value = item.quantity.mul(item.marketPrice);
|
||||
const symbolProfile = symbolProfileMap[item.symbol];
|
||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||
result[item.symbol] = {
|
||||
accounts,
|
||||
allocationCurrent: value.div(totalValue).toNumber(),
|
||||
allocationInvestment: item.investment
|
||||
.div(currentPositions.totalInvestment)
|
||||
.toNumber(),
|
||||
countries: symbolProfile.countries,
|
||||
currency: item.currency,
|
||||
exchange: dataProviderResponse.exchange,
|
||||
grossPerformance: item.grossPerformance.toNumber(),
|
||||
grossPerformancePercent: item.grossPerformancePercentage.toNumber(),
|
||||
investment: item.investment.toNumber(),
|
||||
marketPrice: item.marketPrice,
|
||||
marketState: dataProviderResponse.marketState,
|
||||
name: item.name,
|
||||
quantity: item.quantity.toNumber(),
|
||||
sectors: symbolProfile.sectors,
|
||||
symbol: item.symbol,
|
||||
transactionCount: item.transactionCount,
|
||||
type: dataProviderResponse.type,
|
||||
value: value.toNumber()
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async getPosition(
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
aImpersonationId,
|
||||
this.request.user.id
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
|
||||
const portfolio = await this.createPortfolio(
|
||||
impersonationUserId || this.request.user.id
|
||||
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {
|
||||
averagePrice: undefined,
|
||||
currency: undefined,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
grossPerformancePercent: undefined,
|
||||
historicalData: [],
|
||||
investment: undefined,
|
||||
marketPrice: undefined,
|
||||
maxPrice: undefined,
|
||||
minPrice: undefined,
|
||||
quantity: undefined,
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined
|
||||
};
|
||||
}
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
portfolioStart
|
||||
);
|
||||
|
||||
const positions = portfolio.getPositions(new Date())[aSymbol];
|
||||
const position = currentPositions.positions.find(
|
||||
(item) => item.symbol === aSymbol
|
||||
);
|
||||
|
||||
if (positions) {
|
||||
let {
|
||||
if (position) {
|
||||
const {
|
||||
averagePrice,
|
||||
currency,
|
||||
firstBuyDate,
|
||||
@ -215,9 +303,7 @@ export class PortfolioService {
|
||||
marketPrice,
|
||||
quantity,
|
||||
transactionCount
|
||||
} = portfolio.getPositions(new Date())[aSymbol];
|
||||
|
||||
const orders = portfolio.getOrders(aSymbol);
|
||||
} = position;
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistorical(
|
||||
[aSymbol],
|
||||
@ -226,32 +312,29 @@ export class PortfolioService {
|
||||
new Date()
|
||||
);
|
||||
|
||||
if (marketPrice === 0) {
|
||||
marketPrice = averagePrice;
|
||||
}
|
||||
|
||||
const historicalDataArray: HistoricalDataItem[] = [];
|
||||
let currentAveragePrice: number;
|
||||
let maxPrice = marketPrice;
|
||||
let minPrice = marketPrice;
|
||||
|
||||
if (historicalData[aSymbol]) {
|
||||
let j = -1;
|
||||
for (const [date, { marketPrice }] of Object.entries(
|
||||
historicalData[aSymbol]
|
||||
)) {
|
||||
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
|
||||
if (
|
||||
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
|
||||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
|
||||
while (
|
||||
j + 1 < transactionPoints.length &&
|
||||
!isAfter(parseDate(transactionPoints[j + 1].date), parseDate(date))
|
||||
) {
|
||||
// Get snapshot of first day of month
|
||||
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
|
||||
.positions[aSymbol];
|
||||
orders.shift();
|
||||
|
||||
if (snapshot?.averagePrice) {
|
||||
currentAveragePrice = snapshot?.averagePrice;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
let currentAveragePrice = 0;
|
||||
const currentSymbol = transactionPoints[j].items.find(
|
||||
(item) => item.symbol === aSymbol
|
||||
);
|
||||
if (currentSymbol) {
|
||||
currentAveragePrice = currentSymbol.investment
|
||||
.div(currentSymbol.quantity)
|
||||
.toNumber();
|
||||
}
|
||||
|
||||
historicalDataArray.push({
|
||||
@ -260,65 +343,47 @@ export class PortfolioService {
|
||||
value: marketPrice
|
||||
});
|
||||
|
||||
if (
|
||||
marketPrice &&
|
||||
(marketPrice > maxPrice || maxPrice === undefined)
|
||||
) {
|
||||
maxPrice = marketPrice;
|
||||
}
|
||||
|
||||
if (
|
||||
marketPrice &&
|
||||
(marketPrice < minPrice || minPrice === undefined)
|
||||
) {
|
||||
minPrice = marketPrice;
|
||||
}
|
||||
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
||||
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
averagePrice,
|
||||
currency,
|
||||
firstBuyDate,
|
||||
investment,
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
quantity,
|
||||
transactionCount,
|
||||
grossPerformance: this.exchangeRateDataService.toCurrency(
|
||||
marketPrice - averagePrice,
|
||||
currency,
|
||||
this.request.user.Settings.currency
|
||||
),
|
||||
grossPerformancePercent: roundTo(
|
||||
(marketPrice - averagePrice) / averagePrice,
|
||||
4
|
||||
),
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
grossPerformance: position.grossPerformance.toNumber(),
|
||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||
historicalData: historicalDataArray,
|
||||
investment: investment.toNumber(),
|
||||
quantity: quantity.toNumber(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
} else if (portfolio.getMinDate()) {
|
||||
} else {
|
||||
const currentData = await this.dataProviderService.get([aSymbol]);
|
||||
|
||||
let historicalData = await this.dataProviderService.getHistorical(
|
||||
[aSymbol],
|
||||
'day',
|
||||
portfolio.getMinDate(),
|
||||
portfolioStart,
|
||||
new Date()
|
||||
);
|
||||
|
||||
if (isEmpty(historicalData)) {
|
||||
historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
||||
portfolio.getMinDate(),
|
||||
portfolioStart,
|
||||
new Date()
|
||||
);
|
||||
}
|
||||
|
||||
const historicalDataArray: HistoricalDataItem[] = [];
|
||||
|
||||
for (const [date, { marketPrice, performance }] of Object.entries(
|
||||
for (const [date, { marketPrice }] of Object.entries(
|
||||
historicalData[aSymbol]
|
||||
).reverse()) {
|
||||
historicalDataArray.push({
|
||||
@ -329,13 +394,13 @@ export class PortfolioService {
|
||||
|
||||
return {
|
||||
averagePrice: undefined,
|
||||
currency: currentData[aSymbol].currency,
|
||||
currency: currentData[aSymbol]?.currency,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
grossPerformancePercent: undefined,
|
||||
historicalData: historicalDataArray,
|
||||
investment: undefined,
|
||||
marketPrice: currentData[aSymbol].marketPrice,
|
||||
marketPrice: currentData[aSymbol]?.marketPrice,
|
||||
maxPrice: undefined,
|
||||
minPrice: undefined,
|
||||
quantity: undefined,
|
||||
@ -343,68 +408,339 @@ export class PortfolioService {
|
||||
transactionCount: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async getPositions(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
|
||||
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {
|
||||
hasErrors: false,
|
||||
positions: []
|
||||
};
|
||||
}
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate
|
||||
);
|
||||
|
||||
return {
|
||||
averagePrice: undefined,
|
||||
currency: undefined,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
grossPerformancePercent: undefined,
|
||||
historicalData: [],
|
||||
investment: undefined,
|
||||
marketPrice: undefined,
|
||||
maxPrice: undefined,
|
||||
minPrice: undefined,
|
||||
quantity: undefined,
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined
|
||||
hasErrors: currentPositions.hasErrors,
|
||||
positions: currentPositions.positions.map((position) => {
|
||||
return {
|
||||
...position,
|
||||
averagePrice: new Big(position.averagePrice).toNumber(),
|
||||
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
||||
grossPerformancePercentage:
|
||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||
investment: new Big(position.investment).toNumber(),
|
||||
name: position.name,
|
||||
quantity: new Big(position.quantity).toNumber()
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
|
||||
let currentDate = new Date();
|
||||
public async getPerformance(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const normalizedMinDate =
|
||||
getDate(aMinDate) === 1
|
||||
? aMinDate
|
||||
: add(setDate(aMinDate, 1), { months: 1 });
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
|
||||
const year = getYear(currentDate);
|
||||
const month = getMonth(currentDate);
|
||||
const day = getDate(currentDate);
|
||||
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||
|
||||
currentDate = new Date(Date.UTC(year, month, day, 0));
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {
|
||||
hasErrors: false,
|
||||
performance: {
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentValue: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate
|
||||
);
|
||||
|
||||
const hasErrors = currentPositions.hasErrors;
|
||||
const currentValue = currentPositions.currentValue.toNumber();
|
||||
const currentGrossPerformance =
|
||||
currentPositions.grossPerformance.toNumber();
|
||||
const currentGrossPerformancePercent =
|
||||
currentPositions.grossPerformancePercentage.toNumber();
|
||||
return {
|
||||
hasErrors: currentPositions.hasErrors || hasErrors,
|
||||
performance: {
|
||||
currentGrossPerformance,
|
||||
currentGrossPerformancePercent,
|
||||
// TODO: the next two should include fees
|
||||
currentNetPerformance: currentGrossPerformance,
|
||||
currentNetPerformancePercent: currentGrossPerformancePercent,
|
||||
currentValue: currentValue
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date
|
||||
return isBefore(date, new Date(order.date));
|
||||
})
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.currency,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
|
||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||
const userId = await this.getUserId(impersonationId);
|
||||
const baseCurrency = this.request.user.Settings.currency;
|
||||
|
||||
const { transactionPoints, orders } = await this.getTransactionPoints(
|
||||
userId
|
||||
);
|
||||
|
||||
if (isEmpty(orders)) {
|
||||
return {
|
||||
rules: {}
|
||||
};
|
||||
}
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
portfolioStart
|
||||
);
|
||||
|
||||
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
|
||||
for (const position of currentPositions.positions) {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
const accounts = this.getAccounts(orders, portfolioItemsNow, baseCurrency);
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
[
|
||||
new AccountClusterRiskInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
accounts
|
||||
),
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
accounts
|
||||
),
|
||||
new AccountClusterRiskSingleAccount(
|
||||
this.exchangeRateDataService,
|
||||
accounts
|
||||
)
|
||||
],
|
||||
{ baseCurrency }
|
||||
),
|
||||
currencyClusterRisk: await this.rulesService.evaluate(
|
||||
[
|
||||
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions
|
||||
),
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions
|
||||
),
|
||||
new CurrencyClusterRiskInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions
|
||||
),
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions
|
||||
)
|
||||
],
|
||||
{ baseCurrency }
|
||||
),
|
||||
fees: await this.rulesService.evaluate(
|
||||
[
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions.totalInvestment.toNumber(),
|
||||
this.getFees(orders)
|
||||
)
|
||||
],
|
||||
{ baseCurrency }
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
return sub(currentDate, {
|
||||
days: 1
|
||||
});
|
||||
portfolioStart = max([portfolioStart, subDays(new Date(), 1)]);
|
||||
break;
|
||||
case 'ytd':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = setMonth(currentDate, 0);
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]);
|
||||
break;
|
||||
case '1y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 1
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
portfolioStart = max([portfolioStart, subYears(new Date(), 1)]);
|
||||
break;
|
||||
case '5y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 5
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
default:
|
||||
// Gets handled as all data
|
||||
return undefined;
|
||||
portfolioStart = max([portfolioStart, subYears(new Date(), 5)]);
|
||||
break;
|
||||
}
|
||||
return portfolioStart;
|
||||
}
|
||||
|
||||
private async getTransactionPoints(userId: string): Promise<{
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: OrderWithAccount[];
|
||||
}> {
|
||||
const orders = await this.getOrders(userId);
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return { transactionPoints: [], orders: [] };
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.symbol,
|
||||
type: <OrderType>order.type,
|
||||
unitPrice: new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
order.unitPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
)
|
||||
)
|
||||
}));
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
userCurrency
|
||||
);
|
||||
portfolioCalculator.computeTransactionPoints(portfolioOrders);
|
||||
return {
|
||||
transactionPoints: portfolioCalculator.getTransactionPoints(),
|
||||
orders
|
||||
};
|
||||
}
|
||||
|
||||
private getAccounts(
|
||||
orders: OrderWithAccount[],
|
||||
portfolioItemsNow: { [p: string]: TimelinePosition },
|
||||
userCurrency
|
||||
) {
|
||||
const accounts: PortfolioPosition['accounts'] = {};
|
||||
for (const order of orders) {
|
||||
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
);
|
||||
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * order.unitPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
);
|
||||
|
||||
if (order.type === 'SELL') {
|
||||
currentValueOfSymbol *= -1;
|
||||
originalValueOfSymbol *= -1;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
||||
currentValueOfSymbol;
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
||||
originalValueOfSymbol;
|
||||
} else {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
||||
current: currentValueOfSymbol,
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
}
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private getOrders(aUserId: string) {
|
||||
return this.orderService.orders({
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: true,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
where: { userId: aUserId }
|
||||
});
|
||||
}
|
||||
|
||||
private async getUserId(aImpersonationId: string) {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
aImpersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
return impersonationUserId || this.request.user.id;
|
||||
}
|
||||
|
||||
private getTotalByType(
|
||||
orders: OrderWithAccount[],
|
||||
currency: Currency,
|
||||
type: TypeOfOrder
|
||||
) {
|
||||
return orders
|
||||
.filter(
|
||||
(order) => !isAfter(order.date, endOfToday()) && order.type === type
|
||||
)
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * order.unitPrice,
|
||||
order.currency,
|
||||
currency
|
||||
);
|
||||
})
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
}
|
||||
|
57
apps/api/src/app/subscription/subscription.controller.ts
Normal file
57
apps/api/src/app/subscription/subscription.controller.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
|
||||
@Controller('subscription')
|
||||
export class SubscriptionController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
@Get('stripe/callback')
|
||||
public async stripeCallback(@Req() req, @Res() res) {
|
||||
await this.subscriptionService.createSubscription(
|
||||
req.query.checkoutSessionId
|
||||
);
|
||||
|
||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
||||
}
|
||||
|
||||
@Post('stripe/checkout-session')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createCheckoutSession(
|
||||
@Body() { couponId, priceId }: { couponId: string; priceId: string }
|
||||
) {
|
||||
try {
|
||||
return await this.subscriptionService.createCheckoutSession({
|
||||
couponId,
|
||||
priceId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
13
apps/api/src/app/subscription/subscription.module.ts
Normal file
13
apps/api/src/app/subscription/subscription.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SubscriptionController } from './subscription.controller';
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [SubscriptionController],
|
||||
providers: [ConfigurationService, PrismaService, SubscriptionService]
|
||||
})
|
||||
export class SubscriptionModule {}
|
89
apps/api/src/app/subscription/subscription.service.ts
Normal file
89
apps/api/src/app/subscription/subscription.service.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { addDays } from 'date-fns';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionService {
|
||||
private stripe: Stripe;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private prisma: PrismaService
|
||||
) {
|
||||
this.stripe = new Stripe(
|
||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||
{
|
||||
apiVersion: '2020-08-27'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async createCheckoutSession({
|
||||
couponId,
|
||||
priceId,
|
||||
userId
|
||||
}: {
|
||||
couponId?: string;
|
||||
priceId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
|
||||
client_reference_id: userId,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1
|
||||
}
|
||||
],
|
||||
mode: 'payment',
|
||||
payment_method_types: ['card'],
|
||||
success_url: `${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
|
||||
};
|
||||
|
||||
if (couponId) {
|
||||
checkoutSessionCreateParams.discounts = [
|
||||
{
|
||||
coupon: couponId
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create(
|
||||
checkoutSessionCreateParams
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: session.id
|
||||
};
|
||||
}
|
||||
|
||||
public async createSubscription(aCheckoutSessionId: string) {
|
||||
try {
|
||||
const session = await this.stripe.checkout.sessions.retrieve(
|
||||
aCheckoutSessionId
|
||||
);
|
||||
|
||||
await this.prisma.subscription.create({
|
||||
data: {
|
||||
expiresAt: addDays(new Date(), 365),
|
||||
User: {
|
||||
connect: {
|
||||
id: session.client_reference_id
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await this.stripe.customers.update(session.customer as string, {
|
||||
description: session.client_reference_id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
|
||||
export interface UserSettingsParams {
|
||||
currency?: Currency;
|
||||
userId: string;
|
||||
viewMode?: ViewMode;
|
||||
}
|
@ -25,6 +25,7 @@ import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ -92,10 +93,20 @@ export class UserController {
|
||||
);
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSettings({
|
||||
const userSettings: UserSettingsParams = {
|
||||
currency: data.baseCurrency,
|
||||
userId: this.request.user.id,
|
||||
viewMode: data.viewMode
|
||||
});
|
||||
userId: this.request.user.id
|
||||
};
|
||||
|
||||
if (
|
||||
hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateViewMode
|
||||
)
|
||||
) {
|
||||
userSettings.viewMode = data.viewMode;
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSettings(userSettings);
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { locale } from '@ghostfolio/common/config';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
import { add, isBefore } from 'date-fns';
|
||||
import { isBefore } from 'date-fns';
|
||||
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@ -24,7 +25,7 @@ export class UserService {
|
||||
Account,
|
||||
alias,
|
||||
id,
|
||||
role,
|
||||
permissions,
|
||||
Settings,
|
||||
subscription
|
||||
}: UserWithSettings): Promise<IUser> {
|
||||
@ -36,15 +37,10 @@ export class UserService {
|
||||
where: { GranteeUser: { id } }
|
||||
});
|
||||
|
||||
const currentPermissions = getPermissions(role);
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
||||
}
|
||||
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
subscription,
|
||||
access: access.map((accessItem) => {
|
||||
return {
|
||||
@ -53,7 +49,6 @@ export class UserService {
|
||||
};
|
||||
}),
|
||||
accounts: Account,
|
||||
permissions: currentPermissions,
|
||||
settings: {
|
||||
locale,
|
||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||
@ -72,6 +67,14 @@ export class UserService {
|
||||
|
||||
const user: UserWithSettings = userFromDatabase;
|
||||
|
||||
const currentPermissions = getPermissions(userFromDatabase.role);
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
||||
}
|
||||
|
||||
user.permissions = currentPermissions;
|
||||
|
||||
if (userFromDatabase?.Settings) {
|
||||
if (!userFromDatabase.Settings.currency) {
|
||||
// Set default currency if needed
|
||||
@ -106,6 +109,13 @@ export class UserService {
|
||||
type: SubscriptionType.Basic
|
||||
};
|
||||
}
|
||||
|
||||
if (user.subscription.type === SubscriptionType.Basic) {
|
||||
user.permissions = user.permissions.filter((permission) => {
|
||||
return permission !== permissions.updateViewMode;
|
||||
});
|
||||
user.Settings.viewMode = ViewMode.ZEN;
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
@ -213,11 +223,7 @@ export class UserService {
|
||||
currency,
|
||||
userId,
|
||||
viewMode
|
||||
}: {
|
||||
currency?: Currency;
|
||||
userId: string;
|
||||
viewMode?: ViewMode;
|
||||
}) {
|
||||
}: UserSettingsParams) {
|
||||
await this.prisma.settings.upsert({
|
||||
create: {
|
||||
currency,
|
||||
|
@ -1,3 +1,4 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
production: true,
|
||||
version: `v${require('../../../../package.json').version}`
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
export const environment = {
|
||||
production: false
|
||||
production: false,
|
||||
version: 'dev'
|
||||
};
|
||||
|
@ -5,13 +5,9 @@ import { Order } from '../order';
|
||||
export interface PortfolioInterface {
|
||||
get(aDate?: Date): PortfolioItem[];
|
||||
|
||||
getCommittedFunds(): number;
|
||||
|
||||
getFees(): number;
|
||||
|
||||
getPositions(
|
||||
aDate: Date
|
||||
): {
|
||||
getPositions(aDate: Date): {
|
||||
[symbol: string]: Position;
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,3 @@
|
||||
export interface RuleSettings {
|
||||
isActive: boolean;
|
||||
}
|
@ -1,15 +1,10 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
|
||||
import { EvaluationResult } from './evaluation-result.interface';
|
||||
|
||||
export interface RuleInterface {
|
||||
evaluate(
|
||||
aPortfolioPositionMap: {
|
||||
[symbol: string]: PortfolioPosition;
|
||||
},
|
||||
aFees: number,
|
||||
aRuleSettingsMap: {
|
||||
[key: string]: any;
|
||||
}
|
||||
): EvaluationResult;
|
||||
export interface RuleInterface<T extends RuleSettings> {
|
||||
evaluate(aRuleSettings: T): EvaluationResult;
|
||||
|
||||
getSettings(aUserSettings: UserSettings): T;
|
||||
}
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface UserSettings {
|
||||
baseCurrency: Currency;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { Account, Currency, Platform, SymbolProfile } from '@prisma/client';
|
||||
import { Account, Currency, SymbolProfile } from '@prisma/client';
|
||||
import { endOfToday, isAfter, parseISO } from 'date-fns';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
@ -52,6 +53,10 @@ export class Order {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public getIsDraft() {
|
||||
return isAfter(parseISO(this.date), endOfToday());
|
||||
}
|
||||
|
||||
public getQuantity() {
|
||||
return this.quantity;
|
||||
}
|
||||
|
@ -1,645 +0,0 @@
|
||||
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AccountType,
|
||||
Currency,
|
||||
DataSource,
|
||||
Role,
|
||||
Type,
|
||||
ViewMode
|
||||
} from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { DataProviderService } from '../services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { MarketState } from '../services/interfaces/interfaces';
|
||||
import { RulesService } from '../services/rules.service';
|
||||
import { Portfolio } from './portfolio';
|
||||
|
||||
jest.mock('../services/data-provider.service', () => {
|
||||
return {
|
||||
DataProviderService: jest.fn().mockImplementation(() => {
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
const yesterday = format(getYesterday(), 'yyyy-MM-dd');
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
return Promise.resolve({
|
||||
BTCUSD: {
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
exchange: UNKNOWN_KEY,
|
||||
marketPrice: 57973.008,
|
||||
marketState: MarketState.open,
|
||||
name: 'Bitcoin USD',
|
||||
type: 'Cryptocurrency'
|
||||
},
|
||||
ETHUSD: {
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
exchange: UNKNOWN_KEY,
|
||||
marketPrice: 3915.337,
|
||||
marketState: MarketState.open,
|
||||
name: 'Ethereum USD',
|
||||
type: 'Cryptocurrency'
|
||||
}
|
||||
});
|
||||
},
|
||||
getHistorical: () => {
|
||||
return Promise.resolve({
|
||||
BTCUSD: {
|
||||
[yesterday]: 56710.122,
|
||||
[today]: 57973.008
|
||||
},
|
||||
ETHUSD: {
|
||||
[yesterday]: 3641.984,
|
||||
[today]: 3915.337
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../services/exchange-rate-data.service', () => {
|
||||
return {
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
initialize: () => Promise.resolve(),
|
||||
toCurrency: (value: number) => value
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../services/data-provider.service');
|
||||
jest.mock('../services/exchange-rate-data.service');
|
||||
jest.mock('../services/rules.service');
|
||||
|
||||
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
|
||||
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
|
||||
|
||||
describe('Portfolio', () => {
|
||||
let dataProviderService: DataProviderService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let portfolio: Portfolio;
|
||||
let rulesService: RulesService;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataProviderService = new DataProviderService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null);
|
||||
rulesService = new RulesService();
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
|
||||
portfolio = new Portfolio(
|
||||
dataProviderService,
|
||||
exchangeRateDataService,
|
||||
rulesService
|
||||
);
|
||||
portfolio.setUser({
|
||||
accessToken: null,
|
||||
Account: [
|
||||
{
|
||||
accountType: AccountType.SECURITIES,
|
||||
createdAt: new Date(),
|
||||
id: DEFAULT_ACCOUNT_ID,
|
||||
isDefault: true,
|
||||
name: 'Default Account',
|
||||
platformId: null,
|
||||
updatedAt: new Date(),
|
||||
userId: USER_ID
|
||||
}
|
||||
],
|
||||
alias: 'Test',
|
||||
createdAt: new Date(),
|
||||
id: USER_ID,
|
||||
provider: null,
|
||||
role: Role.USER,
|
||||
Settings: {
|
||||
currency: Currency.CHF,
|
||||
updatedAt: new Date(),
|
||||
userId: USER_ID,
|
||||
viewMode: ViewMode.DEFAULT
|
||||
},
|
||||
thirdPartyId: null,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
});
|
||||
|
||||
describe('works with no orders', () => {
|
||||
it('should return []', () => {
|
||||
expect(portfolio.get(new Date())).toEqual([]);
|
||||
expect(portfolio.getFees()).toEqual(0);
|
||||
expect(portfolio.getPositions(new Date())).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty details', async () => {
|
||||
const details = await portfolio.getDetails('1d');
|
||||
expect(details).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty details', async () => {
|
||||
const details = await portfolio.getDetails('max');
|
||||
expect(details).toEqual({});
|
||||
});
|
||||
|
||||
it('should return zero performance for 1d', async () => {
|
||||
const performance = await portfolio.getPerformance('1d');
|
||||
expect(performance).toEqual({
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentValue: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('should return zero performance for max', async () => {
|
||||
const performance = await portfolio.getPerformance('max');
|
||||
expect(performance).toEqual({
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentValue: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`works with today's orders`, () => {
|
||||
it('should return ["BTC"]', async () => {
|
||||
await portfolio.setOrders([
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 0,
|
||||
date: new Date(),
|
||||
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
|
||||
quantity: 1,
|
||||
symbol: 'BTCUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 49631.24,
|
||||
updatedAt: null,
|
||||
userId: USER_ID
|
||||
}
|
||||
]);
|
||||
|
||||
expect(portfolio.getCommittedFunds()).toEqual(
|
||||
exchangeRateDataService.toCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
);
|
||||
|
||||
const details = await portfolio.getDetails('1d');
|
||||
expect(details).toMatchObject({
|
||||
BTCUSD: {
|
||||
accounts: {
|
||||
[UNKNOWN_KEY]: {
|
||||
/*current: exchangeRateDataService.toCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),*/
|
||||
original: exchangeRateDataService.toCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
}
|
||||
},
|
||||
allocationCurrent: 1,
|
||||
allocationInvestment: 1,
|
||||
countries: [],
|
||||
currency: Currency.USD,
|
||||
exchange: UNKNOWN_KEY,
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
marketPrice: 57973.008,
|
||||
marketState: MarketState.open,
|
||||
name: 'Bitcoin USD',
|
||||
quantity: 1,
|
||||
symbol: 'BTCUSD',
|
||||
transactionCount: 1,
|
||||
type: 'Cryptocurrency'
|
||||
}
|
||||
});
|
||||
|
||||
expect(portfolio.getFees()).toEqual(0);
|
||||
|
||||
/*const performance1d = await portfolio.getPerformance('1d');
|
||||
expect(performance1d).toEqual({
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentValue: exchangeRateDataService.toBaseCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
});*/
|
||||
|
||||
/*const performanceMax = await portfolio.getPerformance('max');
|
||||
expect(performanceMax).toEqual({
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentValue: exchangeRateDataService.toBaseCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
});*/
|
||||
|
||||
expect(portfolio.getPositions(getYesterday())).toMatchObject({});
|
||||
|
||||
expect(portfolio.getSymbols(getYesterday())).toEqual(['BTCUSD']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('works with orders', () => {
|
||||
it('should return ["ETHUSD"]', async () => {
|
||||
await portfolio.setOrders([
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 0,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
userId: USER_ID
|
||||
}
|
||||
]);
|
||||
|
||||
expect(portfolio.getCommittedFunds()).toEqual(
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
);
|
||||
|
||||
const details = await portfolio.getDetails('1d');
|
||||
expect(details).toMatchObject({
|
||||
ETHUSD: {
|
||||
accounts: {
|
||||
[UNKNOWN_KEY]: {
|
||||
/*current: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),*/
|
||||
original: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
}
|
||||
},
|
||||
// allocationCurrent: 1,
|
||||
allocationInvestment: 1,
|
||||
countries: [],
|
||||
currency: Currency.USD,
|
||||
exchange: UNKNOWN_KEY,
|
||||
// grossPerformance: 0,
|
||||
// grossPerformancePercent: 0,
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
marketPrice: 3915.337,
|
||||
name: 'Ethereum USD',
|
||||
quantity: 0.2,
|
||||
transactionCount: 1,
|
||||
symbol: 'ETHUSD',
|
||||
type: 'Cryptocurrency'
|
||||
}
|
||||
});
|
||||
|
||||
expect(portfolio.getFees()).toEqual(0);
|
||||
|
||||
/*const performance = await portfolio.getPerformance('max');
|
||||
expect(performance).toEqual({
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentValue: 0
|
||||
});*/
|
||||
|
||||
expect(portfolio.getPositions(getYesterday())).toMatchObject({
|
||||
ETHUSD: {
|
||||
averagePrice: 991.49,
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
investmentInOriginalCurrency: 0.2 * 991.49,
|
||||
// marketPrice: 3915.337,
|
||||
quantity: 0.2
|
||||
}
|
||||
});
|
||||
|
||||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
|
||||
});
|
||||
|
||||
it('should return ["ETHUSD"]', async () => {
|
||||
await portfolio.setOrders([
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 0,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
userId: USER_ID
|
||||
},
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 0,
|
||||
date: new Date(getUtc('2018-01-28')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
quantity: 0.3,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
userId: USER_ID
|
||||
}
|
||||
]);
|
||||
|
||||
expect(portfolio.getCommittedFunds()).toEqual(
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
) +
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.3 * 1050,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
);
|
||||
|
||||
expect(portfolio.getFees()).toEqual(0);
|
||||
|
||||
expect(portfolio.getPositions(getYesterday())).toMatchObject({
|
||||
ETHUSD: {
|
||||
averagePrice: (0.2 * 991.49 + 0.3 * 1050) / (0.2 + 0.3),
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
||||
investment:
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
) +
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.3 * 1050,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
|
||||
// marketPrice: 3641.984,
|
||||
quantity: 0.5
|
||||
}
|
||||
});
|
||||
|
||||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
|
||||
});
|
||||
|
||||
it('should return ["BTCUSD", "ETHUSD"]', async () => {
|
||||
await portfolio.setOrders([
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.EUR,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: new Date(getUtc('2017-08-16')),
|
||||
fee: 2.99,
|
||||
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
|
||||
quantity: 0.05614682,
|
||||
symbol: 'BTCUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 3562.089535970158,
|
||||
updatedAt: null,
|
||||
userId: USER_ID
|
||||
},
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 2.99,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
userId: USER_ID
|
||||
}
|
||||
]);
|
||||
|
||||
expect(portfolio.getCommittedFunds()).toEqual(
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.05614682 * 3562.089535970158,
|
||||
Currency.EUR,
|
||||
baseCurrency
|
||||
) +
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
);
|
||||
|
||||
expect(portfolio.getFees()).toEqual(
|
||||
exchangeRateDataService.toCurrency(2.99, Currency.EUR, baseCurrency) +
|
||||
exchangeRateDataService.toCurrency(2.99, Currency.USD, baseCurrency)
|
||||
);
|
||||
|
||||
expect(portfolio.getPositions(getYesterday())).toMatchObject({
|
||||
BTCUSD: {
|
||||
averagePrice: 3562.089535970158,
|
||||
currency: Currency.EUR,
|
||||
firstBuyDate: '2017-08-16T00:00:00.000Z',
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
0.05614682 * 3562.089535970158,
|
||||
Currency.EUR,
|
||||
baseCurrency
|
||||
),
|
||||
investmentInOriginalCurrency: 0.05614682 * 3562.089535970158,
|
||||
// marketPrice: 0,
|
||||
quantity: 0.05614682
|
||||
},
|
||||
ETHUSD: {
|
||||
averagePrice: 991.49,
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
investmentInOriginalCurrency: 0.2 * 991.49,
|
||||
// marketPrice: 0,
|
||||
quantity: 0.2
|
||||
}
|
||||
});
|
||||
|
||||
expect(portfolio.getSymbols(getYesterday())).toEqual([
|
||||
'BTCUSD',
|
||||
'ETHUSD'
|
||||
]);
|
||||
});
|
||||
|
||||
it('should work with buy and sell', async () => {
|
||||
await portfolio.setOrders([
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 1.0,
|
||||
date: new Date(getUtc('2018-01-05')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
userId: USER_ID
|
||||
},
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 1.0,
|
||||
date: new Date(getUtc('2018-01-28')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
quantity: 0.1,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.SELL,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
userId: USER_ID
|
||||
},
|
||||
{
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
accountUserId: USER_ID,
|
||||
createdAt: null,
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
fee: 1.0,
|
||||
date: new Date(getUtc('2018-01-31')),
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
userId: USER_ID
|
||||
}
|
||||
]);
|
||||
|
||||
expect(portfolio.getCommittedFunds()).toEqual(
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
) -
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.1 * 1050,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
) +
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.2 * 1050,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
);
|
||||
|
||||
expect(portfolio.getFees()).toEqual(
|
||||
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
|
||||
);
|
||||
|
||||
expect(portfolio.getPositions(getYesterday())).toMatchObject({
|
||||
ETHUSD: {
|
||||
averagePrice:
|
||||
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
||||
// marketPrice: 0,
|
||||
quantity: 0.2 - 0.1 + 0.2
|
||||
}
|
||||
});
|
||||
|
||||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,863 +0,0 @@
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioItem,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
Position,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
import {
|
||||
add,
|
||||
format,
|
||||
getDate,
|
||||
getMonth,
|
||||
getYear,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameDay,
|
||||
isToday,
|
||||
isYesterday,
|
||||
parseISO,
|
||||
setDate,
|
||||
setMonth,
|
||||
sub
|
||||
} from 'date-fns';
|
||||
import { cloneDeep, isEmpty } from 'lodash';
|
||||
import * as roundTo from 'round-to';
|
||||
|
||||
import { DataProviderService } from '../services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
import { RulesService } from '../services/rules.service';
|
||||
import { PortfolioInterface } from './interfaces/portfolio.interface';
|
||||
import { Order } from './order';
|
||||
import { OrderType } from './order-type';
|
||||
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
|
||||
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
|
||||
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
|
||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment';
|
||||
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment';
|
||||
import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment';
|
||||
import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment';
|
||||
import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment';
|
||||
|
||||
export class Portfolio implements PortfolioInterface {
|
||||
private orders: Order[] = [];
|
||||
private portfolioItems: PortfolioItem[] = [];
|
||||
private user: UserWithSettings;
|
||||
|
||||
public constructor(
|
||||
private dataProviderService: DataProviderService,
|
||||
private exchangeRateDataService: ExchangeRateDataService,
|
||||
private rulesService: RulesService
|
||||
) {}
|
||||
|
||||
public async addCurrentPortfolioItems() {
|
||||
const currentData = await this.dataProviderService.get(this.getSymbols());
|
||||
|
||||
const currentDate = new Date();
|
||||
|
||||
const year = getYear(currentDate);
|
||||
const month = getMonth(currentDate);
|
||||
const day = getDate(currentDate);
|
||||
|
||||
const today = new Date(Date.UTC(year, month, day));
|
||||
const yesterday = getYesterday();
|
||||
|
||||
const [portfolioItemsYesterday] = this.get(yesterday);
|
||||
|
||||
let positions: { [symbol: string]: Position } = {};
|
||||
|
||||
this.getSymbols().forEach((symbol) => {
|
||||
positions[symbol] = {
|
||||
averagePrice: portfolioItemsYesterday?.positions[symbol]?.averagePrice,
|
||||
currency: portfolioItemsYesterday?.positions[symbol]?.currency,
|
||||
firstBuyDate: portfolioItemsYesterday?.positions[symbol]?.firstBuyDate,
|
||||
investment: portfolioItemsYesterday?.positions[symbol]?.investment,
|
||||
investmentInOriginalCurrency:
|
||||
portfolioItemsYesterday?.positions[symbol]
|
||||
?.investmentInOriginalCurrency,
|
||||
marketPrice:
|
||||
currentData[symbol]?.marketPrice ??
|
||||
portfolioItemsYesterday.positions[symbol]?.marketPrice,
|
||||
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity,
|
||||
transactionCount:
|
||||
portfolioItemsYesterday?.positions[symbol]?.transactionCount
|
||||
};
|
||||
});
|
||||
|
||||
if (portfolioItemsYesterday?.investment) {
|
||||
const portfolioItemsLength = this.portfolioItems.push(
|
||||
cloneDeep({
|
||||
date: today.toISOString(),
|
||||
grossPerformancePercent: 0,
|
||||
investment: portfolioItemsYesterday?.investment,
|
||||
positions: positions,
|
||||
value: 0
|
||||
})
|
||||
);
|
||||
|
||||
// Set value after pushing today's portfolio items
|
||||
this.portfolioItems[portfolioItemsLength - 1].value = this.getValue(
|
||||
today
|
||||
);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public createFromData({
|
||||
orders,
|
||||
portfolioItems,
|
||||
user
|
||||
}: {
|
||||
orders: IOrder[];
|
||||
portfolioItems: PortfolioItem[];
|
||||
user: UserWithSettings;
|
||||
}): Portfolio {
|
||||
orders.forEach(
|
||||
({
|
||||
account,
|
||||
currency,
|
||||
fee,
|
||||
date,
|
||||
id,
|
||||
quantity,
|
||||
symbol,
|
||||
symbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
this.orders.push(
|
||||
new Order({
|
||||
account,
|
||||
currency,
|
||||
fee,
|
||||
date,
|
||||
id,
|
||||
quantity,
|
||||
symbol,
|
||||
symbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
portfolioItems.forEach(
|
||||
({ date, grossPerformancePercent, investment, positions, value }) => {
|
||||
this.portfolioItems.push({
|
||||
date,
|
||||
grossPerformancePercent,
|
||||
investment,
|
||||
positions,
|
||||
value
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.setUser(user);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public get(aDate?: Date): PortfolioItem[] {
|
||||
if (aDate) {
|
||||
const filteredPortfolio = this.portfolioItems.find((item) => {
|
||||
return isSameDay(aDate, new Date(item.date));
|
||||
});
|
||||
|
||||
if (filteredPortfolio) {
|
||||
return [cloneDeep(filteredPortfolio)];
|
||||
}
|
||||
}
|
||||
|
||||
return cloneDeep(this.portfolioItems);
|
||||
}
|
||||
|
||||
public getCommittedFunds() {
|
||||
return this.getTotalBuy() - this.getTotalSell();
|
||||
}
|
||||
|
||||
public async getDetails(
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
||||
const dateRangeDate = this.convertDateRangeToDate(
|
||||
aDateRange,
|
||||
this.getMinDate()
|
||||
);
|
||||
|
||||
const [portfolioItemsBefore] = this.get(dateRangeDate);
|
||||
|
||||
const [portfolioItemsNow] = await this.get(new Date());
|
||||
|
||||
const investment = this.getInvestment(new Date());
|
||||
const portfolioItems = this.get(new Date());
|
||||
const symbols = this.getSymbols(new Date());
|
||||
const value = this.getValue();
|
||||
|
||||
const details: { [symbol: string]: PortfolioPosition } = {};
|
||||
|
||||
const data = await this.dataProviderService.get(symbols);
|
||||
|
||||
symbols.forEach((symbol) => {
|
||||
const accounts: PortfolioPosition['accounts'] = {};
|
||||
let countriesOfSymbol: Country[];
|
||||
const [portfolioItem] = portfolioItems;
|
||||
|
||||
const ordersBySymbol = this.getOrders().filter((order) => {
|
||||
return order.getSymbol() === symbol;
|
||||
});
|
||||
|
||||
ordersBySymbol.forEach((orderOfSymbol) => {
|
||||
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
orderOfSymbol.getQuantity() *
|
||||
portfolioItemsNow.positions[symbol].marketPrice,
|
||||
orderOfSymbol.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
orderOfSymbol.getQuantity() * orderOfSymbol.getUnitPrice(),
|
||||
orderOfSymbol.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
|
||||
if (orderOfSymbol.getType() === 'SELL') {
|
||||
currentValueOfSymbol *= -1;
|
||||
originalValueOfSymbol *= -1;
|
||||
}
|
||||
|
||||
if (
|
||||
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
|
||||
) {
|
||||
accounts[
|
||||
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
|
||||
].current += currentValueOfSymbol;
|
||||
accounts[
|
||||
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
|
||||
].original += originalValueOfSymbol;
|
||||
} else {
|
||||
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
|
||||
current: currentValueOfSymbol,
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
}
|
||||
|
||||
countriesOfSymbol = (
|
||||
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
|
||||
[]
|
||||
).map((country) => {
|
||||
const { code, weight } = country as Prisma.JsonObject;
|
||||
|
||||
return {
|
||||
code: code as string,
|
||||
continent:
|
||||
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||
name: countries[code as string]?.name ?? UNKNOWN_KEY,
|
||||
weight: weight as number
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
let now = portfolioItemsNow.positions[symbol].marketPrice;
|
||||
|
||||
// 1d
|
||||
let before = portfolioItemsBefore.positions[symbol].marketPrice;
|
||||
|
||||
if (aDateRange === 'ytd') {
|
||||
before =
|
||||
portfolioItemsBefore.positions[symbol].marketPrice ||
|
||||
portfolioItemsNow.positions[symbol].averagePrice;
|
||||
} else if (
|
||||
aDateRange === '1y' ||
|
||||
aDateRange === '5y' ||
|
||||
aDateRange === 'max'
|
||||
) {
|
||||
before = portfolioItemsNow.positions[symbol].averagePrice;
|
||||
}
|
||||
|
||||
if (
|
||||
!isBefore(
|
||||
parseISO(portfolioItemsNow.positions[symbol].firstBuyDate),
|
||||
parseISO(portfolioItemsBefore.date)
|
||||
)
|
||||
) {
|
||||
// Trade was not before the date of portfolioItemsBefore, then override it with average price
|
||||
// (e.g. on same day)
|
||||
before = portfolioItemsNow.positions[symbol].averagePrice;
|
||||
}
|
||||
|
||||
if (isToday(parseISO(portfolioItemsNow.positions[symbol].firstBuyDate))) {
|
||||
now = portfolioItemsNow.positions[symbol].averagePrice;
|
||||
}
|
||||
|
||||
details[symbol] = {
|
||||
...data[symbol],
|
||||
accounts,
|
||||
symbol,
|
||||
allocationCurrent:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol].quantity * now,
|
||||
data[symbol]?.currency,
|
||||
this.user.Settings.currency
|
||||
) / value,
|
||||
allocationInvestment:
|
||||
portfolioItem.positions[symbol].investment / investment,
|
||||
countries: countriesOfSymbol,
|
||||
grossPerformance: roundTo(
|
||||
portfolioItemsNow.positions[symbol].quantity * (now - before),
|
||||
2
|
||||
),
|
||||
grossPerformancePercent: roundTo((now - before) / before, 4),
|
||||
investment: portfolioItem.positions[symbol].investment,
|
||||
quantity: portfolioItem.positions[symbol].quantity,
|
||||
transactionCount: portfolioItem.positions[symbol].transactionCount,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol].quantity * now,
|
||||
data[symbol]?.currency,
|
||||
this.user.Settings.currency
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
public getFees(aDate = new Date(0)) {
|
||||
return this.orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date
|
||||
return isBefore(aDate, new Date(order.getDate()));
|
||||
})
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.getFee(),
|
||||
order.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
|
||||
public getInvestment(aDate: Date): number {
|
||||
return this.get(aDate)[0]?.investment || 0;
|
||||
}
|
||||
|
||||
public getMinDate() {
|
||||
if (this.orders.length > 0) {
|
||||
return new Date(this.orders[0].getDate());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getPerformance(
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<PortfolioPerformance> {
|
||||
const dateRangeDate = this.convertDateRangeToDate(
|
||||
aDateRange,
|
||||
this.getMinDate()
|
||||
);
|
||||
|
||||
const currentInvestment = this.getInvestment(new Date());
|
||||
const currentValue = await this.getValue();
|
||||
|
||||
let originalInvestment = currentInvestment;
|
||||
let originalValue = this.getCommittedFunds();
|
||||
|
||||
if (dateRangeDate) {
|
||||
originalInvestment = this.getInvestment(dateRangeDate);
|
||||
originalValue = (await this.getValue(dateRangeDate)) || originalValue;
|
||||
}
|
||||
|
||||
const fees = this.getFees(dateRangeDate);
|
||||
|
||||
const currentGrossPerformance =
|
||||
currentValue - currentInvestment - (originalValue - originalInvestment);
|
||||
|
||||
// https://www.skillsyouneed.com/num/percent-change.html
|
||||
const currentGrossPerformancePercent =
|
||||
currentGrossPerformance / originalInvestment || 0;
|
||||
|
||||
const currentNetPerformance = currentGrossPerformance - fees;
|
||||
|
||||
// https://www.skillsyouneed.com/num/percent-change.html
|
||||
const currentNetPerformancePercent =
|
||||
currentNetPerformance / originalInvestment || 0;
|
||||
|
||||
return {
|
||||
currentGrossPerformance,
|
||||
currentGrossPerformancePercent,
|
||||
currentNetPerformance,
|
||||
currentNetPerformancePercent,
|
||||
currentValue
|
||||
};
|
||||
}
|
||||
|
||||
public getPositions(aDate: Date) {
|
||||
const [portfolioItem] = this.get(aDate);
|
||||
|
||||
if (portfolioItem) {
|
||||
return portfolioItem.positions;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public getPortfolioItems() {
|
||||
return this.portfolioItems;
|
||||
}
|
||||
|
||||
public async getReport(): Promise<PortfolioReport> {
|
||||
const details = await this.getDetails();
|
||||
|
||||
if (isEmpty(details)) {
|
||||
return {
|
||||
rules: {}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
this,
|
||||
[
|
||||
new AccountClusterRiskInitialInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
|
||||
],
|
||||
{ baseCurrency: this.user.Settings.currency }
|
||||
),
|
||||
currencyClusterRisk: await this.rulesService.evaluate(
|
||||
this,
|
||||
[
|
||||
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new CurrencyClusterRiskInitialInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService
|
||||
)
|
||||
],
|
||||
{ baseCurrency: this.user.Settings.currency }
|
||||
),
|
||||
fees: await this.rulesService.evaluate(
|
||||
this,
|
||||
[new FeeRatioInitialInvestment(this.exchangeRateDataService)],
|
||||
{ baseCurrency: this.user.Settings.currency }
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public getSymbols(aDate?: Date) {
|
||||
let symbols: string[] = [];
|
||||
|
||||
if (aDate) {
|
||||
const positions = this.getPositions(aDate);
|
||||
|
||||
for (const symbol in positions) {
|
||||
if (positions[symbol].quantity > 0) {
|
||||
symbols.push(symbol);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
symbols = this.orders.map((order) => {
|
||||
return order.getSymbol();
|
||||
});
|
||||
}
|
||||
|
||||
// unique values
|
||||
return Array.from(new Set(symbols));
|
||||
}
|
||||
|
||||
public getTotalBuy() {
|
||||
return this.orders
|
||||
.filter((order) => order.getType() === 'BUY')
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.getTotal(),
|
||||
order.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
|
||||
public getTotalSell() {
|
||||
return this.orders
|
||||
.filter((order) => order.getType() === 'SELL')
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.getTotal(),
|
||||
order.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
|
||||
public getOrders(aSymbol?: string) {
|
||||
if (aSymbol) {
|
||||
return this.orders.filter((order) => {
|
||||
return order.getSymbol() === aSymbol;
|
||||
});
|
||||
}
|
||||
|
||||
return this.orders;
|
||||
}
|
||||
|
||||
public getValue(aDate = getToday()) {
|
||||
const positions = this.getPositions(aDate);
|
||||
let value = 0;
|
||||
|
||||
const [portfolioItem] = this.get(aDate);
|
||||
|
||||
for (const symbol in positions) {
|
||||
if (portfolioItem.positions[symbol]?.quantity > 0) {
|
||||
if (
|
||||
isBefore(
|
||||
aDate,
|
||||
parseISO(portfolioItem.positions[symbol]?.firstBuyDate)
|
||||
) ||
|
||||
portfolioItem.positions[symbol]?.marketPrice === 0
|
||||
) {
|
||||
value += this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol]?.quantity *
|
||||
portfolioItem.positions[symbol]?.averagePrice,
|
||||
portfolioItem.positions[symbol]?.currency,
|
||||
this.user.Settings.currency
|
||||
);
|
||||
} else {
|
||||
value += this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol]?.quantity *
|
||||
portfolioItem.positions[symbol]?.marketPrice,
|
||||
portfolioItem.positions[symbol]?.currency,
|
||||
this.user.Settings.currency
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
public async setOrders(aOrders: OrderWithAccount[]) {
|
||||
this.orders = [];
|
||||
|
||||
// Map data
|
||||
aOrders.forEach((order) => {
|
||||
this.orders.push(
|
||||
new Order({
|
||||
account: order.Account,
|
||||
currency: order.currency,
|
||||
date: order.date.toISOString(),
|
||||
fee: order.fee,
|
||||
quantity: order.quantity,
|
||||
symbol: order.symbol,
|
||||
symbolProfile: order.SymbolProfile,
|
||||
type: <OrderType>order.type,
|
||||
unitPrice: order.unitPrice
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await this.update();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public setUser(aUser: UserWithSettings) {
|
||||
this.user = aUser;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Refactor
|
||||
*/
|
||||
private async update() {
|
||||
this.portfolioItems = [];
|
||||
|
||||
let currentDate = this.getMinDate();
|
||||
|
||||
if (!currentDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set current date to first of month
|
||||
currentDate = setDate(currentDate, 1);
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistorical(
|
||||
this.getSymbols(),
|
||||
'month',
|
||||
currentDate,
|
||||
new Date()
|
||||
);
|
||||
|
||||
while (isBefore(currentDate, Date.now())) {
|
||||
const positions: { [symbol: string]: Position } = {};
|
||||
this.getSymbols().forEach((symbol) => {
|
||||
positions[symbol] = {
|
||||
averagePrice: 0,
|
||||
currency: undefined,
|
||||
firstBuyDate: null,
|
||||
investment: 0,
|
||||
investmentInOriginalCurrency: 0,
|
||||
marketPrice:
|
||||
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
|
||||
?.marketPrice || 0,
|
||||
quantity: 0,
|
||||
transactionCount: 0
|
||||
};
|
||||
});
|
||||
|
||||
if (!isYesterday(currentDate) && !isToday(currentDate)) {
|
||||
// Add to portfolio (ignore yesterday and today because they are added later)
|
||||
this.portfolioItems.push(
|
||||
cloneDeep({
|
||||
date: currentDate.toISOString(),
|
||||
grossPerformancePercent: 0,
|
||||
investment: 0,
|
||||
positions: positions,
|
||||
value: 0
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const year = getYear(currentDate);
|
||||
const month = getMonth(currentDate);
|
||||
const day = getDate(currentDate);
|
||||
|
||||
// Count month one up for iteration
|
||||
currentDate = new Date(Date.UTC(year, month + 1, day, 0));
|
||||
}
|
||||
|
||||
const yesterday = getYesterday();
|
||||
|
||||
let positions: { [symbol: string]: Position } = {};
|
||||
|
||||
if (isAfter(yesterday, this.getMinDate())) {
|
||||
// Add yesterday
|
||||
this.getSymbols().forEach((symbol) => {
|
||||
positions[symbol] = {
|
||||
averagePrice: 0,
|
||||
currency: undefined,
|
||||
firstBuyDate: null,
|
||||
investment: 0,
|
||||
investmentInOriginalCurrency: 0,
|
||||
marketPrice:
|
||||
historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')]
|
||||
?.marketPrice || 0,
|
||||
quantity: 0,
|
||||
transactionCount: 0
|
||||
};
|
||||
});
|
||||
|
||||
this.portfolioItems.push(
|
||||
cloneDeep({
|
||||
date: yesterday.toISOString(),
|
||||
grossPerformancePercent: 0,
|
||||
investment: 0,
|
||||
positions: positions,
|
||||
value: 0
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.updatePortfolioItems();
|
||||
}
|
||||
|
||||
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
|
||||
let currentDate = new Date();
|
||||
|
||||
const normalizedMinDate =
|
||||
getDate(aMinDate) === 1
|
||||
? aMinDate
|
||||
: add(setDate(aMinDate, 1), { months: 1 });
|
||||
|
||||
const year = getYear(currentDate);
|
||||
const month = getMonth(currentDate);
|
||||
const day = getDate(currentDate);
|
||||
|
||||
currentDate = new Date(Date.UTC(year, month, day, 0));
|
||||
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
return sub(currentDate, {
|
||||
days: 1
|
||||
});
|
||||
case 'ytd':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = setMonth(currentDate, 0);
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
case '1y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 1
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
case '5y':
|
||||
currentDate = setDate(currentDate, 1);
|
||||
currentDate = sub(currentDate, {
|
||||
years: 5
|
||||
});
|
||||
return isAfter(currentDate, normalizedMinDate)
|
||||
? currentDate
|
||||
: undefined;
|
||||
default:
|
||||
// Gets handled as all data
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private updatePortfolioItems() {
|
||||
// console.time('update-portfolio-items');
|
||||
|
||||
let currentDate = new Date();
|
||||
|
||||
const year = getYear(currentDate);
|
||||
const month = getMonth(currentDate);
|
||||
const day = getDate(currentDate);
|
||||
|
||||
currentDate = new Date(Date.UTC(year, month, day, 0));
|
||||
|
||||
if (this.portfolioItems?.length === 1) {
|
||||
// At least one portfolio items is needed, keep it but change the date to today.
|
||||
// This happens if there are only orders from today
|
||||
this.portfolioItems[0].date = currentDate.toISOString();
|
||||
} else {
|
||||
// Only keep entries which are not before first buy date
|
||||
this.portfolioItems = this.portfolioItems.filter((portfolioItem) => {
|
||||
return (
|
||||
isSameDay(parseISO(portfolioItem.date), this.getMinDate()) ||
|
||||
isAfter(parseISO(portfolioItem.date), this.getMinDate())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.orders.forEach((order) => {
|
||||
let index = this.portfolioItems.findIndex((item) => {
|
||||
const dateOfOrder = setDate(parseISO(order.getDate()), 1);
|
||||
return isSameDay(parseISO(item.date), dateOfOrder);
|
||||
});
|
||||
|
||||
if (index === -1) {
|
||||
// if not found, we only have one order, which means we do not loop below
|
||||
index = 0;
|
||||
}
|
||||
|
||||
for (let i = index; i < this.portfolioItems.length; i++) {
|
||||
// Set currency
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].currency = order.getCurrency();
|
||||
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].transactionCount += 1;
|
||||
|
||||
if (order.getType() === 'BUY') {
|
||||
if (
|
||||
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
|
||||
) {
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].firstBuyDate = resetHours(
|
||||
parseISO(order.getDate())
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].quantity += order.getQuantity();
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].investment += this.exchangeRateDataService.toCurrency(
|
||||
order.getTotal(),
|
||||
order.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].investmentInOriginalCurrency += order.getTotal();
|
||||
|
||||
this.portfolioItems[
|
||||
i
|
||||
].investment += this.exchangeRateDataService.toCurrency(
|
||||
order.getTotal(),
|
||||
order.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
} else if (order.getType() === 'SELL') {
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].quantity -= order.getQuantity();
|
||||
|
||||
if (
|
||||
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
|
||||
) {
|
||||
this.portfolioItems[i].positions[order.getSymbol()].investment = 0;
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].investmentInOriginalCurrency = 0;
|
||||
} else {
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].investment -= this.exchangeRateDataService.toCurrency(
|
||||
order.getTotal(),
|
||||
order.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
this.portfolioItems[i].positions[
|
||||
order.getSymbol()
|
||||
].investmentInOriginalCurrency -= order.getTotal();
|
||||
}
|
||||
|
||||
this.portfolioItems[
|
||||
i
|
||||
].investment -= this.exchangeRateDataService.toCurrency(
|
||||
order.getTotal(),
|
||||
order.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
}
|
||||
|
||||
this.portfolioItems[i].positions[order.getSymbol()].averagePrice =
|
||||
this.portfolioItems[i].positions[order.getSymbol()]
|
||||
.investmentInOriginalCurrency /
|
||||
this.portfolioItems[i].positions[order.getSymbol()].quantity;
|
||||
|
||||
const currentValue = this.getValue(
|
||||
parseISO(this.portfolioItems[i].date)
|
||||
);
|
||||
|
||||
this.portfolioItems[i].grossPerformancePercent =
|
||||
currentValue / this.portfolioItems[i].investment - 1 || 0;
|
||||
this.portfolioItems[i].value = currentValue;
|
||||
}
|
||||
});
|
||||
|
||||
// console.timeEnd('update-portfolio-items');
|
||||
}
|
||||
}
|
@ -1,16 +1,18 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { groupBy } from '@ghostfolio/common/helper';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||
import { RuleInterface } from './interfaces/rule.interface';
|
||||
|
||||
export abstract class Rule implements RuleInterface {
|
||||
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
|
||||
private name: string;
|
||||
|
||||
public constructor(
|
||||
public exchangeRateDataService: ExchangeRateDataService,
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
{
|
||||
name
|
||||
}: {
|
||||
@ -20,44 +22,38 @@ export abstract class Rule implements RuleInterface {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public abstract evaluate(
|
||||
aPortfolioPositionMap: {
|
||||
[symbol: string]: PortfolioPosition;
|
||||
},
|
||||
aFees: number,
|
||||
aRuleSettingsMap?: {
|
||||
[key: string]: any;
|
||||
}
|
||||
): EvaluationResult;
|
||||
|
||||
public getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public groupPositionsByAttribute(
|
||||
aPositions: { [symbol: string]: PortfolioPosition },
|
||||
aAttribute: keyof PortfolioPosition,
|
||||
aBaseCurrency: Currency
|
||||
public groupCurrentPositionsByAttribute(
|
||||
positions: TimelinePosition[],
|
||||
attribute: keyof TimelinePosition,
|
||||
baseCurrency: Currency
|
||||
) {
|
||||
return Array.from(
|
||||
groupBy(aAttribute, Object.values(aPositions)).entries()
|
||||
).map(([attributeValue, objs]) => ({
|
||||
groupKey: attributeValue,
|
||||
investment: objs.reduce(
|
||||
(previousValue, currentValue) =>
|
||||
previousValue + currentValue.investment,
|
||||
0
|
||||
),
|
||||
value: objs.reduce(
|
||||
(previousValue, currentValue) =>
|
||||
previousValue +
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
currentValue.quantity * currentValue.marketPrice,
|
||||
currentValue.currency,
|
||||
aBaseCurrency
|
||||
),
|
||||
0
|
||||
)
|
||||
}));
|
||||
return Array.from(groupBy(attribute, positions).entries()).map(
|
||||
([attributeValue, objs]) => ({
|
||||
groupKey: attributeValue,
|
||||
investment: objs.reduce(
|
||||
(previousValue, currentValue) =>
|
||||
previousValue + currentValue.investment.toNumber(),
|
||||
0
|
||||
),
|
||||
value: objs.reduce(
|
||||
(previousValue, currentValue) =>
|
||||
previousValue +
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
currentValue.quantity.mul(currentValue.marketPrice).toNumber(),
|
||||
currentValue.currency,
|
||||
baseCurrency
|
||||
),
|
||||
0
|
||||
)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public abstract evaluate(aRuleSettings: T): EvaluationResult;
|
||||
|
||||
public abstract getSettings(aUserSettings: UserSettings): T;
|
||||
}
|
||||
|
@ -1,43 +1,35 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class AccountClusterRiskCurrentInvestment extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment'
|
||||
});
|
||||
}
|
||||
|
||||
public evaluate(
|
||||
aPositions: { [symbol: string]: PortfolioPosition },
|
||||
aFees: number,
|
||||
aRuleSettingsMap?: {
|
||||
[key: string]: any;
|
||||
}
|
||||
) {
|
||||
const ruleSettings =
|
||||
aRuleSettingsMap[AccountClusterRiskCurrentInvestment.name];
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const accounts: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||
investment: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
Object.values(aPositions).forEach((position) => {
|
||||
for (const [account, { current }] of Object.entries(position.accounts)) {
|
||||
if (accounts[account]?.investment) {
|
||||
accounts[account].investment += current;
|
||||
} else {
|
||||
accounts[account] = {
|
||||
investment: current,
|
||||
name: account
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const account of Object.keys(this.accounts)) {
|
||||
accounts[account] = {
|
||||
name: account,
|
||||
investment: this.accounts[account].current
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
@ -78,4 +70,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule {
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
public getSettings(aUserSettings: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
@ -1,43 +1,35 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class AccountClusterRiskInitialInvestment extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment'
|
||||
});
|
||||
}
|
||||
|
||||
public evaluate(
|
||||
aPositions: { [symbol: string]: PortfolioPosition },
|
||||
aFees: number,
|
||||
aRuleSettingsMap?: {
|
||||
[key: string]: any;
|
||||
}
|
||||
) {
|
||||
const ruleSettings =
|
||||
aRuleSettingsMap[AccountClusterRiskInitialInvestment.name];
|
||||
|
||||
public evaluate(ruleSettings?: Settings) {
|
||||
const platforms: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||
investment: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
Object.values(aPositions).forEach((position) => {
|
||||
for (const [account, { original }] of Object.entries(position.accounts)) {
|
||||
if (platforms[account]?.investment) {
|
||||
platforms[account].investment += original;
|
||||
} else {
|
||||
platforms[account] = {
|
||||
investment: original,
|
||||
name: account
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const account of Object.keys(this.accounts)) {
|
||||
platforms[account] = {
|
||||
name: account,
|
||||
investment: this.accounts[account].original
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
@ -78,4 +70,18 @@ export class AccountClusterRiskInitialInvestment extends Rule {
|
||||
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,25 +1,23 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class AccountClusterRiskSingleAccount extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Single Account'
|
||||
});
|
||||
}
|
||||
|
||||
public evaluate(positions: { [symbol: string]: PortfolioPosition }) {
|
||||
const accounts: string[] = [];
|
||||
|
||||
Object.values(positions).forEach((position) => {
|
||||
for (const [account] of Object.entries(position.accounts)) {
|
||||
if (!accounts.includes(account)) {
|
||||
accounts.push(account);
|
||||
}
|
||||
}
|
||||
});
|
||||
public evaluate() {
|
||||
const accounts: string[] = Object.keys(this.accounts);
|
||||
|
||||
if (accounts.length === 1) {
|
||||
return {
|
||||
@ -33,4 +31,10 @@ export class AccountClusterRiskSingleAccount extends Rule {
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
public getSettings(aUserSettings: UserSettings): RuleSettings {
|
||||
return {
|
||||
isActive: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,27 +1,25 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment: Base Currency'
|
||||
});
|
||||
}
|
||||
|
||||
public evaluate(
|
||||
aPositions: { [symbol: string]: PortfolioPosition },
|
||||
aFees: number,
|
||||
aRuleSettingsMap?: {
|
||||
[key: string]: any;
|
||||
}
|
||||
) {
|
||||
const ruleSettings =
|
||||
aRuleSettingsMap[CurrencyClusterRiskBaseCurrencyCurrentInvestment.name];
|
||||
|
||||
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
|
||||
aPositions,
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
'currency',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
@ -61,4 +59,15 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule {
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
public getSettings(aUserSettings: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
}
|
||||
|
@ -1,27 +1,24 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment: Base Currency'
|
||||
});
|
||||
}
|
||||
|
||||
public evaluate(
|
||||
aPositions: { [symbol: string]: PortfolioPosition },
|
||||
aFees: number,
|
||||
aRuleSettingsMap?: {
|
||||
[key: string]: any;
|
||||
}
|
||||
) {
|
||||
const ruleSettings =
|
||||
aRuleSettingsMap[CurrencyClusterRiskBaseCurrencyInitialInvestment.name];
|
||||
|
||||
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
|
||||
aPositions,
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
'currency',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
@ -62,4 +59,15 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule {
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
public getSettings(aUserSettings: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
}
|
||||
|
@ -1,27 +1,24 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class CurrencyClusterRiskCurrentInvestment extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
public exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment'
|
||||
});
|
||||
}
|
||||
|
||||
public evaluate(
|
||||
aPositions: { [symbol: string]: PortfolioPosition },
|
||||
aFees: number,
|
||||
aRuleSettingsMap?: {
|
||||
[key: string]: any;
|
||||
}
|
||||
) {
|
||||
const ruleSettings =
|
||||
aRuleSettingsMap[CurrencyClusterRiskCurrentInvestment.name];
|
||||
|
||||
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
|
||||
aPositions,
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
'currency',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
@ -61,4 +58,17 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule {
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
public getSettings(aUserSettings: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
threshold: number;
|
||||
}
|
||||
|
@ -1,27 +1,24 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class CurrencyClusterRiskInitialInvestment extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private currentPositions: CurrentPositions
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment'
|
||||
});
|
||||
}
|
||||
|
||||
public evaluate(
|
||||
aPositions: { [symbol: string]: PortfolioPosition },
|
||||
aFees: number,
|
||||
aRuleSettingsMap?: {
|
||||
[key: string]: any;
|
||||
}
|
||||
) {
|
||||
const ruleSettings =
|
||||
aRuleSettingsMap[CurrencyClusterRiskInitialInvestment.name];
|
||||
|
||||
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
|
||||
aPositions,
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
|
||||
this.currentPositions.positions,
|
||||
'currency',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
@ -61,4 +58,17 @@ export class CurrencyClusterRiskInitialInvestment extends Rule {
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
public getSettings(aUserSettings: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
threshold: number;
|
||||
}
|
||||
|
@ -1,38 +1,23 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class FeeRatioInitialInvestment extends Rule {
|
||||
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
|
||||
export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private totalInvestment: number,
|
||||
private fees: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment'
|
||||
});
|
||||
}
|
||||
|
||||
public evaluate(
|
||||
aPositions: { [symbol: string]: PortfolioPosition },
|
||||
aFees: number,
|
||||
aRuleSettingsMap?: {
|
||||
[key: string]: any;
|
||||
}
|
||||
) {
|
||||
const ruleSettings = aRuleSettingsMap[FeeRatioInitialInvestment.name];
|
||||
|
||||
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
|
||||
aPositions,
|
||||
'currency',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
|
||||
let totalInvestment = 0;
|
||||
|
||||
positionsGroupedByCurrency.forEach((groupItem) => {
|
||||
// Calculate total investment
|
||||
totalInvestment += groupItem.investment;
|
||||
});
|
||||
|
||||
const feeRatio = aFees / totalInvestment;
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const feeRatio = this.fees / this.totalInvestment;
|
||||
|
||||
if (feeRatio > ruleSettings.threshold) {
|
||||
return {
|
||||
@ -50,4 +35,17 @@ export class FeeRatioInitialInvestment extends Rule {
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
public getSettings(aUserSettings: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.01
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
threshold: number;
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { bool, cleanEnv, json, num, port, str } from 'envalid';
|
||||
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { Environment } from './interfaces/environment.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -14,9 +15,12 @@ export class ConfigurationService {
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }),
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
||||
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
|
||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||
@ -26,7 +30,10 @@ export class ConfigurationService {
|
||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
ROOT_URL: str({ default: 'http://localhost:4200' })
|
||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||
STRIPE_SECRET_KEY: str({ default: '' }),
|
||||
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@ export class CronService {
|
||||
|
||||
@Cron(CronExpression.EVERY_12_HOURS)
|
||||
public async runEveryTwelveHours() {
|
||||
await this.dataGatheringService.gatherProfileData();
|
||||
await this.exchangeRateDataService.loadCurrencies();
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,16 @@
|
||||
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getUtc,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
isRakutenRapidApiSymbol,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
endOfToday,
|
||||
format,
|
||||
getDate,
|
||||
getMonth,
|
||||
@ -36,7 +39,7 @@ export class DataGatheringService {
|
||||
|
||||
if (isDataGatheringNeeded) {
|
||||
console.log('7d data gathering has been started.');
|
||||
console.time('data-gathering');
|
||||
console.time('7d-data-gathering');
|
||||
|
||||
await this.prisma.property.create({
|
||||
data: {
|
||||
@ -69,7 +72,7 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
console.log('7d data gathering has been completed.');
|
||||
console.timeEnd('data-gathering');
|
||||
console.timeEnd('7d-data-gathering');
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,7 +83,7 @@ export class DataGatheringService {
|
||||
|
||||
if (!isDataGatheringLocked) {
|
||||
console.log('Max data gathering has been started.');
|
||||
console.time('data-gathering');
|
||||
console.time('max-data-gathering');
|
||||
|
||||
await this.prisma.property.create({
|
||||
data: {
|
||||
@ -113,10 +116,56 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
console.log('Max data gathering has been completed.');
|
||||
console.timeEnd('data-gathering');
|
||||
console.timeEnd('max-data-gathering');
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherProfileData(aSymbols?: string[]) {
|
||||
console.log('Profile data gathering has been started.');
|
||||
console.time('profile-data-gathering');
|
||||
|
||||
let symbols = aSymbols;
|
||||
|
||||
if (!symbols) {
|
||||
const dataGatheringItems = await this.getSymbolsProfileData();
|
||||
symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
}
|
||||
|
||||
const currentData = await this.dataProviderService.get(symbols);
|
||||
|
||||
for (const [symbol, { currency, dataSource, name }] of Object.entries(
|
||||
currentData
|
||||
)) {
|
||||
try {
|
||||
await this.prisma.symbolProfile.upsert({
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
symbol
|
||||
},
|
||||
update: {
|
||||
currency,
|
||||
name
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`${symbol}: ${error?.meta?.cause}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Profile data gathering has been completed.');
|
||||
console.timeEnd('profile-data-gathering');
|
||||
}
|
||||
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
let hasError = false;
|
||||
|
||||
@ -145,11 +194,11 @@ export class DataGatheringService {
|
||||
)
|
||||
) {
|
||||
if (
|
||||
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
|
||||
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
||||
?.marketPrice
|
||||
) {
|
||||
lastMarketPrice =
|
||||
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
|
||||
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
||||
?.marketPrice;
|
||||
}
|
||||
|
||||
@ -187,7 +236,8 @@ export class DataGatheringService {
|
||||
public async getCustomSymbolsToGather(
|
||||
startDate?: Date
|
||||
): Promise<IDataGatheringItem[]> {
|
||||
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
|
||||
const scraperConfigurations =
|
||||
await this.ghostfolioScraperApi.getScraperConfigurations();
|
||||
|
||||
return scraperConfigurations.map((scraperConfiguration) => {
|
||||
return {
|
||||
@ -224,7 +274,12 @@ export class DataGatheringService {
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true }
|
||||
select: { dataSource: true, symbol: true },
|
||||
where: {
|
||||
date: {
|
||||
lt: endOfToday() // no draft
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
|
||||
@ -280,7 +335,12 @@ export class DataGatheringService {
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { dataSource: true, date: true, symbol: true }
|
||||
select: { dataSource: true, date: true, symbol: true },
|
||||
where: {
|
||||
date: {
|
||||
lt: endOfToday() // no draft
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
@ -291,6 +351,25 @@ export class DataGatheringService {
|
||||
];
|
||||
}
|
||||
|
||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true }
|
||||
});
|
||||
|
||||
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
|
||||
(distinctOrder) => {
|
||||
return (
|
||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async isDataGatheringNeeded() {
|
||||
const lastDataGathering = await this.prisma.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
isRakutenRapidApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
@ -29,7 +30,7 @@ export class DataProviderService {
|
||||
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
||||
private readonly yahooFinanceService: YahooFinanceService
|
||||
) {
|
||||
this.rakutenRapidApiService.setPrisma(this.prisma);
|
||||
this.rakutenRapidApiService?.setPrisma(this.prisma);
|
||||
}
|
||||
|
||||
public async get(
|
||||
@ -46,7 +47,10 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
const yahooFinanceSymbols = aSymbols.filter((symbol) => {
|
||||
return !isGhostfolioScraperApiSymbol(symbol);
|
||||
return (
|
||||
!isGhostfolioScraperApiSymbol(symbol) &&
|
||||
!isRakutenRapidApiSymbol(symbol)
|
||||
);
|
||||
});
|
||||
|
||||
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
|
||||
@ -57,13 +61,24 @@ export class DataProviderService {
|
||||
|
||||
for (const symbol of ghostfolioScraperApiSymbols) {
|
||||
if (symbol) {
|
||||
const ghostfolioScraperApiResult = await this.ghostfolioScraperApiService.get(
|
||||
[symbol]
|
||||
);
|
||||
const ghostfolioScraperApiResult =
|
||||
await this.ghostfolioScraperApiService.get([symbol]);
|
||||
response[symbol] = ghostfolioScraperApiResult[symbol];
|
||||
}
|
||||
}
|
||||
|
||||
const rakutenRapidApiSymbols = aSymbols.filter((symbol) => {
|
||||
return isRakutenRapidApiSymbol(symbol);
|
||||
});
|
||||
|
||||
for (const symbol of rakutenRapidApiSymbols) {
|
||||
if (symbol) {
|
||||
const rakutenRapidApiResult =
|
||||
await this.ghostfolioScraperApiService.get([symbol]);
|
||||
response[symbol] = rakutenRapidApiResult[symbol];
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@ -86,9 +101,9 @@ export class DataProviderService {
|
||||
|
||||
const rangeQuery =
|
||||
from && to
|
||||
? `AND date >= '${format(from, 'yyyy-MM-dd')}' AND date <= '${format(
|
||||
? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format(
|
||||
to,
|
||||
'yyyy-MM-dd'
|
||||
DATE_FORMAT
|
||||
)}'`
|
||||
: '';
|
||||
|
||||
@ -106,7 +121,7 @@ export class DataProviderService {
|
||||
|
||||
r[symbol] = {
|
||||
...(r[symbol] || {}),
|
||||
[format(new Date(date), 'yyyy-MM-dd')]: { marketPrice }
|
||||
[format(new Date(date), DATE_FORMAT)]: { marketPrice }
|
||||
};
|
||||
|
||||
return r;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -66,8 +67,8 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
historicalData['Time Series (Digital Currency Daily)']
|
||||
).sort()) {
|
||||
if (
|
||||
isAfter(from, parse(key, 'yyyy-MM-dd', new Date())) &&
|
||||
isBefore(to, parse(key, 'yyyy-MM-dd', new Date()))
|
||||
isAfter(from, parse(key, DATE_FORMAT, new Date())) &&
|
||||
isBefore(to, parse(key, DATE_FORMAT, new Date()))
|
||||
) {
|
||||
response[symbol][key] = {
|
||||
marketPrice: parseFloat(timeSeries['4a. close (USD)'])
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getYesterday,
|
||||
isGhostfolioScraperApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
@ -95,7 +96,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
[format(getYesterday(), 'yyyy-MM-dd')]: {
|
||||
[format(getYesterday(), DATE_FORMAT)]: {
|
||||
marketPrice: value
|
||||
}
|
||||
}
|
||||
@ -109,14 +110,13 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
|
||||
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
|
||||
try {
|
||||
const {
|
||||
value: scraperConfigString
|
||||
} = await this.prisma.property.findFirst({
|
||||
select: {
|
||||
value: true
|
||||
},
|
||||
where: { key: 'SCRAPER_CONFIG' }
|
||||
});
|
||||
const { value: scraperConfigString } =
|
||||
await this.prisma.property.findFirst({
|
||||
select: {
|
||||
value: true
|
||||
},
|
||||
where: { key: 'SCRAPER_CONFIG' }
|
||||
});
|
||||
|
||||
return JSON.parse(scraperConfigString);
|
||||
} catch {}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getToday,
|
||||
getYesterday,
|
||||
isRakutenRapidApiSymbol
|
||||
@ -117,7 +118,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
|
||||
return {
|
||||
'GF.FEAR_AND_GREED_INDEX': {
|
||||
[format(getYesterday(), 'yyyy-MM-dd')]: {
|
||||
[format(getYesterday(), DATE_FORMAT)]: {
|
||||
marketPrice: fgi.previousClose.value
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,11 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
isCrypto,
|
||||
isCurrency,
|
||||
parseCurrency
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -12,9 +17,7 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
Industry,
|
||||
MarketState,
|
||||
Sector,
|
||||
Type
|
||||
} from '../../interfaces/interfaces';
|
||||
import {
|
||||
@ -70,16 +73,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
type: this.parseType(this.getType(symbol, value))
|
||||
};
|
||||
|
||||
const industry = this.parseIndustry(value.summaryProfile?.industry);
|
||||
if (industry) {
|
||||
response[symbol].industry = industry;
|
||||
}
|
||||
|
||||
const sector = this.parseSector(value.summaryProfile?.sector);
|
||||
if (sector) {
|
||||
response[symbol].sector = sector;
|
||||
}
|
||||
|
||||
const url = value.summaryProfile?.website;
|
||||
if (url) {
|
||||
response[symbol].url = url;
|
||||
@ -115,8 +108,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
[symbol: string]: IYahooFinanceHistoricalResponse[];
|
||||
} = await yahooFinance.historical({
|
||||
symbols: yahooSymbols,
|
||||
from: format(from, 'yyyy-MM-dd'),
|
||||
to: format(to, 'yyyy-MM-dd')
|
||||
from: format(from, DATE_FORMAT),
|
||||
to: format(to, DATE_FORMAT)
|
||||
});
|
||||
|
||||
const response: {
|
||||
@ -129,7 +122,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
response[symbol] = {};
|
||||
|
||||
timeSeries.forEach((timeSerie) => {
|
||||
response[symbol][format(timeSerie.date, 'yyyy-MM-dd')] = {
|
||||
response[symbol][format(timeSerie.date, DATE_FORMAT)] = {
|
||||
marketPrice: timeSerie.close,
|
||||
performance: timeSerie.open - timeSerie.close
|
||||
};
|
||||
@ -228,55 +221,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return aString;
|
||||
}
|
||||
|
||||
private parseIndustry(aString: string): Industry {
|
||||
if (aString === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (aString?.toLowerCase() === 'auto manufacturers') {
|
||||
return Industry.Automotive;
|
||||
} else if (aString?.toLowerCase() === 'biotechnology') {
|
||||
return Industry.Biotechnology;
|
||||
} else if (
|
||||
aString?.toLowerCase() === 'drug manufacturers—specialty & generic'
|
||||
) {
|
||||
return Industry.Pharmaceutical;
|
||||
} else if (
|
||||
aString?.toLowerCase() === 'internet content & information' ||
|
||||
aString?.toLowerCase() === 'internet retail'
|
||||
) {
|
||||
return Industry.Internet;
|
||||
} else if (aString?.toLowerCase() === 'packaged foods') {
|
||||
return Industry.Food;
|
||||
} else if (aString?.toLowerCase() === 'software—application') {
|
||||
return Industry.Software;
|
||||
}
|
||||
|
||||
return Industry.Unknown;
|
||||
}
|
||||
|
||||
private parseSector(aString: string): Sector {
|
||||
if (aString === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
aString?.toLowerCase() === 'consumer cyclical' ||
|
||||
aString?.toLowerCase() === 'consumer defensive'
|
||||
) {
|
||||
return Sector.Consumer;
|
||||
} else if (aString?.toLowerCase() === 'healthcare') {
|
||||
return Sector.Healthcare;
|
||||
} else if (
|
||||
aString?.toLowerCase() === 'communication services' ||
|
||||
aString?.toLowerCase() === 'technology'
|
||||
) {
|
||||
return Sector.Technology;
|
||||
}
|
||||
|
||||
return Sector.Unknown;
|
||||
}
|
||||
|
||||
private parseType(aString: string): Type {
|
||||
if (aString?.toLowerCase() === 'cryptocurrency') {
|
||||
return Type.Cryptocurrency;
|
||||
@ -291,6 +235,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
export const convertFromYahooSymbol = (aSymbol: string) => {
|
||||
let symbol = aSymbol.replace('-', '');
|
||||
const symbol = aSymbol.replace('-', '');
|
||||
return symbol.replace('=X', '');
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getYesterday } from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
@ -51,7 +51,7 @@ export class ExchangeRateDataService {
|
||||
|
||||
this.pairs.forEach((pair) => {
|
||||
const [currency1, currency2] = pair.match(/.{1,3}/g);
|
||||
const date = format(getYesterday(), 'yyyy-MM-dd');
|
||||
const date = format(getYesterday(), DATE_FORMAT);
|
||||
|
||||
this.currencies[pair] = resultExtended[pair]?.[date]?.marketPrice;
|
||||
|
||||
|
@ -5,9 +5,12 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
CACHE_TTL: number;
|
||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
||||
ENABLE_FEATURE_BLOG: boolean;
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||
ENABLE_FEATURE_IMPORT: boolean;
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||
ENABLE_FEATURE_STATISTICS: boolean;
|
||||
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_SECRET: string;
|
||||
@ -18,4 +21,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
REDIS_HOST: string;
|
||||
REDIS_PORT: number;
|
||||
ROOT_URL: string;
|
||||
STRIPE_PUBLIC_KEY: string;
|
||||
STRIPE_SECRET_KEY: string;
|
||||
WEB_AUTH_RP_ID: string;
|
||||
}
|
||||
|
@ -3,30 +3,14 @@ import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
|
||||
|
||||
import { OrderType } from '../../models/order-type';
|
||||
|
||||
export const Industry = {
|
||||
Automotive: 'Automotive',
|
||||
Biotechnology: 'Biotechnology',
|
||||
Food: 'Food',
|
||||
Internet: 'Internet',
|
||||
Pharmaceutical: 'Pharmaceutical',
|
||||
Software: 'Software',
|
||||
Unknown: UNKNOWN_KEY
|
||||
};
|
||||
|
||||
export const MarketState = {
|
||||
closed: 'closed',
|
||||
delayed: 'delayed',
|
||||
open: 'open'
|
||||
};
|
||||
|
||||
export const Sector = {
|
||||
Consumer: 'Consumer',
|
||||
Healthcare: 'Healthcare',
|
||||
Technology: 'Technology',
|
||||
Unknown: UNKNOWN_KEY
|
||||
};
|
||||
|
||||
export const Type = {
|
||||
Cash: 'Cash',
|
||||
Cryptocurrency: 'Cryptocurrency',
|
||||
ETF: 'ETF',
|
||||
Stock: 'Stock',
|
||||
@ -55,13 +39,11 @@ export interface IDataProviderResponse {
|
||||
currency: Currency;
|
||||
dataSource: DataSource;
|
||||
exchange?: string;
|
||||
industry?: Industry;
|
||||
marketChange?: number;
|
||||
marketChangePercent?: number;
|
||||
marketPrice: number;
|
||||
marketState: MarketState;
|
||||
name: string;
|
||||
sector?: Sector;
|
||||
type?: Type;
|
||||
url?: string;
|
||||
}
|
||||
@ -72,10 +54,6 @@ export interface IDataGatheringItem {
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
export type Industry = typeof Industry[keyof typeof Industry];
|
||||
|
||||
export type MarketState = typeof MarketState[keyof typeof MarketState];
|
||||
|
||||
export type Sector = typeof Sector[keyof typeof Sector];
|
||||
|
||||
export type Type = typeof Type[keyof typeof Type];
|
||||
|
15
apps/api/src/services/interfaces/symbol-profile.interface.ts
Normal file
15
apps/api/src/services/interfaces/symbol-profile.interface.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
|
||||
export interface EnhancedSymbolProfile {
|
||||
createdAt: Date;
|
||||
currency: Currency | null;
|
||||
dataSource: DataSource;
|
||||
id: string;
|
||||
name: string | null;
|
||||
updatedAt: Date;
|
||||
symbol: string;
|
||||
countries: Country[];
|
||||
sectors: Sector[];
|
||||
}
|
@ -1,78 +1,24 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { Portfolio } from '../models/portfolio';
|
||||
import { Rule } from '../models/rule';
|
||||
import { AccountClusterRiskCurrentInvestment } from '../models/rules/account-cluster-risk/current-investment';
|
||||
import { AccountClusterRiskInitialInvestment } from '../models/rules/account-cluster-risk/initial-investment';
|
||||
import { AccountClusterRiskSingleAccount } from '../models/rules/account-cluster-risk/single-account';
|
||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '../models/rules/currency-cluster-risk/base-currency-current-investment';
|
||||
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '../models/rules/currency-cluster-risk/base-currency-initial-investment';
|
||||
import { CurrencyClusterRiskCurrentInvestment } from '../models/rules/currency-cluster-risk/current-investment';
|
||||
import { CurrencyClusterRiskInitialInvestment } from '../models/rules/currency-cluster-risk/initial-investment';
|
||||
import { FeeRatioInitialInvestment } from '../models/rules/fees/fee-ratio-initial-investment';
|
||||
|
||||
@Injectable()
|
||||
export class RulesService {
|
||||
public constructor() {}
|
||||
|
||||
public async evaluate(
|
||||
aPortfolio: Portfolio,
|
||||
aRules: Rule[],
|
||||
aUserSettings: { baseCurrency: string }
|
||||
public async evaluate<T extends RuleSettings>(
|
||||
aRules: Rule<T>[],
|
||||
aUserSettings: { baseCurrency: Currency }
|
||||
) {
|
||||
const defaultSettings = this.getDefaultRuleSettings(aUserSettings);
|
||||
const details = await aPortfolio.getDetails();
|
||||
|
||||
return aRules
|
||||
.filter((rule) => {
|
||||
return defaultSettings[rule.constructor.name]?.isActive;
|
||||
return rule.getSettings(aUserSettings)?.isActive;
|
||||
})
|
||||
.map((rule) => {
|
||||
const evaluationResult = rule.evaluate(
|
||||
details,
|
||||
aPortfolio.getFees(),
|
||||
defaultSettings
|
||||
);
|
||||
const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings));
|
||||
return { ...evaluationResult, name: rule.getName() };
|
||||
});
|
||||
}
|
||||
|
||||
private getDefaultRuleSettings(aUserSettings: { baseCurrency: string }) {
|
||||
return {
|
||||
[AccountClusterRiskCurrentInvestment.name]: {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
},
|
||||
[AccountClusterRiskInitialInvestment.name]: {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
},
|
||||
[AccountClusterRiskSingleAccount.name]: { isActive: true },
|
||||
[CurrencyClusterRiskBaseCurrencyInitialInvestment.name]: {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true
|
||||
},
|
||||
[CurrencyClusterRiskBaseCurrencyCurrentInvestment.name]: {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true
|
||||
},
|
||||
[CurrencyClusterRiskCurrentInvestment.name]: {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
},
|
||||
[CurrencyClusterRiskInitialInvestment.name]: {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
},
|
||||
[FeeRatioInitialInvestment.name]: {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.01
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
64
apps/api/src/services/symbol-profile.service.ts
Normal file
64
apps/api/src/services/symbol-profile.service.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, SymbolProfile } from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
|
||||
@Injectable()
|
||||
export class SymbolProfileService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
public async getSymbolProfiles(
|
||||
symbols: string[]
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prisma.symbolProfile
|
||||
.findMany({
|
||||
where: {
|
||||
symbol: {
|
||||
in: symbols
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
}
|
||||
|
||||
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] {
|
||||
return symbolProfiles.map((symbolProfile) => ({
|
||||
...symbolProfile,
|
||||
countries: this.getCountries(symbolProfile),
|
||||
sectors: this.getSectors(symbolProfile)
|
||||
}));
|
||||
}
|
||||
|
||||
private getCountries(symbolProfile: SymbolProfile): Country[] {
|
||||
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map(
|
||||
(country) => {
|
||||
const { code, weight } = country as Prisma.JsonObject;
|
||||
|
||||
return {
|
||||
code: code as string,
|
||||
continent:
|
||||
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||
name: countries[code as string]?.name ?? UNKNOWN_KEY,
|
||||
weight: weight as number
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private getSectors(symbolProfile: SymbolProfile): Sector[] {
|
||||
return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
|
||||
(sector) => {
|
||||
const { name, weight } = sector as Prisma.JsonObject;
|
||||
|
||||
return {
|
||||
name: (name as string) ?? UNKNOWN_KEY,
|
||||
weight: weight as number
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
@ -5,19 +5,14 @@ module.exports = {
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
astTransformers: {
|
||||
before: [
|
||||
'jest-preset-angular/build/InlineFilesTransformer',
|
||||
'jest-preset-angular/build/StripStylesTransformer'
|
||||
]
|
||||
}
|
||||
stringifyContentPathRegex: '\\.(html|svg)$'
|
||||
}
|
||||
},
|
||||
coverageDirectory: '../../coverage/apps/client',
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js',
|
||||
'jest-preset-angular/build/AngularSnapshotSerializer.js',
|
||||
'jest-preset-angular/build/HTMLCommentSerializer.js'
|
||||
]
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment'
|
||||
],
|
||||
transform: { '^.+\\.(ts|js|html)$': 'jest-preset-angular' }
|
||||
};
|
||||
|
@ -33,6 +33,20 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
||||
},
|
||||
{
|
||||
path: 'de/blog/2021/07/hallo-ghostfolio',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
|
||||
).then((m) => m.HalloGhostfolioPageModule)
|
||||
},
|
||||
{
|
||||
path: 'en/blog/2021/07/hello-ghostfolio',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||
).then((m) => m.HelloGhostfolioPageModule)
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () =>
|
||||
@ -92,6 +106,13 @@ const routes: Routes = [
|
||||
(m) => m.TransactionsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'webauthn',
|
||||
loadChildren: () =>
|
||||
import('./pages/webauthn/webauthn-page.module').then(
|
||||
(m) => m.WebauthnPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'zen',
|
||||
loadChildren: () =>
|
||||
@ -112,6 +133,7 @@ const routes: Routes = [
|
||||
routes,
|
||||
// Preload all lazy loaded modules with the attribute preload === true
|
||||
{
|
||||
anchorScrolling: 'enabled',
|
||||
preloadingStrategy: ModulePreloadService,
|
||||
// enableTracing: true // <-- debugging purposes only
|
||||
relativeLinkResolution: 'legacy'
|
||||
|
@ -10,16 +10,16 @@
|
||||
|
||||
<main role="main">
|
||||
<div *ngIf="canCreateAccount" class="container create-account-container">
|
||||
<div class="row mb-5">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<a [routerLink]="['/']">
|
||||
<mat-card
|
||||
class="create-account-box p-2 text-center"
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2 text-center">
|
||||
<a class="text-center" [routerLink]="['/']">
|
||||
<div
|
||||
class="create-account-box d-inline-block px-3 py-2"
|
||||
(click)="onCreateAccount()"
|
||||
>
|
||||
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
||||
<button mat-button color="primary" i18n>Create Account</button>
|
||||
</mat-card></a
|
||||
<span i18n>You are using the Live Demo.</span>
|
||||
<a class="ml-2" href="#" i18n>Create Account</a>
|
||||
</div></a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@ -28,10 +28,7 @@
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
<footer
|
||||
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
|
||||
class="footer d-flex justify-content-center position-absolute w-100"
|
||||
>
|
||||
<footer *ngIf="!user" class="footer d-flex justify-content-center w-100">
|
||||
<div class="container text-center">
|
||||
<div>
|
||||
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||
|
@ -1,21 +1,32 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
|
||||
main {
|
||||
padding: 5rem 0;
|
||||
min-height: 100vh;
|
||||
padding-top: 5rem;
|
||||
|
||||
.create-account-box {
|
||||
cursor: pointer;
|
||||
font-size: 90%;
|
||||
.create-account-container {
|
||||
height: 3.5rem;
|
||||
margin-top: -0.5rem;
|
||||
|
||||
.link {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
.create-account-box {
|
||||
background-color: rgba(0, 0, 0, $alpha-hover);
|
||||
border-radius: 2rem;
|
||||
cursor: pointer;
|
||||
font-size: 80%;
|
||||
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
bottom: 0;
|
||||
height: 5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
@ -6,7 +6,11 @@ import {
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
||||
import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
|
||||
import {
|
||||
primaryColorHex,
|
||||
secondaryColorHex,
|
||||
warnColorHex
|
||||
} from '@ghostfolio/common/config';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
||||
@ -52,10 +56,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.dataService.fetchInfo().subscribe((info) => {
|
||||
this.info = info;
|
||||
});
|
||||
|
||||
this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe(() => {
|
||||
@ -63,6 +63,8 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
|
||||
const urlSegments = urlSegmentGroup.segments;
|
||||
this.currentRoute = urlSegments[0].path;
|
||||
|
||||
this.info = this.dataService.fetchInfo();
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
@ -106,5 +108,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
||||
this.materialCssVarsService.setAccentColor(secondaryColorHex);
|
||||
this.materialCssVarsService.setWarnColor(warnColorHex);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { Platform } from '@angular/cdk/platform';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import {
|
||||
DateAdapter,
|
||||
MAT_DATE_FORMATS,
|
||||
@ -15,7 +13,9 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MaterialCssVarsModule } from 'angular-material-css-vars';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { CustomDateAdapter } from './adapter/custom-date-adapter';
|
||||
import { DateFormats } from './adapter/date-formats';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
@ -25,6 +25,10 @@ import { authInterceptorProviders } from './core/auth.interceptor';
|
||||
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
||||
import { LanguageService } from './core/language.service';
|
||||
|
||||
export function NgxStripeFactory(): string {
|
||||
return environment.stripePublicKey;
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
@ -34,8 +38,6 @@ import { LanguageService } from './core/language.service';
|
||||
GfHeaderModule,
|
||||
HttpClientModule,
|
||||
MarkdownModule.forRoot(),
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MaterialCssVarsModule.forRoot({
|
||||
darkThemeClass: 'is-dark-theme',
|
||||
isAutoContrast: true,
|
||||
@ -43,7 +45,8 @@ import { LanguageService } from './core/language.service';
|
||||
}),
|
||||
MatNativeDateModule,
|
||||
MatSnackBarModule,
|
||||
NgxSkeletonLoaderModule
|
||||
NgxSkeletonLoaderModule,
|
||||
NgxStripeModule.forRoot(environment.stripePublicKey)
|
||||
],
|
||||
providers: [
|
||||
authInterceptorProviders,
|
||||
@ -54,7 +57,11 @@ import { LanguageService } from './core/language.service';
|
||||
useClass: CustomDateAdapter,
|
||||
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
|
||||
},
|
||||
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
|
||||
{ provide: MAT_DATE_FORMATS, useValue: DateFormats },
|
||||
{
|
||||
provide: STRIPE_PUBLISHABLE_KEY,
|
||||
useFactory: NgxStripeFactory
|
||||
}
|
||||
],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
|
@ -3,6 +3,11 @@
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.name }}
|
||||
<span
|
||||
*ngIf="element.isDefault"
|
||||
class="d-lg-inline-block d-none text-muted"
|
||||
>(Default)</span
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -21,6 +26,27 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="transactions">
|
||||
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
|
||||
Transactions
|
||||
</th>
|
||||
<td *matCellDef="let element" class="text-right" mat-cell>
|
||||
{{ element.Order?.length }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="balance">
|
||||
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>Balance</th>
|
||||
<td *matCellDef="let element" class="text-right" mat-cell>
|
||||
<gf-value
|
||||
class="d-inline-block justify-content-end"
|
||||
[currency]="element.currency"
|
||||
[locale]="locale"
|
||||
[value]="element.balance"
|
||||
></gf-value>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
@ -48,13 +74,6 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="transactions">
|
||||
<th *matHeaderCellDef i18n mat-header-cell>Transactions</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
{{ element.Order?.length }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||
</table>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user