mirror of https://github.com/ghostfolio/ghostfolio
Thomas
4 years ago
commit
c616312233
371 changed files with 31010 additions and 0 deletions
@ -0,0 +1,8 @@ |
|||
.git/ |
|||
.vscode/ |
|||
node_modules/ |
|||
|
|||
.dockerignore |
|||
.editorconfig |
|||
.gitignore |
|||
Dockerfile |
@ -0,0 +1,13 @@ |
|||
# Editor configuration, see http://editorconfig.org |
|||
root = true |
|||
|
|||
[*] |
|||
charset = utf-8 |
|||
indent_style = space |
|||
indent_size = 2 |
|||
insert_final_newline = true |
|||
trim_trailing_whitespace = true |
|||
|
|||
[*.md] |
|||
max_line_length = off |
|||
trim_trailing_whitespace = false |
@ -0,0 +1,117 @@ |
|||
{ |
|||
"root": true, |
|||
"ignorePatterns": ["**/*"], |
|||
"plugins": ["@nrwl/nx"], |
|||
"overrides": [ |
|||
{ |
|||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"], |
|||
"rules": { |
|||
"@nrwl/nx/enforce-module-boundaries": [ |
|||
"error", |
|||
{ |
|||
"enforceBuildableLibDependency": true, |
|||
"allow": [], |
|||
"depConstraints": [ |
|||
{ |
|||
"sourceTag": "*", |
|||
"onlyDependOnLibsWithTags": ["*"] |
|||
} |
|||
] |
|||
} |
|||
] |
|||
} |
|||
}, |
|||
{ |
|||
"files": ["*.ts", "*.tsx"], |
|||
"extends": ["plugin:@nrwl/nx/typescript"], |
|||
"rules": {} |
|||
}, |
|||
{ |
|||
"files": ["*.js", "*.jsx"], |
|||
"extends": ["plugin:@nrwl/nx/javascript"], |
|||
"rules": {} |
|||
}, |
|||
{ |
|||
"files": ["*.ts"], |
|||
"plugins": ["eslint-plugin-import", "@typescript-eslint"], |
|||
"rules": { |
|||
"@typescript-eslint/consistent-type-definitions": "error", |
|||
"@typescript-eslint/dot-notation": "off", |
|||
"@typescript-eslint/explicit-member-accessibility": [ |
|||
"off", |
|||
{ |
|||
"accessibility": "explicit" |
|||
} |
|||
], |
|||
"@typescript-eslint/member-ordering": "error", |
|||
"@typescript-eslint/naming-convention": "error", |
|||
"@typescript-eslint/no-empty-function": "off", |
|||
"@typescript-eslint/no-empty-interface": "error", |
|||
"@typescript-eslint/no-inferrable-types": [ |
|||
"error", |
|||
{ |
|||
"ignoreParameters": true |
|||
} |
|||
], |
|||
"@typescript-eslint/no-misused-new": "error", |
|||
"@typescript-eslint/no-non-null-assertion": "error", |
|||
"@typescript-eslint/no-shadow": [ |
|||
"error", |
|||
{ |
|||
"hoist": "all" |
|||
} |
|||
], |
|||
"@typescript-eslint/no-unused-expressions": "error", |
|||
"@typescript-eslint/prefer-function-type": "error", |
|||
"@typescript-eslint/unified-signatures": "error", |
|||
"arrow-body-style": "off", |
|||
"constructor-super": "error", |
|||
"eqeqeq": ["error", "smart"], |
|||
"guard-for-in": "error", |
|||
"id-blacklist": "off", |
|||
"id-match": "off", |
|||
"import/no-deprecated": "warn", |
|||
"no-bitwise": "error", |
|||
"no-caller": "error", |
|||
"no-console": [ |
|||
"error", |
|||
{ |
|||
"allow": [ |
|||
"log", |
|||
"warn", |
|||
"dir", |
|||
"timeLog", |
|||
"assert", |
|||
"clear", |
|||
"count", |
|||
"countReset", |
|||
"group", |
|||
"groupEnd", |
|||
"table", |
|||
"dirxml", |
|||
"error", |
|||
"groupCollapsed", |
|||
"Console", |
|||
"profile", |
|||
"profileEnd", |
|||
"timeStamp", |
|||
"context" |
|||
] |
|||
} |
|||
], |
|||
"no-debugger": "error", |
|||
"no-empty": "off", |
|||
"no-eval": "error", |
|||
"no-fallthrough": "error", |
|||
"no-new-wrappers": "error", |
|||
"no-restricted-imports": ["error", "rxjs/Rx"], |
|||
"no-throw-literal": "error", |
|||
"no-undef-init": "error", |
|||
"no-underscore-dangle": "off", |
|||
"no-var": "error", |
|||
"prefer-const": "error", |
|||
"radix": "error" |
|||
} |
|||
} |
|||
] |
|||
} |
@ -0,0 +1,48 @@ |
|||
# See http://help.github.com/ignore-files/ for more about ignoring files. |
|||
|
|||
# compiled output |
|||
/dist/apps/api/data/*.json |
|||
/docker/**/*.*.* |
|||
/docker/**/*.*.*.zip |
|||
/tmp |
|||
/out-tsc |
|||
|
|||
# dependencies |
|||
/node_modules |
|||
|
|||
# IDEs and editors |
|||
/.idea |
|||
.project |
|||
.classpath |
|||
.c9/ |
|||
*.launch |
|||
.settings/ |
|||
*.sublime-workspace |
|||
|
|||
# IDE - VSCode |
|||
.vscode/* |
|||
!.vscode/extensions.json |
|||
!.vscode/launch.json |
|||
!.vscode/settings.json |
|||
!.vscode/tasks.json |
|||
|
|||
# misc |
|||
/.sass-cache |
|||
/connect.lock |
|||
/coverage |
|||
/dist |
|||
/libpeerconnection.log |
|||
npm-debug.log |
|||
yarn-error.log |
|||
testem.log |
|||
/typings |
|||
|
|||
# System Files |
|||
.DS_Store |
|||
Thumbs.db |
|||
|
|||
# Local |
|||
/backups |
|||
.env |
|||
.env.* |
|||
ec2-nano-ssh.pem |
@ -0,0 +1 @@ |
|||
/dist |
@ -0,0 +1,8 @@ |
|||
{ |
|||
"endOfLine": "auto", |
|||
"printWidth": 80, |
|||
"singleQuote": true, |
|||
"tabWidth": 2, |
|||
"trailingComma": "none", |
|||
"useTabs": false |
|||
} |
@ -0,0 +1,8 @@ |
|||
{ |
|||
"recommendations": [ |
|||
"angular.ng-template", |
|||
"esbenp.prettier-vscode", |
|||
"firsttris.vscode-jest-runner", |
|||
"nrwl.angular-console" |
|||
] |
|||
} |
@ -0,0 +1,32 @@ |
|||
{ |
|||
"version": "0.2.0", |
|||
"configurations": [ |
|||
{ |
|||
"name": "Debug Jest File", |
|||
"type": "node", |
|||
"request": "launch", |
|||
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng", |
|||
"args": [ |
|||
"test", |
|||
"--codeCoverage=false", |
|||
"--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts" |
|||
], |
|||
"cwd": "${workspaceFolder}", |
|||
"console": "internalConsole" |
|||
}, |
|||
{ |
|||
"envFile": "${workspaceFolder}/.env", |
|||
"type": "node", |
|||
"request": "launch", |
|||
"name": "Launch Program", |
|||
"program": "${workspaceFolder}/apps/api/src/main.ts", |
|||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"], |
|||
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"], |
|||
"autoAttachChildProcesses": true, |
|||
"skipFiles": [ |
|||
"${workspaceFolder}/node_modules/**/*.js", |
|||
"<node_internals>/**/*.js" |
|||
] |
|||
} |
|||
] |
|||
} |
@ -0,0 +1,4 @@ |
|||
{ |
|||
"editor.defaultFormatter": "esbenp.prettier-vscode", |
|||
"editor.formatOnSave": true |
|||
} |
@ -0,0 +1 @@ |
|||
network-timeout 600000 |
@ -0,0 +1,586 @@ |
|||
# Changelog |
|||
|
|||
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). |
|||
|
|||
## 0.85.0 - 12.04.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Refactored many frontend components |
|||
|
|||
## 0.84.0 - 11.04.2021 |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed static portfolio analysis rules (_Currency Cluster Risk_) if no positions in base currency |
|||
- Initial Investment: Base Currency |
|||
- Current Investment: Base Currency |
|||
|
|||
## 0.83.0 - 11.04.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a new static portfolio analysis rule: Fees in relation to the initial investment |
|||
|
|||
### Changed |
|||
|
|||
- Reset the cache on the server start |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed an issue in the portfolio update on deleting a transaction |
|||
- Fixed an issue in the _X-Ray_ section (missing redirection on logout) |
|||
|
|||
## 0.82.0 - 10.04.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a gradient to the line charts |
|||
- Added a selector to set the base currency on the account page |
|||
|
|||
## 0.81.0 - 06.04.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added support for assets in `GBP` |
|||
- Added an error handling with messages in the client |
|||
|
|||
### Changed |
|||
|
|||
- Changed the _Ghostfolio_ SaaS (cloud) from a `nano` to a `micro` instance for a better performance |
|||
|
|||
## 0.80.0 - 05.04.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Improved the spacing in the header |
|||
- Upgraded `chart.js` from version `2.9.4` to `3.0.2` |
|||
|
|||
## 0.79.0 - 04.04.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Refactored the data management services |
|||
- Upgraded `bootstrap` from version `4.5.3` to `4.6.0` |
|||
- Upgraded `date-fns` from version `2.16.1` to `2.19.0` |
|||
- Upgraded `ionicons` from version `5.4.0` to `5.5.1` |
|||
- Upgraded `lodash` from version `4.17.20` to `4.17.21` |
|||
- Upgraded `ngx-markdown` from version `11.1.0` to `11.1.2` |
|||
- Upgraded `ngx-skeleton-loader` from version `2.6.2` to `2.9.1` |
|||
- Upgraded `prisma` from version `2.18.0` to `2.20.1` |
|||
|
|||
## 0.78.0 - 04.04.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a spinner to the create or edit transaction dialog |
|||
- Added support for the back button in |
|||
- portfolio performance chart dialog |
|||
- position detail dialog |
|||
- create transaction dialog |
|||
- edit transaction dialog |
|||
|
|||
### Changed |
|||
|
|||
- Improved the single platform rule by adding the number of platforms |
|||
|
|||
## 0.77.1 - 03.04.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Minor improvements |
|||
|
|||
## 0.77.0 - 03.04.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added support for base currency in user settings |
|||
- Added an investment risk disclaimer to the footer |
|||
- Added two more static portfolio analysis rules: |
|||
- _Currency Cluster Risk_ (current investment) |
|||
- _Platform Cluster Risk_ (current investment) |
|||
|
|||
### Changed |
|||
|
|||
- Grouped the _X-Ray_ section visually in _Currency Cluster Risk_ and _Platform Cluster Risk_ |
|||
|
|||
## 0.76.0 - 02.04.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added two more static portfolio analysis rules: |
|||
- _Currency Cluster Risk_ (base currency) |
|||
- _Platform Cluster Risk_ (single platform) |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed an issue in the _X-Ray_ section (empty portfolio) |
|||
|
|||
## 0.75.0 - 01.04.2021 |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed an issue in the exchange rate service occurring on the first day of the month |
|||
|
|||
## 0.74.0 - 01.04.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a _Create Account_ message in the _Live Demo_ |
|||
- Added skeleton loaders to the _X-Ray_ section |
|||
|
|||
### Changed |
|||
|
|||
- Improved the alignment of the _Why Ghostfolio?_ section |
|||
- Improved the styling of the _Fear & Greed Index_ (market mood) |
|||
|
|||
## 0.73.0 - 31.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added the _Fear & Greed Index_ (market mood) to the portfolio performance chart dialog |
|||
- Added a link to the info box on the analysis page |
|||
|
|||
### Changed |
|||
|
|||
- Improved the intro text in the _X-Ray_ section |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed the flickering of the _Sign in_ button in the header |
|||
|
|||
## 0.72.1 - 30.03.2021 |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed an issue with updating or resetting the platform of a transaction |
|||
|
|||
## 0.72.0 - 30.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added an intro text to the _X-Ray_ section |
|||
|
|||
### Changed |
|||
|
|||
- Improved the editing of transactions |
|||
- Harmonized the page titles |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed an issue with wrong transaction dates |
|||
|
|||
## 0.71.0 - 28.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added the second static portfolio analysis rule: _Platform Cluster Risk_ |
|||
|
|||
### Changed |
|||
|
|||
- Improved the styling in the _X-Ray_ section |
|||
|
|||
## 0.70.0 - 27.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added the current _Fear & Greed Index_ as text |
|||
- Extended the landing page text: _Ghostfolio_ empowers busy folks... |
|||
- Added the first static portfolio analysis rule in the brand new _X-Ray_ section |
|||
|
|||
### Changed |
|||
|
|||
- Improved the spacing in the footer |
|||
|
|||
## 0.69.0 - 27.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added the current _Fear & Greed Index_ to the resources page |
|||
|
|||
## 0.68.0 - 26.03.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Improved the performance of the position detail dialog |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed a scroll issue in dialogs |
|||
|
|||
## 0.67.0 - 26.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added an experimental API to get historical data for benchmarks |
|||
|
|||
## 0.66.0 - 25.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a chevron to the position |
|||
- Added an experimental API to get benchmark data |
|||
|
|||
## 0.65.0 - 24.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a legend to the portfolio performance chart |
|||
- Added a placeholder to the filter of the transactions table |
|||
|
|||
### Changed |
|||
|
|||
- Changed the regular data management check to a smarter approach |
|||
|
|||
## 0.64.0 - 23.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added an index to the market data database table |
|||
|
|||
### Changed |
|||
|
|||
- Optimized the other dialogs for mobile (full screen and close button) |
|||
|
|||
## 0.63.0 - 22.03.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Improved the transactions table |
|||
- Optimized the position detail dialog for mobile (full screen and close button) |
|||
|
|||
## 0.62.0 - 21.03.2021 |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed an issue while loading data concurrently via the date range component |
|||
|
|||
## 0.61.0 - 21.03.2021 |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed an issue in the performance calculation if there are only transactions from today |
|||
|
|||
## 0.60.0 - 20.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a button to create the first transaction on the analysis page |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed an issue on the analysis page if there are only transactions from today |
|||
|
|||
## 0.59.0 - 20.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Extended the landing page text: Why _Ghostfolio_? |
|||
- Extended the glossary of the resources page |
|||
|
|||
## 0.58.0 - 20.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added meta data for _Open Graph_ and _Twitter Cards_ |
|||
- Added meta data: `description` and `keywords` |
|||
|
|||
### Changed |
|||
|
|||
- Improved the icon |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed the `sitemap.xml` file |
|||
|
|||
## 0.57.0 - 19.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added the `sitemap.xml` file |
|||
- Added a resources page |
|||
- Added a chart to the landing page |
|||
|
|||
### Changed |
|||
|
|||
- Improved the performance chart |
|||
- Improved the average buy price in the position detail chart |
|||
- Improved the style of the active page in the navigation on mobile |
|||
|
|||
## 0.56.0 - 18.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added the quantity and investment in the position detail dialog |
|||
|
|||
### Changed |
|||
|
|||
- Improved the performance chart |
|||
- Improved the performance calculation |
|||
- Improved the average buy price in the position detail chart |
|||
|
|||
## 0.55.0 - 16.03.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Improved the performance calculation |
|||
|
|||
## 0.54.0 - 15.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added another _Create Account_ button at the end of the landing page |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed an issue in the position detail chart if the position has been bought today (no historical data) |
|||
- Fixed an issue in the transaction service with unordered items |
|||
|
|||
## 0.53.0 - 14.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Set up database backup |
|||
|
|||
### Changed |
|||
|
|||
- Improved `site.webmanifest` |
|||
|
|||
## 0.52.0 - 14.03.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Added the membership status to the account page |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed an issue in the chart (empty portfolio) |
|||
|
|||
## 0.51.0 - 14.03.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Changed the default number of rows from 10 to 7 in the positions table |
|||
|
|||
## 0.50.1 - 13.03.2021 |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed the button to expand rows in the positions table |
|||
|
|||
## 0.50.0 - 13.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added filters to switch between _Original Shares_ vs. _Current Shares_ in pie charts |
|||
- Added a button to expand rows in the positions table |
|||
|
|||
### Changed |
|||
|
|||
- Ordered platforms by name in edit transaction dialog |
|||
- Modularized the date range component |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed the error handling for the data management (errors in nested data) |
|||
|
|||
## 0.49.0 - 13.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added additional portfolio filters for `1Y` and `5Y` |
|||
- Added an error handling for the data management |
|||
|
|||
### Changed |
|||
|
|||
- Improved the pricing section |
|||
|
|||
## 0.48.1 - 11.03.2021 |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed the about page for unauthorized users |
|||
|
|||
## 0.48.0 - 11.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a pricing section |
|||
|
|||
### Changed |
|||
|
|||
- Improved the positions and transactions table |
|||
- Harmonized alignment |
|||
- Enabled position detail dialog |
|||
|
|||
## 0.47.0 - 10.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a positions table with information about _Original Shares_ vs. _Current Shares_ |
|||
- Added data management to control panel |
|||
|
|||
## 0.46.0 - 09.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added permission based access-control |
|||
- Added an admin control panel |
|||
|
|||
## 0.45.0 - 08.03.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Changed the data management of benchmarks with extended persistency |
|||
- Changed the data management of currencies with extended persistency |
|||
|
|||
## 0.44.0 - 07.03.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Changed the data management with extended persistency |
|||
- Upgraded `prisma` from version `2.16.1` to `2.18.0` |
|||
- Upgraded `angular` from version `11.0.9` to `11.2.4` |
|||
|
|||
## 0.43.0 - 04.03.2021 |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed missing columns (_Quantity_, _Unit Price_ and _Fee_) in transactions table |
|||
- Fixed displaying edit transaction dialog in impersonation mode |
|||
- Fixed `/.well-known/assetlinks.json` for TWA |
|||
|
|||
## 0.42.0 - 03.03.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Improved the skeleton loader (minor) |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed the portfolio unit tests |
|||
|
|||
## 0.41.0 - 02.03.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added the possibility to create or edit a transaction with a platform |
|||
|
|||
### Changed |
|||
|
|||
- Increased the token expiration duration |
|||
|
|||
### Fixed |
|||
|
|||
- Only show relevant data in the position detail dialog |
|||
- Improved the performance chart styling in Safari |
|||
|
|||
## 0.40.0 - 01.03.2021 |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed the calculation issues occurring on the first day of each month |
|||
- Harmonized the percent value formatting |
|||
|
|||
## 0.39.0 - 28.02.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Improved the buy price in the position detail dialog |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed the (hidden) header issue |
|||
|
|||
## 0.38.0 - 26.02.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added `/.well-known/assetlinks.json` for TWA |
|||
|
|||
## 0.37.0 - 25.02.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a benchmark (_S&P 500_) to the portfolio performance chart |
|||
|
|||
## 0.36.1 - 24.02.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Minor improvements in the transactions table |
|||
|
|||
## 0.36.0 - 24.02.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added the possibility to edit a transaction |
|||
|
|||
## 0.35.0 - 23.02.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Added transparent background to header |
|||
- Harmonized currency value formatting |
|||
|
|||
### Fixed |
|||
|
|||
- Fixed header issue with (not) signed in |
|||
|
|||
## 0.34.0 - 21.02.2021 |
|||
|
|||
### Changed |
|||
|
|||
- Improved skeleton loader of position |
|||
- Simplified sign in / sign up flow |
|||
|
|||
## 0.33.0 - 21.02.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added favicon and `site.webmanifest` |
|||
|
|||
### Changed |
|||
|
|||
- Set font style of numbers to tabular |
|||
- Rename _Orders_ to _Transactions_ |
|||
|
|||
### Security |
|||
|
|||
- Additionally hash the _Security Token_ (no more stored in plain text) |
|||
|
|||
## 0.32.0 - 20.02.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a landing page text: How does _Ghostfolio_ work? |
|||
- Added the _Independent & Bootstrapped_ badge to the about page |
|||
|
|||
## 0.31.0 - 20.02.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a changelog to the about page |
|||
- Added a twitter account to the about page |
|||
- Added the version to the about page |
|||
|
|||
## 0.30.0 - 19.02.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added an about page |
|||
|
|||
## 0.29.0 - 19.02.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added a landing page text: Why _Ghostfolio_? |
|||
|
|||
## 0.28.2 - 17.02.2021 |
|||
|
|||
### Added |
|||
|
|||
- Added caching for the portfolio (Redis) |
@ -0,0 +1,9 @@ |
|||
FROM node:14 |
|||
|
|||
# Create app directory |
|||
WORKDIR /app |
|||
|
|||
COPY . . |
|||
|
|||
EXPOSE 3333 |
|||
CMD [ "npm", "run", "start:prod" ] |
@ -0,0 +1,38 @@ |
|||
<div align="center"> |
|||
<h1>Ghostfolio</h1> |
|||
<p> |
|||
<strong>Privacy-first Portfolio Tracker</strong> |
|||
</p> |
|||
<p> |
|||
<a href="https://www.ghostfol.io"><strong>Ghostfolio</strong></a> |
|||
</p> |
|||
</div> |
|||
|
|||
## Features |
|||
|
|||
- ✅ Dark Mode |
|||
|
|||
## Getting Started |
|||
|
|||
### Prerequisites |
|||
|
|||
- [Node.js](https://nodejs.org/en/download) |
|||
- [Yarn](https://yarnpkg.com/en/docs/install) |
|||
- [Docker](https://www.docker.com/products/docker-desktop) |
|||
|
|||
### Setup |
|||
|
|||
1. Run `yarn install` |
|||
2. Run `yarn docker:dockerize` |
|||
3. Copy `.env.sample` to `docker/.env` |
|||
4. Run `cd docker/<version>` |
|||
5. Run `docker-compose build` |
|||
6. Run `docker-compose up -d` |
|||
|
|||
## Development |
|||
|
|||
- Start server |
|||
- Serve: Run `yarn start:server` |
|||
- Debug: Run `yarn watch:server` and run "Launch Program" in _Visual Studio Code_ |
|||
- Start client |
|||
- Run `yarn start:client` |
@ -0,0 +1,228 @@ |
|||
{ |
|||
"version": 1, |
|||
"cli": { |
|||
"defaultCollection": "@nrwl/nest" |
|||
}, |
|||
"defaultProject": "api", |
|||
"schematics": { |
|||
"@nrwl/angular:application": { |
|||
"unitTestRunner": "jest", |
|||
"e2eTestRunner": "cypress" |
|||
}, |
|||
"@nrwl/angular:library": { |
|||
"unitTestRunner": "jest" |
|||
}, |
|||
"@nrwl/nest": {} |
|||
}, |
|||
"projects": { |
|||
"api": { |
|||
"root": "apps/api", |
|||
"sourceRoot": "apps/api/src", |
|||
"projectType": "application", |
|||
"prefix": "api", |
|||
"schematics": {}, |
|||
"architect": { |
|||
"build": { |
|||
"builder": "@nrwl/node:build", |
|||
"options": { |
|||
"outputPath": "dist/apps/api", |
|||
"main": "apps/api/src/main.ts", |
|||
"tsConfig": "apps/api/tsconfig.app.json", |
|||
"assets": ["apps/api/src/assets"] |
|||
}, |
|||
"configurations": { |
|||
"production": { |
|||
"optimization": true, |
|||
"extractLicenses": true, |
|||
"inspect": false, |
|||
"fileReplacements": [ |
|||
{ |
|||
"replace": "apps/api/src/environments/environment.ts", |
|||
"with": "apps/api/src/environments/environment.prod.ts" |
|||
} |
|||
] |
|||
} |
|||
}, |
|||
"outputs": ["{options.outputPath}"] |
|||
}, |
|||
"serve": { |
|||
"builder": "@nrwl/node:execute", |
|||
"options": { |
|||
"buildTarget": "api:build" |
|||
} |
|||
}, |
|||
"lint": { |
|||
"builder": "@nrwl/linter:eslint", |
|||
"options": { |
|||
"lintFilePatterns": ["apps/api/**/*.ts"] |
|||
} |
|||
}, |
|||
"test": { |
|||
"builder": "@nrwl/jest:jest", |
|||
"options": { |
|||
"jestConfig": "apps/api/jest.config.js", |
|||
"passWithNoTests": true |
|||
}, |
|||
"outputs": ["coverage/apps/api"] |
|||
} |
|||
} |
|||
}, |
|||
"client": { |
|||
"projectType": "application", |
|||
"schematics": { |
|||
"@schematics/angular:component": { |
|||
"style": "scss" |
|||
} |
|||
}, |
|||
"root": "apps/client", |
|||
"sourceRoot": "apps/client/src", |
|||
"prefix": "gf", |
|||
"architect": { |
|||
"build": { |
|||
"builder": "@angular-devkit/build-angular:browser", |
|||
"options": { |
|||
"outputPath": "dist/apps/client", |
|||
"index": "apps/client/src/index.html", |
|||
"main": "apps/client/src/main.ts", |
|||
"polyfills": "apps/client/src/polyfills.ts", |
|||
"tsConfig": "apps/client/tsconfig.app.json", |
|||
"aot": true, |
|||
"assets": [ |
|||
"apps/client/src/assets", |
|||
{ |
|||
"glob": "assetlinks.json", |
|||
"input": "apps/client/src/assets", |
|||
"output": "./.well-known" |
|||
}, |
|||
{ |
|||
"glob": "CHANGELOG.md", |
|||
"input": "", |
|||
"output": "./" |
|||
}, |
|||
{ |
|||
"glob": "sitemap.xml", |
|||
"input": "apps/client/src/assets", |
|||
"output": "./" |
|||
}, |
|||
{ |
|||
"glob": "**/*", |
|||
"input": "node_modules/ionicons/dist/ionicons", |
|||
"output": "./ionicons" |
|||
}, |
|||
{ |
|||
"glob": "**/*.js", |
|||
"input": "node_modules/ionicons/dist/", |
|||
"output": "./" |
|||
} |
|||
], |
|||
"styles": ["apps/client/src/styles.scss"], |
|||
"scripts": ["node_modules/marked/lib/marked.js"] |
|||
}, |
|||
"configurations": { |
|||
"production": { |
|||
"fileReplacements": [ |
|||
{ |
|||
"replace": "apps/client/src/environments/environment.ts", |
|||
"with": "apps/client/src/environments/environment.prod.ts" |
|||
} |
|||
], |
|||
"optimization": true, |
|||
"outputHashing": "all", |
|||
"sourceMap": false, |
|||
"namedChunks": false, |
|||
"extractLicenses": true, |
|||
"vendorChunk": false, |
|||
"buildOptimizer": true, |
|||
"budgets": [ |
|||
{ |
|||
"type": "initial", |
|||
"maximumWarning": "2mb", |
|||
"maximumError": "5mb" |
|||
}, |
|||
{ |
|||
"type": "anyComponentStyle", |
|||
"maximumWarning": "6kb", |
|||
"maximumError": "10kb" |
|||
} |
|||
] |
|||
} |
|||
}, |
|||
"outputs": ["{options.outputPath}"] |
|||
}, |
|||
"serve": { |
|||
"builder": "@angular-devkit/build-angular:dev-server", |
|||
"options": { |
|||
"browserTarget": "client:build", |
|||
"proxyConfig": "apps/client/proxy.conf.json" |
|||
}, |
|||
"configurations": { |
|||
"production": { |
|||
"browserTarget": "client:build:production" |
|||
} |
|||
} |
|||
}, |
|||
"extract-i18n": { |
|||
"builder": "@angular-devkit/build-angular:extract-i18n", |
|||
"options": { |
|||
"browserTarget": "client:build" |
|||
} |
|||
}, |
|||
"lint": { |
|||
"builder": "@nrwl/linter:eslint", |
|||
"options": { |
|||
"lintFilePatterns": ["apps/client/**/*.ts"] |
|||
} |
|||
}, |
|||
"test": { |
|||
"builder": "@nrwl/jest:jest", |
|||
"options": { |
|||
"jestConfig": "apps/client/jest.config.js", |
|||
"passWithNoTests": true |
|||
}, |
|||
"outputs": ["coverage/apps/client"] |
|||
} |
|||
} |
|||
}, |
|||
"client-e2e": { |
|||
"root": "apps/client-e2e", |
|||
"sourceRoot": "apps/client-e2e/src", |
|||
"projectType": "application", |
|||
"architect": { |
|||
"e2e": { |
|||
"builder": "@nrwl/cypress:cypress", |
|||
"options": { |
|||
"cypressConfig": "apps/client-e2e/cypress.json", |
|||
"tsConfig": "apps/client-e2e/tsconfig.e2e.json", |
|||
"devServerTarget": "client:serve" |
|||
}, |
|||
"configurations": { |
|||
"production": { |
|||
"devServerTarget": "client:serve:production" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
"helper": { |
|||
"root": "libs/helper", |
|||
"sourceRoot": "libs/helper/src", |
|||
"projectType": "library", |
|||
"architect": { |
|||
"lint": { |
|||
"builder": "@nrwl/linter:eslint", |
|||
"options": { |
|||
"lintFilePatterns": ["libs/helper/**/*.ts"] |
|||
} |
|||
}, |
|||
"test": { |
|||
"builder": "@nrwl/jest:jest", |
|||
"outputs": ["coverage/libs/helper"], |
|||
"options": { |
|||
"jestConfig": "libs/helper/jest.config.js", |
|||
"passWithNoTests": true |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,22 @@ |
|||
{ |
|||
"extends": "../../.eslintrc.json", |
|||
"ignorePatterns": ["!**/*"], |
|||
"rules": {}, |
|||
"overrides": [ |
|||
{ |
|||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"], |
|||
"parserOptions": { |
|||
"project": ["apps/api/tsconfig.*?.json"] |
|||
}, |
|||
"rules": {} |
|||
}, |
|||
{ |
|||
"files": ["*.ts", "*.tsx"], |
|||
"rules": {} |
|||
}, |
|||
{ |
|||
"files": ["*.js", "*.jsx"], |
|||
"rules": {} |
|||
} |
|||
] |
|||
} |
@ -0,0 +1,15 @@ |
|||
module.exports = { |
|||
displayName: 'api', |
|||
preset: '../../jest.preset.js', |
|||
globals: { |
|||
'ts-jest': { |
|||
tsconfig: '<rootDir>/tsconfig.spec.json' |
|||
} |
|||
}, |
|||
transform: { |
|||
'^.+\\.[tj]s$': 'ts-jest' |
|||
}, |
|||
moduleFileExtensions: ['ts', 'js', 'html'], |
|||
coverageDirectory: '../../coverage/apps/api', |
|||
testTimeout: 10000 |
|||
}; |
@ -0,0 +1,32 @@ |
|||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type'; |
|||
|
|||
import { AccessService } from './access.service'; |
|||
import { Access } from './interfaces/access.interface'; |
|||
|
|||
@Controller('access') |
|||
export class AccessController { |
|||
public constructor( |
|||
private readonly accessService: AccessService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get() |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getAllAccesses(): Promise<Access[]> { |
|||
const accessesWithGranteeUser = await this.accessService.accesses({ |
|||
include: { |
|||
GranteeUser: true |
|||
}, |
|||
where: { userId: this.request.user.id } |
|||
}); |
|||
|
|||
return accessesWithGranteeUser.map((access) => { |
|||
return { |
|||
granteeAlias: access.GranteeUser.alias |
|||
}; |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,12 @@ |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { AccessController } from './access.controller'; |
|||
import { AccessService } from './access.service'; |
|||
|
|||
@Module({ |
|||
imports: [], |
|||
controllers: [AccessController], |
|||
providers: [AccessService, PrismaService] |
|||
}) |
|||
export class AccessModule {} |
@ -0,0 +1,30 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Prisma } from '@prisma/client'; |
|||
|
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { AccessWithGranteeUser } from './interfaces/access-with-grantee-user.type'; |
|||
|
|||
@Injectable() |
|||
export class AccessService { |
|||
public constructor(private prisma: PrismaService) {} |
|||
|
|||
public async accesses(params: { |
|||
include?: Prisma.AccessInclude; |
|||
skip?: number; |
|||
take?: number; |
|||
cursor?: Prisma.AccessWhereUniqueInput; |
|||
where?: Prisma.AccessWhereInput; |
|||
orderBy?: Prisma.AccessOrderByInput; |
|||
}): Promise<AccessWithGranteeUser[]> { |
|||
const { include, skip, take, cursor, where, orderBy } = params; |
|||
|
|||
return this.prisma.access.findMany({ |
|||
cursor, |
|||
include, |
|||
orderBy, |
|||
skip, |
|||
take, |
|||
where |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,3 @@ |
|||
import { Access, User } from '@prisma/client'; |
|||
|
|||
export type AccessWithGranteeUser = Access & { GranteeUser?: User }; |
@ -0,0 +1,3 @@ |
|||
export interface Access { |
|||
granteeAlias: string; |
|||
} |
@ -0,0 +1,64 @@ |
|||
import { |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Post, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
import { getPermissions, hasPermission, permissions } from 'libs/helper/src'; |
|||
|
|||
import { DataGatheringService } from '../../services/data-gathering.service'; |
|||
import { AdminService } from './admin.service'; |
|||
import { AdminData } from './interfaces/admin-data.interface'; |
|||
|
|||
@Controller('admin') |
|||
export class AdminController { |
|||
public constructor( |
|||
private readonly adminService: AdminService, |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get() |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getAdminData(): Promise<AdminData> { |
|||
if ( |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.accessAdminControl |
|||
) |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.adminService.get(); |
|||
} |
|||
|
|||
@Post('gather/max') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async gatherMax(): Promise<void> { |
|||
if ( |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.accessAdminControl |
|||
) |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
this.dataGatheringService.gatherMax(); |
|||
|
|||
return; |
|||
} |
|||
} |
@ -0,0 +1,27 @@ |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { DataGatheringService } from '../../services/data-gathering.service'; |
|||
import { DataProviderService } from '../../services/data-provider.service'; |
|||
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service'; |
|||
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; |
|||
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service'; |
|||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service'; |
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { AdminController } from './admin.controller'; |
|||
import { AdminService } from './admin.service'; |
|||
|
|||
@Module({ |
|||
imports: [], |
|||
controllers: [AdminController], |
|||
providers: [ |
|||
AdminService, |
|||
AlphaVantageService, |
|||
DataGatheringService, |
|||
DataProviderService, |
|||
ExchangeRateDataService, |
|||
PrismaService, |
|||
RakutenRapidApiService, |
|||
YahooFinanceService |
|||
] |
|||
}) |
|||
export class AdminModule {} |
@ -0,0 +1,108 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Currency } from '@prisma/client'; |
|||
|
|||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service'; |
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { AdminData } from './interfaces/admin-data.interface'; |
|||
|
|||
@Injectable() |
|||
export class AdminService { |
|||
public constructor( |
|||
private exchangeRateDataService: ExchangeRateDataService, |
|||
private prisma: PrismaService |
|||
) {} |
|||
|
|||
public async get(): Promise<AdminData> { |
|||
return { |
|||
analytics: await this.getUserAnalytics(), |
|||
exchangeRates: [ |
|||
{ |
|||
label1: Currency.EUR, |
|||
label2: Currency.CHF, |
|||
value: await this.exchangeRateDataService.toCurrency( |
|||
1, |
|||
Currency.EUR, |
|||
Currency.CHF |
|||
) |
|||
}, |
|||
{ |
|||
label1: Currency.GBP, |
|||
label2: Currency.CHF, |
|||
value: await this.exchangeRateDataService.toCurrency( |
|||
1, |
|||
Currency.GBP, |
|||
Currency.CHF |
|||
) |
|||
}, |
|||
{ |
|||
label1: Currency.USD, |
|||
label2: Currency.CHF, |
|||
value: await this.exchangeRateDataService.toCurrency( |
|||
1, |
|||
Currency.USD, |
|||
Currency.CHF |
|||
) |
|||
}, |
|||
{ |
|||
label1: Currency.USD, |
|||
label2: Currency.EUR, |
|||
value: await this.exchangeRateDataService.toCurrency( |
|||
1, |
|||
Currency.USD, |
|||
Currency.EUR |
|||
) |
|||
}, |
|||
{ |
|||
label1: Currency.USD, |
|||
label2: Currency.GBP, |
|||
value: await this.exchangeRateDataService.toCurrency( |
|||
1, |
|||
Currency.USD, |
|||
Currency.GBP |
|||
) |
|||
} |
|||
], |
|||
lastDataGathering: await this.getLastDataGathering(), |
|||
transactionCount: await this.prisma.order.count(), |
|||
userCount: await this.prisma.user.count() |
|||
}; |
|||
} |
|||
|
|||
private async getLastDataGathering() { |
|||
const lastDataGathering = await this.prisma.property.findUnique({ |
|||
where: { key: 'LAST_DATA_GATHERING' } |
|||
}); |
|||
|
|||
if (lastDataGathering?.value) { |
|||
return new Date(lastDataGathering.value); |
|||
} |
|||
|
|||
const dataGatheringInProgress = await this.prisma.property.findUnique({ |
|||
where: { key: 'LOCKED_DATA_GATHERING' } |
|||
}); |
|||
|
|||
if (dataGatheringInProgress) { |
|||
return 'IN_PROGRESS'; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
private async getUserAnalytics() { |
|||
return await this.prisma.analytics.findMany({ |
|||
orderBy: { updatedAt: 'desc' }, |
|||
select: { |
|||
activityCount: true, |
|||
updatedAt: true, |
|||
User: { |
|||
select: { |
|||
alias: true, |
|||
createdAt: true, |
|||
id: true |
|||
} |
|||
} |
|||
}, |
|||
take: 20 |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,14 @@ |
|||
export interface AdminData { |
|||
analytics: { |
|||
activityCount: number; |
|||
updatedAt: Date; |
|||
User: { |
|||
alias: string; |
|||
id: string; |
|||
}; |
|||
}[]; |
|||
exchangeRates: { label1: string; label2: string; value: number }[]; |
|||
lastDataGathering: Date | 'IN_PROGRESS'; |
|||
transactionCount: number; |
|||
userCount: number; |
|||
} |
@ -0,0 +1,31 @@ |
|||
import { Controller } from '@nestjs/common'; |
|||
|
|||
import { PrismaService } from '../services/prisma.service'; |
|||
import { RedisCacheService } from './redis-cache/redis-cache.service'; |
|||
|
|||
@Controller() |
|||
export class AppController { |
|||
public constructor( |
|||
private prisma: PrismaService, |
|||
private readonly redisCacheService: RedisCacheService |
|||
) { |
|||
this.initialize(); |
|||
} |
|||
|
|||
private async initialize() { |
|||
this.redisCacheService.reset(); |
|||
|
|||
const isDataGatheringLocked = await this.prisma.property.findUnique({ |
|||
where: { key: 'LOCKED_DATA_GATHERING' } |
|||
}); |
|||
|
|||
if (!isDataGatheringLocked) { |
|||
// Prepare for automatical data gather if not locked
|
|||
await this.prisma.property.deleteMany({ |
|||
where: { |
|||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }] |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,71 @@ |
|||
import { join } from 'path'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
import { ConfigModule } from '@nestjs/config'; |
|||
import { ScheduleModule } from '@nestjs/schedule'; |
|||
import { ServeStaticModule } from '@nestjs/serve-static'; |
|||
|
|||
import { CronService } from '../services/cron.service'; |
|||
import { DataGatheringService } from '../services/data-gathering.service'; |
|||
import { DataProviderService } from '../services/data-provider.service'; |
|||
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service'; |
|||
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; |
|||
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service'; |
|||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; |
|||
import { PrismaService } from '../services/prisma.service'; |
|||
import { AccessModule } from './access/access.module'; |
|||
import { AdminModule } from './admin/admin.module'; |
|||
import { AppController } from './app.controller'; |
|||
import { AuthModule } from './auth/auth.module'; |
|||
import { CacheModule } from './cache/cache.module'; |
|||
import { ExperimentalModule } from './experimental/experimental.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 { SymbolModule } from './symbol/symbol.module'; |
|||
import { UserModule } from './user/user.module'; |
|||
|
|||
@Module({ |
|||
imports: [ |
|||
AdminModule, |
|||
AccessModule, |
|||
AuthModule, |
|||
CacheModule, |
|||
ConfigModule.forRoot(), |
|||
ExperimentalModule, |
|||
InfoModule, |
|||
OrderModule, |
|||
PortfolioModule, |
|||
RedisCacheModule, |
|||
ScheduleModule.forRoot(), |
|||
ServeStaticModule.forRoot({ |
|||
serveStaticOptions: { |
|||
/*etag: false // Disable etag header to fix PWA |
|||
setHeaders: (res, path) => { |
|||
if (path.includes('ngsw.json')) { |
|||
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
|
|||
// https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache
|
|||
res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); |
|||
} |
|||
}*/ |
|||
}, |
|||
rootPath: join(__dirname, '..', 'client'), |
|||
exclude: ['/api*'] |
|||
}), |
|||
SymbolModule, |
|||
UserModule |
|||
], |
|||
controllers: [AppController], |
|||
providers: [ |
|||
AlphaVantageService, |
|||
CronService, |
|||
DataGatheringService, |
|||
DataProviderService, |
|||
ExchangeRateDataService, |
|||
PrismaService, |
|||
RakutenRapidApiService, |
|||
YahooFinanceService |
|||
] |
|||
}) |
|||
export class AppModule {} |
@ -0,0 +1,52 @@ |
|||
import { |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Param, |
|||
Req, |
|||
Res, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { AuthService } from './auth.service'; |
|||
|
|||
@Controller('auth') |
|||
export class AuthController { |
|||
public constructor(private readonly authService: AuthService) {} |
|||
|
|||
@Get('anonymous/:accessToken') |
|||
public async accessTokenLogin(@Param('accessToken') accessToken: string) { |
|||
try { |
|||
const authToken = await this.authService.validateAnonymousLogin( |
|||
accessToken |
|||
); |
|||
return { authToken }; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('google') |
|||
@UseGuards(AuthGuard('google')) |
|||
public googleLogin() { |
|||
// Initiates the Google OAuth2 login flow
|
|||
} |
|||
|
|||
@Get('google/callback') |
|||
@UseGuards(AuthGuard('google')) |
|||
public googleLoginCallback(@Req() req, @Res() res) { |
|||
// Handles the Google OAuth2 callback
|
|||
const jwt: string = req.user.jwt; |
|||
|
|||
if (jwt) { |
|||
res.redirect(`${process.env.ROOT_URL}/auth/${jwt}`); |
|||
} else { |
|||
res.redirect(`${process.env.ROOT_URL}/auth`); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,27 @@ |
|||
import { Module } from '@nestjs/common'; |
|||
import { JwtModule } from '@nestjs/jwt'; |
|||
|
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { UserService } from '../user/user.service'; |
|||
import { AuthController } from './auth.controller'; |
|||
import { AuthService } from './auth.service'; |
|||
import { GoogleStrategy } from './google.strategy'; |
|||
import { JwtStrategy } from './jwt.strategy'; |
|||
|
|||
@Module({ |
|||
controllers: [AuthController], |
|||
imports: [ |
|||
JwtModule.register({ |
|||
secret: process.env.JWT_SECRET_KEY, |
|||
signOptions: { expiresIn: '180 days' } |
|||
}) |
|||
], |
|||
providers: [ |
|||
AuthService, |
|||
GoogleStrategy, |
|||
JwtStrategy, |
|||
PrismaService, |
|||
UserService |
|||
] |
|||
}) |
|||
export class AuthModule {} |
@ -0,0 +1,67 @@ |
|||
import { Injectable, InternalServerErrorException } from '@nestjs/common'; |
|||
import { JwtService } from '@nestjs/jwt'; |
|||
|
|||
import { UserService } from '../user/user.service'; |
|||
import { ValidateOAuthLoginParams } from './interfaces/interfaces'; |
|||
|
|||
@Injectable() |
|||
export class AuthService { |
|||
public constructor( |
|||
private jwtService: JwtService, |
|||
private readonly userService: UserService |
|||
) {} |
|||
|
|||
public async validateAnonymousLogin(accessToken: string) { |
|||
return new Promise(async (resolve, reject) => { |
|||
try { |
|||
const hashedAccessToken = this.userService.createAccessToken( |
|||
accessToken, |
|||
process.env.ACCESS_TOKEN_SALT |
|||
); |
|||
|
|||
const [user] = await this.userService.users({ |
|||
where: { accessToken: hashedAccessToken } |
|||
}); |
|||
|
|||
if (user) { |
|||
const jwt: string = this.jwtService.sign({ |
|||
id: user.id |
|||
}); |
|||
|
|||
resolve(jwt); |
|||
} else { |
|||
throw new Error(); |
|||
} |
|||
} catch { |
|||
reject(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public async validateOAuthLogin({ |
|||
provider, |
|||
thirdPartyId |
|||
}: ValidateOAuthLoginParams): Promise<string> { |
|||
try { |
|||
let [user] = await this.userService.users({ |
|||
where: { provider, thirdPartyId } |
|||
}); |
|||
|
|||
if (!user) { |
|||
// Create new user if not found
|
|||
user = await this.userService.createUser({ |
|||
provider, |
|||
thirdPartyId |
|||
}); |
|||
} |
|||
|
|||
const jwt: string = this.jwtService.sign({ |
|||
id: user.id |
|||
}); |
|||
|
|||
return jwt; |
|||
} catch (err) { |
|||
throw new InternalServerErrorException('validateOAuthLogin', err.message); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,43 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { PassportStrategy } from '@nestjs/passport'; |
|||
import { Provider } from '@prisma/client'; |
|||
import { Strategy } from 'passport-google-oauth20'; |
|||
|
|||
import { AuthService } from './auth.service'; |
|||
|
|||
@Injectable() |
|||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { |
|||
public constructor(private readonly authService: AuthService) { |
|||
super({ |
|||
callbackURL: `${process.env.ROOT_URL}/api/auth/google/callback`, |
|||
clientID: process.env.GOOGLE_CLIENT_ID, |
|||
clientSecret: process.env.GOOGLE_SECRET, |
|||
passReqToCallback: true, |
|||
scope: ['email', 'profile'] |
|||
}); |
|||
} |
|||
|
|||
public async validate( |
|||
request: any, |
|||
token: string, |
|||
refreshToken: string, |
|||
profile, |
|||
done: Function, |
|||
done2: Function |
|||
) { |
|||
try { |
|||
const jwt: string = await this.authService.validateOAuthLogin({ |
|||
provider: Provider.GOOGLE, |
|||
thirdPartyId: profile.id |
|||
}); |
|||
const user = { |
|||
jwt |
|||
}; |
|||
|
|||
done(null, user); |
|||
} catch (err) { |
|||
console.error(err); |
|||
done(err, false); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,6 @@ |
|||
import { Provider } from '@prisma/client'; |
|||
|
|||
export interface ValidateOAuthLoginParams { |
|||
provider: Provider; |
|||
thirdPartyId: string; |
|||
} |
@ -0,0 +1,39 @@ |
|||
import { Injectable, UnauthorizedException } from '@nestjs/common'; |
|||
import { PassportStrategy } from '@nestjs/passport'; |
|||
import { ExtractJwt, Strategy } from 'passport-jwt'; |
|||
|
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { UserService } from '../user/user.service'; |
|||
|
|||
@Injectable() |
|||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { |
|||
public constructor( |
|||
private prisma: PrismaService, |
|||
private readonly userService: UserService |
|||
) { |
|||
super({ |
|||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), |
|||
secretOrKey: process.env.JWT_SECRET_KEY |
|||
}); |
|||
} |
|||
|
|||
public async validate({ id }: { id: string }) { |
|||
try { |
|||
const user = await this.userService.user({ id }); |
|||
|
|||
if (user) { |
|||
await this.prisma.analytics.upsert({ |
|||
create: { User: { connect: { id: user.id } } }, |
|||
update: { activityCount: { increment: 1 }, updatedAt: new Date() }, |
|||
where: { userId: user.id } |
|||
}); |
|||
|
|||
return user; |
|||
} else { |
|||
throw ''; |
|||
} |
|||
} catch (err) { |
|||
throw new UnauthorizedException('unauthorized', err.message); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,26 @@ |
|||
import { Controller, Inject, Param, Post, UseGuards } from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type'; |
|||
|
|||
import { RedisCacheService } from '../redis-cache/redis-cache.service'; |
|||
import { CacheService } from './cache.service'; |
|||
|
|||
@Controller('cache') |
|||
export class CacheController { |
|||
public constructor( |
|||
private readonly cacheService: CacheService, |
|||
private readonly redisCacheService: RedisCacheService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) { |
|||
this.redisCacheService.reset(); |
|||
} |
|||
|
|||
@Post('flush') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async flushCache(): Promise<void> { |
|||
this.redisCacheService.reset(); |
|||
|
|||
return this.cacheService.flush(this.request.user.id); |
|||
} |
|||
} |
@ -0,0 +1,13 @@ |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { RedisCacheModule } from '../redis-cache/redis-cache.module'; |
|||
import { CacheController } from './cache.controller'; |
|||
import { CacheService } from './cache.service'; |
|||
|
|||
@Module({ |
|||
imports: [RedisCacheModule], |
|||
controllers: [CacheController], |
|||
providers: [CacheService, PrismaService] |
|||
}) |
|||
export class CacheModule {} |
@ -0,0 +1,19 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Prisma, User } from '@prisma/client'; |
|||
|
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
|
|||
@Injectable() |
|||
export class CacheService { |
|||
public constructor(private prisma: PrismaService) {} |
|||
|
|||
public async flush(aUserId: string): Promise<void> { |
|||
await this.prisma.property.deleteMany({ |
|||
where: { |
|||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }] |
|||
} |
|||
}); |
|||
|
|||
return; |
|||
} |
|||
} |
@ -0,0 +1,22 @@ |
|||
import { Currency, Type } from '@prisma/client'; |
|||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator'; |
|||
|
|||
export class CreateOrderDto { |
|||
@IsString() |
|||
currency: Currency; |
|||
|
|||
@IsISO8601() |
|||
date: string; |
|||
|
|||
@IsNumber() |
|||
quantity: number; |
|||
|
|||
@IsString() |
|||
symbol: string; |
|||
|
|||
@IsString() |
|||
type: Type; |
|||
|
|||
@IsNumber() |
|||
unitPrice: number; |
|||
} |
@ -0,0 +1,88 @@ |
|||
import { |
|||
Body, |
|||
Controller, |
|||
Get, |
|||
Headers, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type'; |
|||
import { parse } from 'date-fns'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
import { baseCurrency, benchmarks } from 'libs/helper/src'; |
|||
import { isApiTokenAuthorized } from 'libs/helper/src'; |
|||
|
|||
import { CreateOrderDto } from './create-order.dto'; |
|||
import { ExperimentalService } from './experimental.service'; |
|||
import { Data } from './interfaces/data.interface'; |
|||
|
|||
@Controller('experimental') |
|||
export class ExperimentalController { |
|||
public constructor( |
|||
private readonly experimentalService: ExperimentalService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get('benchmarks') |
|||
public async getBenchmarks( |
|||
@Headers('Authorization') apiToken: string |
|||
): Promise<string[]> { |
|||
if (!isApiTokenAuthorized(apiToken)) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return benchmarks; |
|||
} |
|||
|
|||
@Get('benchmarks/:symbol') |
|||
public async getBenchmark( |
|||
@Headers('Authorization') apiToken: string, |
|||
@Param('symbol') symbol: string |
|||
): Promise<{ date: Date; marketPrice: number }[]> { |
|||
if (!isApiTokenAuthorized(apiToken)) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
const marketData = await this.experimentalService.getBenchmark(symbol); |
|||
|
|||
if (marketData?.length === 0) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
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); |
|||
} |
|||
} |
@ -0,0 +1,27 @@ |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { DataProviderService } from '../../services/data-provider.service'; |
|||
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service'; |
|||
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; |
|||
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service'; |
|||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service'; |
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { RulesService } from '../../services/rules.service'; |
|||
import { ExperimentalController } from './experimental.controller'; |
|||
import { ExperimentalService } from './experimental.service'; |
|||
|
|||
@Module({ |
|||
imports: [], |
|||
controllers: [ExperimentalController], |
|||
providers: [ |
|||
AlphaVantageService, |
|||
DataProviderService, |
|||
ExchangeRateDataService, |
|||
ExperimentalService, |
|||
PrismaService, |
|||
RakutenRapidApiService, |
|||
RulesService, |
|||
YahooFinanceService |
|||
] |
|||
}) |
|||
export class ExperimentalModule {} |
@ -0,0 +1,62 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Currency, Type } from '@prisma/client'; |
|||
import { parseISO } from 'date-fns'; |
|||
|
|||
import { Portfolio } from '../../models/portfolio'; |
|||
import { DataProviderService } from '../../services/data-provider.service'; |
|||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service'; |
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { RulesService } from '../../services/rules.service'; |
|||
import { OrderWithPlatform } from '../order/interfaces/order-with-platform.type'; |
|||
import { CreateOrderDto } from './create-order.dto'; |
|||
import { Data } from './interfaces/data.interface'; |
|||
|
|||
@Injectable() |
|||
export class ExperimentalService { |
|||
public constructor( |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private prisma: PrismaService, |
|||
private readonly rulesService: RulesService |
|||
) {} |
|||
|
|||
public async getBenchmark(aSymbol: string) { |
|||
return this.prisma.marketData.findMany({ |
|||
orderBy: { date: 'asc' }, |
|||
select: { date: true, marketPrice: true }, |
|||
where: { symbol: aSymbol } |
|||
}); |
|||
} |
|||
|
|||
public async getValue( |
|||
aOrders: CreateOrderDto[], |
|||
aDate: Date, |
|||
aBaseCurrency: Currency |
|||
): Promise<Data> { |
|||
const ordersWithPlatform: OrderWithPlatform[] = aOrders.map((order) => { |
|||
return { |
|||
...order, |
|||
createdAt: new Date(), |
|||
date: parseISO(order.date), |
|||
fee: 0, |
|||
id: undefined, |
|||
platformId: 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) |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,6 @@ |
|||
import { Currency } from '@prisma/client'; |
|||
|
|||
export interface Data { |
|||
currency: Currency; |
|||
value: number; |
|||
} |
@ -0,0 +1,14 @@ |
|||
import { Controller, Get } from '@nestjs/common'; |
|||
|
|||
import { InfoService } from './info.service'; |
|||
import { InfoItem } from './interfaces/info-item.interface'; |
|||
|
|||
@Controller('info') |
|||
export class InfoController { |
|||
public constructor(private readonly infoService: InfoService) {} |
|||
|
|||
@Get() |
|||
public async getInfo(): Promise<InfoItem> { |
|||
return this.infoService.get(); |
|||
} |
|||
} |
@ -0,0 +1,18 @@ |
|||
import { Module } from '@nestjs/common'; |
|||
import { JwtModule } from '@nestjs/jwt'; |
|||
|
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { InfoController } from './info.controller'; |
|||
import { InfoService } from './info.service'; |
|||
|
|||
@Module({ |
|||
imports: [ |
|||
JwtModule.register({ |
|||
secret: process.env.JWT_SECRET_KEY, |
|||
signOptions: { expiresIn: '30 days' } |
|||
}) |
|||
], |
|||
controllers: [InfoController], |
|||
providers: [InfoService, PrismaService] |
|||
}) |
|||
export class InfoModule {} |
@ -0,0 +1,44 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { JwtService } from '@nestjs/jwt'; |
|||
import { Currency } from '@prisma/client'; |
|||
|
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { InfoItem } from './interfaces/info-item.interface'; |
|||
|
|||
@Injectable() |
|||
export class InfoService { |
|||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f'; |
|||
|
|||
public constructor( |
|||
private jwtService: JwtService, |
|||
private prisma: PrismaService |
|||
) {} |
|||
|
|||
public async get(): Promise<InfoItem> { |
|||
const platforms = await this.prisma.platform.findMany({ |
|||
orderBy: { name: 'asc' }, |
|||
select: { id: true, name: true } |
|||
}); |
|||
|
|||
return { |
|||
platforms, |
|||
currencies: Object.values(Currency), |
|||
demoAuthToken: this.getDemoAuthToken(), |
|||
lastDataGathering: await this.getLastDataGathering() |
|||
}; |
|||
} |
|||
|
|||
private getDemoAuthToken() { |
|||
return this.jwtService.sign({ |
|||
id: InfoService.DEMO_USER_ID |
|||
}); |
|||
} |
|||
|
|||
private async getLastDataGathering() { |
|||
const lastDataGathering = await this.prisma.property.findUnique({ |
|||
where: { key: 'LAST_DATA_GATHERING' } |
|||
}); |
|||
|
|||
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null; |
|||
} |
|||
} |
@ -0,0 +1,12 @@ |
|||
import { Currency } from '@prisma/client'; |
|||
|
|||
export interface InfoItem { |
|||
currencies: Currency[]; |
|||
demoAuthToken: string; |
|||
lastDataGathering?: Date; |
|||
message?: { |
|||
text: string; |
|||
type: string; |
|||
}; |
|||
platforms: { id: string; name: string }[]; |
|||
} |
@ -0,0 +1,3 @@ |
|||
import { UserWithSettings } from './user-with-settings'; |
|||
|
|||
export type RequestWithUser = Request & { user: UserWithSettings }; |
@ -0,0 +1,3 @@ |
|||
import { Settings, User } from '@prisma/client'; |
|||
|
|||
export type UserWithSettings = User & { Settings: Settings }; |
@ -0,0 +1,29 @@ |
|||
import { Currency, Type } from '@prisma/client'; |
|||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator'; |
|||
|
|||
export class CreateOrderDto { |
|||
@IsString() |
|||
currency: Currency; |
|||
|
|||
@IsISO8601() |
|||
date: string; |
|||
|
|||
@IsNumber() |
|||
fee: number; |
|||
|
|||
@IsString() |
|||
@ValidateIf((object, value) => value !== null) |
|||
platformId: string | null; |
|||
|
|||
@IsNumber() |
|||
quantity: number; |
|||
|
|||
@IsString() |
|||
symbol: string; |
|||
|
|||
@IsString() |
|||
type: Type; |
|||
|
|||
@IsNumber() |
|||
unitPrice: number; |
|||
} |
@ -0,0 +1,3 @@ |
|||
import { Order, Platform } from '@prisma/client'; |
|||
|
|||
export type OrderWithPlatform = Order & { Platform?: Platform }; |
@ -0,0 +1,218 @@ |
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
Headers, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
Put, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { Order as OrderModel } from '@prisma/client'; |
|||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type'; |
|||
import { parseISO } from 'date-fns'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
import { getPermissions, hasPermission, permissions } from 'libs/helper/src'; |
|||
|
|||
import { nullifyValuesInObjects } from '../../helper/object.helper'; |
|||
import { ImpersonationService } from '../../services/impersonation.service'; |
|||
import { CreateOrderDto } from './create-order.dto'; |
|||
import { OrderService } from './order.service'; |
|||
import { UpdateOrderDto } from './update-order.dto'; |
|||
|
|||
@Controller('order') |
|||
export class OrderController { |
|||
public constructor( |
|||
private readonly impersonationService: ImpersonationService, |
|||
private readonly orderService: OrderService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Delete(':id') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { |
|||
if ( |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.deleteOrder |
|||
) |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.orderService.deleteOrder( |
|||
{ |
|||
id_userId: { |
|||
id, |
|||
userId: this.request.user.id |
|||
} |
|||
}, |
|||
this.request.user.id |
|||
); |
|||
} |
|||
|
|||
@Get() |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getAllOrders( |
|||
@Headers('impersonation-id') impersonationId |
|||
): Promise<OrderModel[]> { |
|||
const impersonationUserId = await this.impersonationService.validateImpersonationId( |
|||
impersonationId, |
|||
this.request.user.id |
|||
); |
|||
|
|||
let orders = await this.orderService.orders({ |
|||
include: { |
|||
Platform: true |
|||
}, |
|||
orderBy: { date: 'desc' }, |
|||
where: { userId: impersonationUserId || this.request.user.id } |
|||
}); |
|||
|
|||
if ( |
|||
impersonationUserId && |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.readForeignPortfolio |
|||
) |
|||
) { |
|||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']); |
|||
} |
|||
|
|||
return orders; |
|||
} |
|||
|
|||
@Get(':id') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getOrderById(@Param('id') id: string): Promise<OrderModel> { |
|||
return this.orderService.order({ |
|||
id_userId: { |
|||
id, |
|||
userId: this.request.user.id |
|||
} |
|||
}); |
|||
} |
|||
|
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { |
|||
if ( |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.createOrder |
|||
) |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
const date = parseISO(data.date); |
|||
|
|||
if (data.platformId) { |
|||
const platformId = data.platformId; |
|||
delete data.platformId; |
|||
|
|||
return this.orderService.createOrder( |
|||
{ |
|||
...data, |
|||
date, |
|||
Platform: { connect: { id: platformId } }, |
|||
User: { connect: { id: this.request.user.id } } |
|||
}, |
|||
this.request.user.id |
|||
); |
|||
} else { |
|||
delete data.platformId; |
|||
|
|||
return this.orderService.createOrder( |
|||
{ |
|||
...data, |
|||
date, |
|||
User: { connect: { id: this.request.user.id } } |
|||
}, |
|||
this.request.user.id |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Put(':id') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { |
|||
if ( |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.updateOrder |
|||
) |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
const originalOrder = await this.orderService.order({ |
|||
id_userId: { |
|||
id, |
|||
userId: this.request.user.id |
|||
} |
|||
}); |
|||
|
|||
const date = parseISO(data.date); |
|||
|
|||
if (data.platformId) { |
|||
const platformId = data.platformId; |
|||
delete data.platformId; |
|||
|
|||
return this.orderService.updateOrder( |
|||
{ |
|||
data: { |
|||
...data, |
|||
date, |
|||
Platform: { connect: { id: platformId } }, |
|||
User: { connect: { id: this.request.user.id } } |
|||
}, |
|||
where: { |
|||
id_userId: { |
|||
id, |
|||
userId: this.request.user.id |
|||
} |
|||
} |
|||
}, |
|||
this.request.user.id |
|||
); |
|||
} else { |
|||
// platformId is null, remove it
|
|||
delete data.platformId; |
|||
|
|||
return this.orderService.updateOrder( |
|||
{ |
|||
data: { |
|||
...data, |
|||
date, |
|||
Platform: originalOrder.platformId |
|||
? { disconnect: true } |
|||
: undefined, |
|||
User: { connect: { id: this.request.user.id } } |
|||
}, |
|||
where: { |
|||
id_userId: { |
|||
id, |
|||
userId: this.request.user.id |
|||
} |
|||
} |
|||
}, |
|||
this.request.user.id |
|||
); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,30 @@ |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { DataGatheringService } from '../../services/data-gathering.service'; |
|||
import { DataProviderService } from '../../services/data-provider.service'; |
|||
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service'; |
|||
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; |
|||
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service'; |
|||
import { ImpersonationService } from '../../services/impersonation.service'; |
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { CacheService } from '../cache/cache.service'; |
|||
import { RedisCacheModule } from '../redis-cache/redis-cache.module'; |
|||
import { OrderController } from './order.controller'; |
|||
import { OrderService } from './order.service'; |
|||
|
|||
@Module({ |
|||
imports: [RedisCacheModule], |
|||
controllers: [OrderController], |
|||
providers: [ |
|||
AlphaVantageService, |
|||
CacheService, |
|||
DataGatheringService, |
|||
DataProviderService, |
|||
ImpersonationService, |
|||
OrderService, |
|||
PrismaService, |
|||
RakutenRapidApiService, |
|||
YahooFinanceService |
|||
] |
|||
}) |
|||
export class OrderModule {} |
@ -0,0 +1,105 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Order, Prisma } from '@prisma/client'; |
|||
|
|||
import { DataGatheringService } from '../../services/data-gathering.service'; |
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { CacheService } from '../cache/cache.service'; |
|||
import { RedisCacheService } from '../redis-cache/redis-cache.service'; |
|||
import { OrderWithPlatform } from './interfaces/order-with-platform.type'; |
|||
|
|||
@Injectable() |
|||
export class OrderService { |
|||
public constructor( |
|||
private readonly cacheService: CacheService, |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
private readonly redisCacheService: RedisCacheService, |
|||
private prisma: PrismaService |
|||
) {} |
|||
|
|||
public async order( |
|||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput |
|||
): Promise<Order | null> { |
|||
return this.prisma.order.findUnique({ |
|||
where: orderWhereUniqueInput |
|||
}); |
|||
} |
|||
|
|||
public async orders(params: { |
|||
include?: Prisma.OrderInclude; |
|||
skip?: number; |
|||
take?: number; |
|||
cursor?: Prisma.OrderWhereUniqueInput; |
|||
where?: Prisma.OrderWhereInput; |
|||
orderBy?: Prisma.OrderOrderByInput; |
|||
}): Promise<OrderWithPlatform[]> { |
|||
const { include, skip, take, cursor, where, orderBy } = params; |
|||
|
|||
return this.prisma.order.findMany({ |
|||
cursor, |
|||
include, |
|||
orderBy, |
|||
skip, |
|||
take, |
|||
where |
|||
}); |
|||
} |
|||
|
|||
public async createOrder( |
|||
data: Prisma.OrderCreateInput, |
|||
aUserId: string |
|||
): Promise<Order> { |
|||
this.redisCacheService.remove(`${aUserId}.portfolio`); |
|||
|
|||
// Gather symbol data of order in the background
|
|||
this.dataGatheringService.gatherSymbols([ |
|||
{ |
|||
date: <Date>data.date, |
|||
symbol: data.symbol |
|||
} |
|||
]); |
|||
|
|||
await this.cacheService.flush(aUserId); |
|||
|
|||
return this.prisma.order.create({ |
|||
data |
|||
}); |
|||
} |
|||
|
|||
public async deleteOrder( |
|||
where: Prisma.OrderWhereUniqueInput, |
|||
aUserId: string |
|||
): Promise<Order> { |
|||
this.redisCacheService.remove(`${aUserId}.portfolio`); |
|||
|
|||
return this.prisma.order.delete({ |
|||
where |
|||
}); |
|||
} |
|||
|
|||
public async updateOrder( |
|||
params: { |
|||
where: Prisma.OrderWhereUniqueInput; |
|||
data: Prisma.OrderUpdateInput; |
|||
}, |
|||
aUserId: string |
|||
): Promise<Order> { |
|||
const { data, where } = params; |
|||
|
|||
this.redisCacheService.remove(`${aUserId}.portfolio`); |
|||
|
|||
// Gather symbol data of order in the background
|
|||
this.dataGatheringService.gatherSymbols([ |
|||
{ |
|||
date: <Date>data.date, |
|||
symbol: <string>data.symbol |
|||
} |
|||
]); |
|||
|
|||
await this.cacheService.flush(aUserId); |
|||
|
|||
return this.prisma.order.update({ |
|||
data, |
|||
where |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,32 @@ |
|||
import { Currency, Type } from '@prisma/client'; |
|||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator'; |
|||
|
|||
export class UpdateOrderDto { |
|||
@IsString() |
|||
currency: Currency; |
|||
|
|||
@IsISO8601() |
|||
date: string; |
|||
|
|||
@IsNumber() |
|||
fee: number; |
|||
|
|||
@IsString() |
|||
@ValidateIf((object, value) => value !== null) |
|||
platformId: string | null; |
|||
|
|||
@IsString() |
|||
id: string; |
|||
|
|||
@IsNumber() |
|||
quantity: number; |
|||
|
|||
@IsString() |
|||
symbol: string; |
|||
|
|||
@IsString() |
|||
type: Type; |
|||
|
|||
@IsNumber() |
|||
unitPrice: number; |
|||
} |
@ -0,0 +1 @@ |
|||
export type DateRange = '1d' | '1y' | '5y' | 'max' | 'ytd'; |
@ -0,0 +1,19 @@ |
|||
import { Currency } from '@prisma/client'; |
|||
|
|||
export interface PortfolioItem { |
|||
date: string; |
|||
grossPerformancePercent: number; |
|||
investment: number; |
|||
positions: { [symbol: string]: Position }; |
|||
value: number; |
|||
} |
|||
|
|||
export interface Position { |
|||
averagePrice: number; |
|||
currency: Currency; |
|||
firstBuyDate: string; |
|||
investment: number; |
|||
investmentInOriginalCurrency?: number; |
|||
marketPrice?: number; |
|||
quantity: number; |
|||
} |
@ -0,0 +1,7 @@ |
|||
export interface PortfolioOverview { |
|||
committedFunds: number; |
|||
fees: number; |
|||
ordersCount: number; |
|||
totalBuy: number; |
|||
totalSell: number; |
|||
} |
@ -0,0 +1,7 @@ |
|||
export interface PortfolioPerformance { |
|||
currentGrossPerformance: number; |
|||
currentGrossPerformancePercent: number; |
|||
currentNetPerformance: number; |
|||
currentNetPerformancePercent: number; |
|||
currentValue: number; |
|||
} |
@ -0,0 +1,21 @@ |
|||
export interface PortfolioPositionDetail { |
|||
averagePrice: number; |
|||
currency: string; |
|||
firstBuyDate: string; |
|||
grossPerformance: number; |
|||
grossPerformancePercent: number; |
|||
historicalData: HistoricalDataItem[]; |
|||
investment: number; |
|||
marketPrice: number; |
|||
maxPrice: number; |
|||
minPrice: number; |
|||
quantity: number; |
|||
symbol: string; |
|||
} |
|||
|
|||
export interface HistoricalDataItem { |
|||
averagePrice?: number; |
|||
date: string; |
|||
grossPerformancePercent?: number; |
|||
value: number; |
|||
} |
@ -0,0 +1,25 @@ |
|||
import { Currency } from '@prisma/client'; |
|||
|
|||
export interface PortfolioPosition { |
|||
currency: Currency; |
|||
exchange?: string; |
|||
grossPerformance: number; |
|||
grossPerformancePercent: number; |
|||
industry?: string; |
|||
investment: number; |
|||
isMarketOpen: boolean; |
|||
marketChange?: number; |
|||
marketChangePercent?: number; |
|||
marketPrice: number; |
|||
name: string; |
|||
platforms: { |
|||
[name: string]: { current: number; original: number }; |
|||
}; |
|||
quantity: number; |
|||
sector?: string; |
|||
shareCurrent: number; |
|||
shareInvestment: number; |
|||
symbol: string; |
|||
type?: string; |
|||
url?: string; |
|||
} |
@ -0,0 +1,9 @@ |
|||
export interface PortfolioReport { |
|||
rules: { [group: string]: PortfolioReportRule[] }; |
|||
} |
|||
|
|||
export interface PortfolioReportRule { |
|||
evaluation: string; |
|||
name: string; |
|||
value: boolean; |
|||
} |
@ -0,0 +1,326 @@ |
|||
import { |
|||
Controller, |
|||
Get, |
|||
Headers, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Query, |
|||
Res, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { Response } from 'express'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
import { getPermissions, hasPermission, permissions } from 'libs/helper/src'; |
|||
|
|||
import { |
|||
hasNotDefinedValuesInObject, |
|||
nullifyValuesInObject |
|||
} from '../../helper/object.helper'; |
|||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service'; |
|||
import { ImpersonationService } from '../../services/impersonation.service'; |
|||
import { RequestWithUser } from '../interfaces/request-with-user.type'; |
|||
import { PortfolioItem } from './interfaces/portfolio-item.interface'; |
|||
import { PortfolioOverview } from './interfaces/portfolio-overview.interface'; |
|||
import { PortfolioPerformance } from './interfaces/portfolio-performance.interface'; |
|||
import { |
|||
HistoricalDataItem, |
|||
PortfolioPositionDetail |
|||
} from './interfaces/portfolio-position-detail.interface'; |
|||
import { PortfolioPosition } from './interfaces/portfolio-position.interface'; |
|||
import { PortfolioReport } from './interfaces/portfolio-report.interface'; |
|||
import { PortfolioService } from './portfolio.service'; |
|||
|
|||
@Controller('portfolio') |
|||
export class PortfolioController { |
|||
public constructor( |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly impersonationService: ImpersonationService, |
|||
private portfolioService: PortfolioService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get() |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async findAll( |
|||
@Headers('impersonation-id') impersonationId |
|||
): Promise<PortfolioItem[]> { |
|||
let portfolio = await this.portfolioService.findAll(impersonationId); |
|||
|
|||
if ( |
|||
impersonationId && |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
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; |
|||
}); |
|||
|
|||
portfolioItem.investment = null; |
|||
|
|||
return portfolioItem; |
|||
}); |
|||
} |
|||
|
|||
return portfolio; |
|||
} |
|||
|
|||
@Get('chart') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getChart( |
|||
@Headers('impersonation-id') impersonationId, |
|||
@Query('range') range, |
|||
@Res() res: Response |
|||
): Promise<HistoricalDataItem[]> { |
|||
let chartData = await this.portfolioService.getChart( |
|||
impersonationId, |
|||
range |
|||
); |
|||
|
|||
let hasNullValue = false; |
|||
|
|||
chartData.forEach((chartDataItem) => { |
|||
if (hasNotDefinedValuesInObject(chartDataItem)) { |
|||
hasNullValue = true; |
|||
} |
|||
}); |
|||
|
|||
if (hasNullValue) { |
|||
res.status(StatusCodes.ACCEPTED); |
|||
} |
|||
|
|||
if ( |
|||
impersonationId && |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.readForeignPortfolio |
|||
) |
|||
) { |
|||
let maxValue = 0; |
|||
|
|||
chartData.forEach((portfolioItem) => { |
|||
if (portfolioItem.value > maxValue) { |
|||
maxValue = portfolioItem.value; |
|||
} |
|||
}); |
|||
|
|||
chartData = chartData.map((historicalDataItem) => { |
|||
return { |
|||
...historicalDataItem, |
|||
marketPrice: Number((historicalDataItem.value / maxValue).toFixed(2)) |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
return <any>res.json(chartData); |
|||
} |
|||
|
|||
@Get('details') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getDetails( |
|||
@Headers('impersonation-id') impersonationId, |
|||
@Query('range') range, |
|||
@Res() res: Response |
|||
): 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 |
|||
); |
|||
|
|||
try { |
|||
details = await portfolio.getDetails(range); |
|||
} catch (error) { |
|||
console.error(error); |
|||
|
|||
res.status(StatusCodes.ACCEPTED); |
|||
} |
|||
|
|||
if (hasNotDefinedValuesInObject(details)) { |
|||
res.status(StatusCodes.ACCEPTED); |
|||
} |
|||
|
|||
if ( |
|||
impersonationId && |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.readForeignPortfolio |
|||
) |
|||
) { |
|||
const totalInvestment = Object.values(details) |
|||
.map((portfolioPosition) => { |
|||
return portfolioPosition.investment; |
|||
}) |
|||
.reduce((a, b) => a + b, 0); |
|||
|
|||
const totalValue = Object.values(details) |
|||
.map((portfolioPosition) => { |
|||
return this.exchangeRateDataService.toCurrency( |
|||
portfolioPosition.quantity * portfolioPosition.marketPrice, |
|||
portfolioPosition.currency, |
|||
this.request.user.Settings.currency |
|||
); |
|||
}) |
|||
.reduce((a, b) => a + b, 0); |
|||
|
|||
for (const [symbol, portfolioPosition] of Object.entries(details)) { |
|||
portfolioPosition.grossPerformance = null; |
|||
portfolioPosition.investment = |
|||
portfolioPosition.investment / totalInvestment; |
|||
|
|||
for (const [platform, { current, original }] of Object.entries( |
|||
portfolioPosition.platforms |
|||
)) { |
|||
portfolioPosition.platforms[platform].current = current / totalValue; |
|||
portfolioPosition.platforms[platform].original = |
|||
original / totalInvestment; |
|||
} |
|||
|
|||
portfolioPosition.quantity = null; |
|||
} |
|||
} |
|||
|
|||
return <any>res.json(details); |
|||
} |
|||
|
|||
@Get('overview') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getOverview( |
|||
@Headers('impersonation-id') impersonationId |
|||
): Promise<PortfolioOverview> { |
|||
let overview = await this.portfolioService.getOverview(impersonationId); |
|||
|
|||
if ( |
|||
impersonationId && |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.readForeignPortfolio |
|||
) |
|||
) { |
|||
overview = nullifyValuesInObject(overview, [ |
|||
'committedFunds', |
|||
'fees', |
|||
'totalBuy', |
|||
'totalSell' |
|||
]); |
|||
} |
|||
|
|||
return overview; |
|||
} |
|||
|
|||
@Get('performance') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getPerformance( |
|||
@Headers('impersonation-id') impersonationId, |
|||
@Query('range') range, |
|||
@Res() res: Response |
|||
): Promise<PortfolioPerformance> { |
|||
const impersonationUserId = await this.impersonationService.validateImpersonationId( |
|||
impersonationId, |
|||
this.request.user.id |
|||
); |
|||
|
|||
const portfolio = await this.portfolioService.createPortfolio( |
|||
impersonationUserId || this.request.user.id |
|||
); |
|||
|
|||
let performance = await portfolio.getPerformance(range); |
|||
|
|||
if (hasNotDefinedValuesInObject(performance)) { |
|||
res.status(StatusCodes.ACCEPTED); |
|||
} |
|||
|
|||
if ( |
|||
impersonationId && |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.readForeignPortfolio |
|||
) |
|||
) { |
|||
performance = nullifyValuesInObject(performance, [ |
|||
'currentGrossPerformance', |
|||
'currentNetPerformance', |
|||
'currentValue' |
|||
]); |
|||
} |
|||
|
|||
return <any>res.json(performance); |
|||
} |
|||
|
|||
@Get('position/:symbol') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getPosition( |
|||
@Headers('impersonation-id') impersonationId, |
|||
@Param('symbol') symbol |
|||
): Promise<PortfolioPositionDetail> { |
|||
let position = await this.portfolioService.getPosition( |
|||
impersonationId, |
|||
symbol |
|||
); |
|||
|
|||
if (position) { |
|||
if ( |
|||
impersonationId && |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.readForeignPortfolio |
|||
) |
|||
) { |
|||
position = nullifyValuesInObject(position, ['grossPerformance']); |
|||
} |
|||
|
|||
return position; |
|||
} |
|||
|
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
@Get('report') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
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 |
|||
); |
|||
|
|||
let report = await portfolio.getReport(); |
|||
|
|||
if ( |
|||
impersonationId && |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.readForeignPortfolio |
|||
) |
|||
) { |
|||
// TODO: Filter out absolute numbers
|
|||
} |
|||
|
|||
return report; |
|||
} |
|||
} |
@ -0,0 +1,38 @@ |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { DataGatheringService } from '../../services/data-gathering.service'; |
|||
import { DataProviderService } from '../../services/data-provider.service'; |
|||
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service'; |
|||
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; |
|||
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service'; |
|||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service'; |
|||
import { ImpersonationService } from '../../services/impersonation.service'; |
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { RulesService } from '../../services/rules.service'; |
|||
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'; |
|||
|
|||
@Module({ |
|||
imports: [RedisCacheModule], |
|||
controllers: [PortfolioController], |
|||
providers: [ |
|||
AlphaVantageService, |
|||
CacheService, |
|||
DataGatheringService, |
|||
DataProviderService, |
|||
ExchangeRateDataService, |
|||
ImpersonationService, |
|||
OrderService, |
|||
PortfolioService, |
|||
PrismaService, |
|||
RakutenRapidApiService, |
|||
RulesService, |
|||
UserService, |
|||
YahooFinanceService |
|||
] |
|||
}) |
|||
export class PortfolioModule {} |
@ -0,0 +1,385 @@ |
|||
import { Inject, Injectable } from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type'; |
|||
import { |
|||
add, |
|||
format, |
|||
getDate, |
|||
getMonth, |
|||
getYear, |
|||
isAfter, |
|||
isSameDay, |
|||
parse, |
|||
parseISO, |
|||
setDate, |
|||
setMonth, |
|||
sub |
|||
} from 'date-fns'; |
|||
import { isEmpty } from 'lodash'; |
|||
import * as roundTo from 'round-to'; |
|||
|
|||
import { Portfolio } from '../../models/portfolio'; |
|||
import { DataProviderService } from '../../services/data-provider.service'; |
|||
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service'; |
|||
import { ImpersonationService } from '../../services/impersonation.service'; |
|||
import { IOrder } from '../../services/interfaces/interfaces'; |
|||
import { RulesService } from '../../services/rules.service'; |
|||
import { OrderService } from '../order/order.service'; |
|||
import { RedisCacheService } from '../redis-cache/redis-cache.service'; |
|||
import { UserService } from '../user/user.service'; |
|||
import { DateRange } from './interfaces/date-range.type'; |
|||
import { PortfolioItem } from './interfaces/portfolio-item.interface'; |
|||
import { PortfolioOverview } from './interfaces/portfolio-overview.interface'; |
|||
import { |
|||
HistoricalDataItem, |
|||
PortfolioPositionDetail |
|||
} from './interfaces/portfolio-position-detail.interface'; |
|||
|
|||
@Injectable() |
|||
export class PortfolioService { |
|||
public constructor( |
|||
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 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; |
|||
} |
|||
} |
|||
|
|||
public async createPortfolio(aUserId: string): Promise<Portfolio> { |
|||
let portfolio: Portfolio; |
|||
let stringifiedPortfolio = await this.redisCacheService.get( |
|||
`${aUserId}.portfolio` |
|||
); |
|||
|
|||
const user = await this.userService.user({ id: aUserId }); |
|||
|
|||
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: { |
|||
Platform: 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() |
|||
}; |
|||
|
|||
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 portfolio = await this.createPortfolio( |
|||
impersonationUserId || this.request.user.id |
|||
); |
|||
|
|||
if (portfolio.getOrders().length <= 0) { |
|||
return []; |
|||
} |
|||
|
|||
const dateRangeDate = this.convertDateRangeToDate( |
|||
aDateRange, |
|||
portfolio.getMinDate() |
|||
); |
|||
|
|||
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 || null |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public async getOverview( |
|||
aImpersonationId: string |
|||
): Promise<PortfolioOverview> { |
|||
const impersonationUserId = await this.impersonationService.validateImpersonationId( |
|||
aImpersonationId, |
|||
this.request.user.id |
|||
); |
|||
|
|||
const portfolio = await this.createPortfolio( |
|||
impersonationUserId || this.request.user.id |
|||
); |
|||
|
|||
const committedFunds = portfolio.getCommittedFunds(); |
|||
const fees = portfolio.getFees(); |
|||
|
|||
return { |
|||
committedFunds, |
|||
fees, |
|||
ordersCount: portfolio.getOrders().length, |
|||
totalBuy: portfolio.getTotalBuy(), |
|||
totalSell: portfolio.getTotalSell() |
|||
}; |
|||
} |
|||
|
|||
public async getPosition( |
|||
aImpersonationId: string, |
|||
aSymbol: string |
|||
): Promise<PortfolioPositionDetail> { |
|||
const impersonationUserId = await this.impersonationService.validateImpersonationId( |
|||
aImpersonationId, |
|||
this.request.user.id |
|||
); |
|||
|
|||
const portfolio = await this.createPortfolio( |
|||
impersonationUserId || this.request.user.id |
|||
); |
|||
|
|||
const positions = portfolio.getPositions(new Date())[aSymbol]; |
|||
|
|||
if (positions) { |
|||
let { |
|||
averagePrice, |
|||
currency, |
|||
firstBuyDate, |
|||
investment, |
|||
marketPrice, |
|||
quantity |
|||
} = portfolio.getPositions(new Date())[aSymbol]; |
|||
|
|||
const historicalData = await this.dataProviderService.getHistorical( |
|||
[aSymbol], |
|||
'day', |
|||
parseISO(firstBuyDate), |
|||
new Date() |
|||
); |
|||
|
|||
if (marketPrice === 0) { |
|||
marketPrice = averagePrice; |
|||
} |
|||
|
|||
const historicalDataArray: HistoricalDataItem[] = []; |
|||
let maxPrice = marketPrice; |
|||
let minPrice = marketPrice; |
|||
|
|||
if (historicalData[aSymbol]) { |
|||
for (const [date, { marketPrice }] of Object.entries( |
|||
historicalData[aSymbol] |
|||
)) { |
|||
historicalDataArray.push({ |
|||
averagePrice, |
|||
date, |
|||
value: marketPrice |
|||
}); |
|||
|
|||
if ( |
|||
marketPrice && |
|||
(marketPrice > maxPrice || maxPrice === undefined) |
|||
) { |
|||
maxPrice = marketPrice; |
|||
} |
|||
|
|||
if ( |
|||
marketPrice && |
|||
(marketPrice < minPrice || minPrice === undefined) |
|||
) { |
|||
minPrice = marketPrice; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return { |
|||
averagePrice, |
|||
currency, |
|||
firstBuyDate, |
|||
investment, |
|||
marketPrice, |
|||
maxPrice, |
|||
minPrice, |
|||
quantity, |
|||
grossPerformance: this.exchangeRateDataService.toCurrency( |
|||
marketPrice - averagePrice, |
|||
currency, |
|||
this.request.user.Settings.currency |
|||
), |
|||
grossPerformancePercent: roundTo( |
|||
(marketPrice - averagePrice) / averagePrice, |
|||
4 |
|||
), |
|||
historicalData: historicalDataArray, |
|||
symbol: aSymbol |
|||
}; |
|||
} else if (portfolio.getMinDate()) { |
|||
const currentData = await this.dataProviderService.get([aSymbol]); |
|||
|
|||
let historicalData = await this.dataProviderService.getHistorical( |
|||
[aSymbol], |
|||
'day', |
|||
portfolio.getMinDate(), |
|||
new Date() |
|||
); |
|||
|
|||
if (isEmpty(historicalData)) { |
|||
historicalData = await this.dataProviderService.getHistoricalRaw( |
|||
[aSymbol], |
|||
portfolio.getMinDate(), |
|||
new Date() |
|||
); |
|||
} |
|||
|
|||
const historicalDataArray: HistoricalDataItem[] = []; |
|||
|
|||
for (const [date, { marketPrice, performance }] of Object.entries( |
|||
historicalData[aSymbol] |
|||
).reverse()) { |
|||
historicalDataArray.push({ |
|||
date, |
|||
value: marketPrice |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
averagePrice: undefined, |
|||
currency: currentData[aSymbol].currency, |
|||
firstBuyDate: undefined, |
|||
grossPerformance: undefined, |
|||
grossPerformancePercent: undefined, |
|||
historicalData: historicalDataArray, |
|||
investment: undefined, |
|||
marketPrice: currentData[aSymbol].marketPrice, |
|||
maxPrice: undefined, |
|||
minPrice: undefined, |
|||
quantity: undefined, |
|||
symbol: aSymbol |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
averagePrice: undefined, |
|||
currency: undefined, |
|||
firstBuyDate: undefined, |
|||
grossPerformance: undefined, |
|||
grossPerformancePercent: undefined, |
|||
historicalData: [], |
|||
investment: undefined, |
|||
marketPrice: undefined, |
|||
maxPrice: undefined, |
|||
minPrice: undefined, |
|||
quantity: undefined, |
|||
symbol: aSymbol |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,24 @@ |
|||
import { CacheModule, Module } from '@nestjs/common'; |
|||
import { ConfigModule, ConfigService } from '@nestjs/config'; |
|||
import * as redisStore from 'cache-manager-redis-store'; |
|||
|
|||
import { RedisCacheService } from './redis-cache.service'; |
|||
|
|||
@Module({ |
|||
imports: [ |
|||
CacheModule.registerAsync({ |
|||
imports: [ConfigModule], |
|||
inject: [ConfigService], |
|||
useFactory: async (configService: ConfigService) => ({ |
|||
host: configService.get('REDIS_HOST'), |
|||
max: configService.get('MAX_ITEM_IN_CACHE'), |
|||
port: configService.get('REDIS_PORT'), |
|||
store: redisStore, |
|||
ttl: configService.get('CACHE_TTL') |
|||
}) |
|||
}) |
|||
], |
|||
providers: [RedisCacheService], |
|||
exports: [RedisCacheService] |
|||
}) |
|||
export class RedisCacheModule {} |
@ -0,0 +1,23 @@ |
|||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; |
|||
import { Cache } from 'cache-manager'; |
|||
|
|||
@Injectable() |
|||
export class RedisCacheService { |
|||
public constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {} |
|||
|
|||
public async get(key: string): Promise<string> { |
|||
return await this.cache.get(key); |
|||
} |
|||
|
|||
public async remove(key: string) { |
|||
await this.cache.del(key); |
|||
} |
|||
|
|||
public async reset() { |
|||
await this.cache.reset(); |
|||
} |
|||
|
|||
public async set(key: string, value: string) { |
|||
await this.cache.set(key, value, { ttl: Number(process.env.CACHE_TTL) }); |
|||
} |
|||
} |
@ -0,0 +1,4 @@ |
|||
export interface LookupItem { |
|||
name: string; |
|||
symbol: string; |
|||
} |
@ -0,0 +1,6 @@ |
|||
import { Currency } from '@prisma/client'; |
|||
|
|||
export interface SymbolItem { |
|||
currency: Currency; |
|||
marketPrice: number; |
|||
} |
@ -0,0 +1,50 @@ |
|||
import { |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Query, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { LookupItem } from './interfaces/lookup-item.interface'; |
|||
import { SymbolItem } from './interfaces/symbol-item.interface'; |
|||
import { SymbolService } from './symbol.service'; |
|||
|
|||
@Controller('symbol') |
|||
export class SymbolController { |
|||
public constructor( |
|||
private readonly symbolService: SymbolService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
/** |
|||
* Must be before /:symbol |
|||
*/ |
|||
@Get('lookup') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async lookupSymbol(@Query() { query }): Promise<LookupItem[]> { |
|||
try { |
|||
return this.symbolService.lookup(query); |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Must be after /lookup |
|||
*/ |
|||
@Get(':symbol') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> { |
|||
return this.symbolService.get(symbol); |
|||
} |
|||
} |
@ -0,0 +1,23 @@ |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { DataProviderService } from '../../services/data-provider.service'; |
|||
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service'; |
|||
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; |
|||
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service'; |
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { SymbolController } from './symbol.controller'; |
|||
import { SymbolService } from './symbol.service'; |
|||
|
|||
@Module({ |
|||
imports: [], |
|||
controllers: [SymbolController], |
|||
providers: [ |
|||
AlphaVantageService, |
|||
DataProviderService, |
|||
PrismaService, |
|||
RakutenRapidApiService, |
|||
SymbolService, |
|||
YahooFinanceService |
|||
] |
|||
}) |
|||
export class SymbolModule {} |
@ -0,0 +1,68 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Currency } from '@prisma/client'; |
|||
import { convertFromYahooSymbol } from 'apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service'; |
|||
import * as bent from 'bent'; |
|||
|
|||
import { DataProviderService } from '../../services/data-provider.service'; |
|||
import { LookupItem } from './interfaces/lookup-item.interface'; |
|||
import { SymbolItem } from './interfaces/symbol-item.interface'; |
|||
|
|||
@Injectable() |
|||
export class SymbolService { |
|||
public constructor( |
|||
private readonly dataProviderService: DataProviderService |
|||
) {} |
|||
|
|||
public async get(aSymbol: string): Promise<SymbolItem> { |
|||
const response = await this.dataProviderService.get([aSymbol]); |
|||
const { currency, marketPrice } = response[aSymbol]; |
|||
|
|||
return { |
|||
marketPrice, |
|||
currency: <Currency>(<unknown>currency) |
|||
}; |
|||
} |
|||
|
|||
public async lookup(aQuery: string): Promise<LookupItem[]> { |
|||
const get = bent( |
|||
`https://query1.finance.yahoo.com/v1/finance/search?q=${aQuery}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`, |
|||
'GET', |
|||
'json', |
|||
200 |
|||
); |
|||
|
|||
try { |
|||
const { quotes } = await get(); |
|||
|
|||
return quotes |
|||
.filter(({ isYahooFinance }) => { |
|||
return isYahooFinance; |
|||
}) |
|||
.filter(({ quoteType }) => { |
|||
return ( |
|||
quoteType === 'CRYPTOCURRENCY' || |
|||
quoteType === 'EQUITY' || |
|||
quoteType === 'ETF' |
|||
); |
|||
}) |
|||
.filter(({ quoteType, symbol }) => { |
|||
if (quoteType === 'CRYPTOCURRENCY') { |
|||
// Only allow cryptocurrencies in USD
|
|||
return symbol.includes('USD'); |
|||
} |
|||
|
|||
return true; |
|||
}) |
|||
.map(({ longname, shortname, symbol }) => { |
|||
return { |
|||
name: longname || shortname, |
|||
symbol: convertFromYahooSymbol(symbol) |
|||
}; |
|||
}); |
|||
} catch (error) { |
|||
console.error(error); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,4 @@ |
|||
export interface Access { |
|||
alias?: string; |
|||
id: string; |
|||
} |
@ -0,0 +1,4 @@ |
|||
export interface UserItem { |
|||
accessToken?: string; |
|||
authToken: string; |
|||
} |
@ -0,0 +1,20 @@ |
|||
import { Currency } from '@prisma/client'; |
|||
|
|||
import { Access } from './access.interface'; |
|||
|
|||
export interface User { |
|||
access: Access[]; |
|||
alias?: string; |
|||
id: string; |
|||
permissions: string[]; |
|||
settings: UserSettings; |
|||
subscription: { |
|||
expiresAt: Date; |
|||
type: 'Diamond'; |
|||
}; |
|||
} |
|||
|
|||
export interface UserSettings { |
|||
baseCurrency: Currency; |
|||
locale: string; |
|||
} |
@ -0,0 +1,7 @@ |
|||
import { Currency } from '@prisma/client'; |
|||
import { IsString } from 'class-validator'; |
|||
|
|||
export class UpdateUserSettingsDto { |
|||
@IsString() |
|||
currency: Currency; |
|||
} |
@ -0,0 +1,73 @@ |
|||
import { |
|||
Body, |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
Put, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { JwtService } from '@nestjs/jwt'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { Provider } from '@prisma/client'; |
|||
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
import { getPermissions, hasPermission, permissions } from 'libs/helper/src'; |
|||
|
|||
import { UserItem } from './interfaces/user-item.interface'; |
|||
import { User } from './interfaces/user.interface'; |
|||
import { UpdateUserSettingsDto } from './update-user-settings.dto'; |
|||
import { UserService } from './user.service'; |
|||
|
|||
@Controller('user') |
|||
export class UserController { |
|||
public constructor( |
|||
private jwtService: JwtService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser, |
|||
private readonly userService: UserService |
|||
) {} |
|||
|
|||
@Get() |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getUser(@Param('id') id: string): Promise<User> { |
|||
return this.userService.getUser(this.request.user); |
|||
} |
|||
|
|||
@Post() |
|||
public async signupUser(): Promise<UserItem> { |
|||
const { accessToken, id } = await this.userService.createUser({ |
|||
provider: Provider.ANONYMOUS |
|||
}); |
|||
|
|||
return { |
|||
accessToken, |
|||
authToken: this.jwtService.sign({ |
|||
id |
|||
}) |
|||
}; |
|||
} |
|||
|
|||
@Put('settings') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) { |
|||
if ( |
|||
!hasPermission( |
|||
getPermissions(this.request.user.role), |
|||
permissions.updateUserSettings |
|||
) |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return await this.userService.updateUserSettings({ |
|||
currency: data.currency, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,18 @@ |
|||
import { Module } from '@nestjs/common'; |
|||
import { JwtModule } from '@nestjs/jwt'; |
|||
|
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { UserController } from './user.controller'; |
|||
import { UserService } from './user.service'; |
|||
|
|||
@Module({ |
|||
imports: [ |
|||
JwtModule.register({ |
|||
secret: process.env.JWT_SECRET_KEY, |
|||
signOptions: { expiresIn: '30 days' } |
|||
}) |
|||
], |
|||
controllers: [UserController], |
|||
providers: [PrismaService, UserService] |
|||
}) |
|||
export class UserModule {} |
@ -0,0 +1,185 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Currency, Prisma, Provider, User } from '@prisma/client'; |
|||
import { add } from 'date-fns'; |
|||
import { locale, resetHours } from 'libs/helper/src'; |
|||
import { getPermissions } from 'libs/helper/src'; |
|||
|
|||
import { PrismaService } from '../../services/prisma.service'; |
|||
import { UserWithSettings } from '../interfaces/user-with-settings'; |
|||
import { User as IUser } from './interfaces/user.interface'; |
|||
|
|||
const crypto = require('crypto'); |
|||
|
|||
@Injectable() |
|||
export class UserService { |
|||
public static DEFAULT_CURRENCY = Currency.USD; |
|||
|
|||
public constructor(private prisma: PrismaService) {} |
|||
|
|||
public async getUser({ |
|||
alias, |
|||
id, |
|||
role, |
|||
Settings |
|||
}: UserWithSettings): Promise<IUser> { |
|||
const access = await this.prisma.access.findMany({ |
|||
include: { |
|||
User: true |
|||
}, |
|||
orderBy: { User: { alias: 'asc' } }, |
|||
where: { GranteeUser: { id } } |
|||
}); |
|||
|
|||
return { |
|||
alias, |
|||
id, |
|||
access: access.map((accessItem) => { |
|||
return { |
|||
alias: accessItem.User.alias, |
|||
id: accessItem.id |
|||
}; |
|||
}), |
|||
permissions: getPermissions(role), |
|||
settings: { |
|||
baseCurrency: Settings?.currency || UserService.DEFAULT_CURRENCY, |
|||
locale |
|||
}, |
|||
subscription: { |
|||
expiresAt: resetHours(add(new Date(), { days: 7 })), |
|||
type: 'Diamond' |
|||
} |
|||
}; |
|||
} |
|||
|
|||
public async user( |
|||
userWhereUniqueInput: Prisma.UserWhereUniqueInput |
|||
): Promise<UserWithSettings | null> { |
|||
const user = await this.prisma.user.findUnique({ |
|||
include: { Settings: true }, |
|||
where: userWhereUniqueInput |
|||
}); |
|||
|
|||
if (user?.Settings) { |
|||
if (!user.Settings.currency) { |
|||
// Set default currency if needed
|
|||
user.Settings.currency = UserService.DEFAULT_CURRENCY; |
|||
} |
|||
} else if (user) { |
|||
// Set default settings if needed
|
|||
user.Settings = { |
|||
currency: UserService.DEFAULT_CURRENCY, |
|||
updatedAt: new Date(), |
|||
userId: user?.id |
|||
}; |
|||
} |
|||
|
|||
return user; |
|||
} |
|||
|
|||
public async users(params: { |
|||
skip?: number; |
|||
take?: number; |
|||
cursor?: Prisma.UserWhereUniqueInput; |
|||
where?: Prisma.UserWhereInput; |
|||
orderBy?: Prisma.UserOrderByInput; |
|||
}): Promise<User[]> { |
|||
const { skip, take, cursor, where, orderBy } = params; |
|||
return this.prisma.user.findMany({ |
|||
skip, |
|||
take, |
|||
cursor, |
|||
where, |
|||
orderBy |
|||
}); |
|||
} |
|||
|
|||
public createAccessToken(password: string, salt: string): string { |
|||
const hash = crypto.createHmac('sha512', salt); |
|||
hash.update(password); |
|||
|
|||
return hash.digest('hex'); |
|||
} |
|||
|
|||
public async createUser(data?: Prisma.UserCreateInput): Promise<User> { |
|||
let user = await this.prisma.user.create({ |
|||
data |
|||
}); |
|||
|
|||
if (data.provider === Provider.ANONYMOUS) { |
|||
const accessToken = this.createAccessToken( |
|||
user.id, |
|||
this.getRandomString(10) |
|||
); |
|||
|
|||
const hashedAccessToken = this.createAccessToken( |
|||
accessToken, |
|||
process.env.ACCESS_TOKEN_SALT |
|||
); |
|||
|
|||
user = await this.prisma.user.update({ |
|||
data: { accessToken: hashedAccessToken }, |
|||
where: { id: user.id } |
|||
}); |
|||
|
|||
return { ...user, accessToken }; |
|||
} |
|||
|
|||
return user; |
|||
} |
|||
|
|||
public async updateUser(params: { |
|||
where: Prisma.UserWhereUniqueInput; |
|||
data: Prisma.UserUpdateInput; |
|||
}): Promise<User> { |
|||
const { where, data } = params; |
|||
return this.prisma.user.update({ |
|||
data, |
|||
where |
|||
}); |
|||
} |
|||
|
|||
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> { |
|||
return this.prisma.user.delete({ |
|||
where |
|||
}); |
|||
} |
|||
|
|||
private getRandomString(length: number) { |
|||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; |
|||
const result = []; |
|||
|
|||
for (let i = 0; i < length; i++) { |
|||
result.push( |
|||
characters.charAt(Math.floor(Math.random() * characters.length)) |
|||
); |
|||
} |
|||
return result.join(''); |
|||
} |
|||
|
|||
public async updateUserSettings({ |
|||
currency, |
|||
userId |
|||
}: { |
|||
currency: Currency; |
|||
userId: string; |
|||
}) { |
|||
await this.prisma.settings.upsert({ |
|||
create: { |
|||
currency, |
|||
User: { |
|||
connect: { |
|||
id: userId |
|||
} |
|||
} |
|||
}, |
|||
update: { |
|||
currency |
|||
}, |
|||
where: { |
|||
userId: userId |
|||
} |
|||
}); |
|||
|
|||
return; |
|||
} |
|||
} |
@ -0,0 +1,3 @@ |
|||
export const environment = { |
|||
production: true |
|||
}; |
@ -0,0 +1,3 @@ |
|||
export const environment = { |
|||
production: false |
|||
}; |
@ -0,0 +1,29 @@ |
|||
import { cloneDeep, isObject } from 'lodash'; |
|||
|
|||
export function hasNotDefinedValuesInObject(aObject: Object): boolean { |
|||
for (const key in aObject) { |
|||
if (aObject[key] === null || aObject[key] === null) { |
|||
return true; |
|||
} else if (isObject(aObject[key])) { |
|||
return hasNotDefinedValuesInObject(aObject[key]); |
|||
} |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T { |
|||
const object = cloneDeep(aObject); |
|||
|
|||
keys.forEach((key) => { |
|||
object[key] = null; |
|||
}); |
|||
|
|||
return object; |
|||
} |
|||
|
|||
export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] { |
|||
return aObjects.map((object) => { |
|||
return nullifyValuesInObject(object, keys); |
|||
}); |
|||
} |
@ -0,0 +1,25 @@ |
|||
import { Logger, ValidationPipe } from '@nestjs/common'; |
|||
import { NestFactory } from '@nestjs/core'; |
|||
|
|||
import { AppModule } from './app/app.module'; |
|||
|
|||
async function bootstrap() { |
|||
const app = await NestFactory.create(AppModule); |
|||
app.enableCors(); |
|||
const globalPrefix = 'api'; |
|||
app.setGlobalPrefix(globalPrefix); |
|||
app.useGlobalPipes( |
|||
new ValidationPipe({ |
|||
forbidNonWhitelisted: true, |
|||
transform: true, |
|||
whitelist: true |
|||
}) |
|||
); |
|||
|
|||
const port = process.env.PORT || 3333; |
|||
await app.listen(port, () => { |
|||
Logger.log(`Listening at http://localhost:${port}`); |
|||
}); |
|||
} |
|||
|
|||
bootstrap(); |
@ -0,0 +1,4 @@ |
|||
export interface EvaluationResult { |
|||
evaluation: string; |
|||
value: boolean; |
|||
} |
@ -0,0 +1,30 @@ |
|||
import { |
|||
PortfolioItem, |
|||
Position |
|||
} from 'apps/api/src/app/portfolio/interfaces/portfolio-item.interface'; |
|||
|
|||
import { Order } from '../order'; |
|||
|
|||
export interface PortfolioInterface { |
|||
get(aDate?: Date): PortfolioItem[]; |
|||
|
|||
getCommittedFunds(): number; |
|||
|
|||
getFees(): number; |
|||
|
|||
getPositions( |
|||
aDate: Date |
|||
): { |
|||
[symbol: string]: Position; |
|||
}; |
|||
|
|||
getSymbols(aDate?: Date): string[]; |
|||
|
|||
getTotalBuy(): number; |
|||
|
|||
getTotalSell(): number; |
|||
|
|||
getOrders(): Order[]; |
|||
|
|||
getValue(aDate?: Date): number; |
|||
} |
@ -0,0 +1,14 @@ |
|||
import { PortfolioPosition } from '../../app/portfolio/interfaces/portfolio-position.interface'; |
|||
import { EvaluationResult } from './evaluation-result.interface'; |
|||
|
|||
export interface RuleInterface { |
|||
evaluate( |
|||
aPortfolioPositionMap: { |
|||
[symbol: string]: PortfolioPosition; |
|||
}, |
|||
aFees: number, |
|||
aRuleSettingsMap: { |
|||
[key: string]: any; |
|||
} |
|||
): EvaluationResult; |
|||
} |
@ -0,0 +1,8 @@ |
|||
export enum OrderType { |
|||
CorporateAction = 'CORPORATE_ACTION', |
|||
Bonus = 'BONUS', |
|||
Buy = 'BUY', |
|||
Dividend = 'DIVIDEND', |
|||
Sell = 'SELL', |
|||
Split = 'SPLIT' |
|||
} |
@ -0,0 +1,72 @@ |
|||
import { Currency, Platform } from '@prisma/client'; |
|||
import { v4 as uuidv4 } from 'uuid'; |
|||
|
|||
import { IOrder } from '../services/interfaces/interfaces'; |
|||
import { OrderType } from './order-type'; |
|||
|
|||
export class Order { |
|||
private currency: Currency; |
|||
private fee: number; |
|||
private date: string; |
|||
private id: string; |
|||
private quantity: number; |
|||
private platform: Platform; |
|||
private symbol: string; |
|||
private total: number; |
|||
private type: OrderType; |
|||
private unitPrice: number; |
|||
|
|||
public constructor(data: IOrder) { |
|||
this.currency = data.currency; |
|||
this.fee = data.fee; |
|||
this.date = data.date; |
|||
this.id = data.id || uuidv4(); |
|||
this.platform = data.platform; |
|||
this.quantity = data.quantity; |
|||
this.symbol = data.symbol; |
|||
this.type = data.type; |
|||
this.unitPrice = data.unitPrice; |
|||
|
|||
this.total = this.quantity * data.unitPrice; |
|||
} |
|||
|
|||
public getCurrency() { |
|||
return this.currency; |
|||
} |
|||
|
|||
public getDate() { |
|||
return this.date; |
|||
} |
|||
|
|||
public getFee() { |
|||
return this.fee; |
|||
} |
|||
|
|||
public getId() { |
|||
return this.id; |
|||
} |
|||
|
|||
public getPlatform() { |
|||
return this.platform; |
|||
} |
|||
|
|||
public getQuantity() { |
|||
return this.quantity; |
|||
} |
|||
|
|||
public getSymbol() { |
|||
return this.symbol; |
|||
} |
|||
|
|||
public getTotal() { |
|||
return this.total; |
|||
} |
|||
|
|||
public getType() { |
|||
return this.type; |
|||
} |
|||
|
|||
public getUnitPrice() { |
|||
return this.unitPrice; |
|||
} |
|||
} |
@ -0,0 +1,558 @@ |
|||
import { Test } from '@nestjs/testing'; |
|||
import { Currency, Role, Type } from '@prisma/client'; |
|||
import { baseCurrency } from 'libs/helper/src'; |
|||
import { getYesterday } from 'libs/helper/src'; |
|||
import { getUtc } from 'libs/helper/src'; |
|||
|
|||
import { DataProviderService } from '../services/data-provider.service'; |
|||
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service'; |
|||
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; |
|||
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service'; |
|||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; |
|||
import { PrismaService } from '../services/prisma.service'; |
|||
import { RulesService } from '../services/rules.service'; |
|||
import { Portfolio } from './portfolio'; |
|||
|
|||
describe('Portfolio', () => { |
|||
let alphaVantageService: AlphaVantageService; |
|||
let dataProviderService: DataProviderService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let portfolio: Portfolio; |
|||
let prismaService: PrismaService; |
|||
let rakutenRapidApiService: RakutenRapidApiService; |
|||
let rulesService: RulesService; |
|||
let yahooFinanceService: YahooFinanceService; |
|||
|
|||
beforeAll(async () => { |
|||
const app = await Test.createTestingModule({ |
|||
imports: [], |
|||
providers: [ |
|||
AlphaVantageService, |
|||
DataProviderService, |
|||
ExchangeRateDataService, |
|||
PrismaService, |
|||
RakutenRapidApiService, |
|||
RulesService, |
|||
YahooFinanceService |
|||
] |
|||
}).compile(); |
|||
|
|||
alphaVantageService = app.get<AlphaVantageService>(AlphaVantageService); |
|||
dataProviderService = app.get<DataProviderService>(DataProviderService); |
|||
exchangeRateDataService = app.get<ExchangeRateDataService>( |
|||
ExchangeRateDataService |
|||
); |
|||
prismaService = app.get<PrismaService>(PrismaService); |
|||
rakutenRapidApiService = app.get<RakutenRapidApiService>( |
|||
RakutenRapidApiService |
|||
); |
|||
rulesService = app.get<RulesService>(RulesService); |
|||
yahooFinanceService = app.get<YahooFinanceService>(YahooFinanceService); |
|||
|
|||
await exchangeRateDataService.initialize(); |
|||
|
|||
portfolio = new Portfolio( |
|||
dataProviderService, |
|||
exchangeRateDataService, |
|||
rulesService |
|||
); |
|||
portfolio.setUser({ |
|||
accessToken: null, |
|||
alias: 'Test', |
|||
createdAt: new Date(), |
|||
id: '', |
|||
provider: null, |
|||
role: Role.USER, |
|||
Settings: { |
|||
currency: Currency.CHF, |
|||
updatedAt: new Date(), |
|||
userId: '' |
|||
}, |
|||
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([ |
|||
{ |
|||
createdAt: null, |
|||
currency: Currency.USD, |
|||
fee: 0, |
|||
date: new Date(), |
|||
id: '8d999347-dee2-46ee-88e1-26b344e71fcc', |
|||
platformId: null, |
|||
quantity: 1, |
|||
symbol: 'BTCUSD', |
|||
type: Type.BUY, |
|||
unitPrice: 49631.24, |
|||
updatedAt: null, |
|||
userId: null |
|||
} |
|||
]); |
|||
|
|||
expect(portfolio.getCommittedFunds()).toEqual( |
|||
exchangeRateDataService.toCurrency( |
|||
1 * 49631.24, |
|||
Currency.USD, |
|||
baseCurrency |
|||
) |
|||
); |
|||
|
|||
const details = await portfolio.getDetails('1d'); |
|||
expect(details).toMatchObject({ |
|||
BTCUSD: { |
|||
currency: Currency.USD, |
|||
exchange: 'Other', |
|||
grossPerformance: 0, |
|||
grossPerformancePercent: 0, |
|||
investment: exchangeRateDataService.toCurrency( |
|||
1 * 49631.24, |
|||
Currency.USD, |
|||
baseCurrency |
|||
), |
|||
isMarketOpen: true, |
|||
// marketPrice: 57973.008,
|
|||
name: 'Bitcoin USD', |
|||
platforms: { |
|||
Other: { |
|||
/*current: exchangeRateDataService.toCurrency( |
|||
1 * 49631.24, |
|||
Currency.USD, |
|||
baseCurrency |
|||
),*/ |
|||
original: exchangeRateDataService.toCurrency( |
|||
1 * 49631.24, |
|||
Currency.USD, |
|||
baseCurrency |
|||
) |
|||
} |
|||
}, |
|||
quantity: 1, |
|||
// shareCurrent: 0.9999999559148652,
|
|||
shareInvestment: 1, |
|||
symbol: 'BTCUSD', |
|||
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([ |
|||
{ |
|||
createdAt: null, |
|||
currency: Currency.USD, |
|||
fee: 0, |
|||
date: new Date(getUtc('2018-01-05')), |
|||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', |
|||
platformId: null, |
|||
quantity: 0.2, |
|||
symbol: 'ETHUSD', |
|||
type: Type.BUY, |
|||
unitPrice: 991.49, |
|||
updatedAt: null, |
|||
userId: null |
|||
} |
|||
]); |
|||
|
|||
expect(portfolio.getCommittedFunds()).toEqual( |
|||
exchangeRateDataService.toCurrency( |
|||
0.2 * 991.49, |
|||
Currency.USD, |
|||
baseCurrency |
|||
) |
|||
); |
|||
|
|||
const details = await portfolio.getDetails('1d'); |
|||
expect(details).toMatchObject({ |
|||
ETHUSD: { |
|||
currency: Currency.USD, |
|||
exchange: 'Other', |
|||
// grossPerformance: 0,
|
|||
// grossPerformancePercent: 0,
|
|||
investment: exchangeRateDataService.toCurrency( |
|||
0.2 * 991.49, |
|||
Currency.USD, |
|||
baseCurrency |
|||
), |
|||
// marketPrice: 57973.008,
|
|||
name: 'Ethereum USD', |
|||
platforms: { |
|||
Other: { |
|||
/*current: exchangeRateDataService.toCurrency( |
|||
0.2 * 991.49, |
|||
Currency.USD, |
|||
baseCurrency |
|||
),*/ |
|||
original: exchangeRateDataService.toCurrency( |
|||
0.2 * 991.49, |
|||
Currency.USD, |
|||
baseCurrency |
|||
) |
|||
} |
|||
}, |
|||
quantity: 0.2, |
|||
shareCurrent: 1, |
|||
shareInvestment: 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: 0,
|
|||
quantity: 0.2 |
|||
} |
|||
}); |
|||
|
|||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']); |
|||
}); |
|||
|
|||
it('should return ["ETHUSD"]', async () => { |
|||
await portfolio.setOrders([ |
|||
{ |
|||
createdAt: null, |
|||
currency: Currency.USD, |
|||
fee: 0, |
|||
date: new Date(getUtc('2018-01-05')), |
|||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', |
|||
platformId: null, |
|||
quantity: 0.2, |
|||
symbol: 'ETHUSD', |
|||
type: Type.BUY, |
|||
unitPrice: 991.49, |
|||
updatedAt: null, |
|||
userId: null |
|||
}, |
|||
{ |
|||
createdAt: null, |
|||
currency: Currency.USD, |
|||
fee: 0, |
|||
date: new Date(getUtc('2018-01-28')), |
|||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', |
|||
platformId: null, |
|||
quantity: 0.3, |
|||
symbol: 'ETHUSD', |
|||
type: Type.BUY, |
|||
unitPrice: 1050, |
|||
updatedAt: null, |
|||
userId: null |
|||
} |
|||
]); |
|||
|
|||
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: 0,
|
|||
quantity: 0.5 |
|||
} |
|||
}); |
|||
|
|||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']); |
|||
}); |
|||
|
|||
it('should return ["BTCUSD", "ETHUSD"]', async () => { |
|||
await portfolio.setOrders([ |
|||
{ |
|||
createdAt: null, |
|||
currency: Currency.EUR, |
|||
date: new Date(getUtc('2017-08-16')), |
|||
fee: 2.99, |
|||
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475', |
|||
platformId: null, |
|||
quantity: 0.05614682, |
|||
symbol: 'BTCUSD', |
|||
type: Type.BUY, |
|||
unitPrice: 3562.089535970158, |
|||
updatedAt: null, |
|||
userId: null |
|||
}, |
|||
{ |
|||
createdAt: null, |
|||
currency: Currency.USD, |
|||
fee: 2.99, |
|||
date: new Date(getUtc('2018-01-05')), |
|||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', |
|||
platformId: null, |
|||
quantity: 0.2, |
|||
symbol: 'ETHUSD', |
|||
type: Type.BUY, |
|||
unitPrice: 991.49, |
|||
updatedAt: null, |
|||
userId: null |
|||
} |
|||
]); |
|||
|
|||
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([ |
|||
{ |
|||
createdAt: null, |
|||
currency: Currency.USD, |
|||
fee: 1.0, |
|||
date: new Date(getUtc('2018-01-05')), |
|||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', |
|||
platformId: null, |
|||
quantity: 0.2, |
|||
symbol: 'ETHUSD', |
|||
type: Type.BUY, |
|||
unitPrice: 991.49, |
|||
updatedAt: null, |
|||
userId: null |
|||
}, |
|||
{ |
|||
createdAt: null, |
|||
currency: Currency.USD, |
|||
fee: 1.0, |
|||
date: new Date(getUtc('2018-01-28')), |
|||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', |
|||
platformId: null, |
|||
quantity: 0.1, |
|||
symbol: 'ETHUSD', |
|||
type: Type.SELL, |
|||
unitPrice: 1050, |
|||
updatedAt: null, |
|||
userId: null |
|||
}, |
|||
{ |
|||
createdAt: null, |
|||
currency: Currency.USD, |
|||
fee: 1.0, |
|||
date: new Date(getUtc('2018-01-31')), |
|||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', |
|||
platformId: null, |
|||
quantity: 0.2, |
|||
symbol: 'ETHUSD', |
|||
type: Type.BUY, |
|||
unitPrice: 1050, |
|||
updatedAt: null, |
|||
userId: null |
|||
} |
|||
]); |
|||
|
|||
// TODO: Fix
|
|||
/*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', |
|||
// TODO: Fix
|
|||
/*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']); |
|||
}); |
|||
}); |
|||
|
|||
afterAll(async () => { |
|||
prismaService.$disconnect(); |
|||
}); |
|||
}); |
@ -0,0 +1,820 @@ |
|||
import { |
|||
PortfolioItem, |
|||
Position |
|||
} from 'apps/api/src/app/portfolio/interfaces/portfolio-item.interface'; |
|||
import { |
|||
add, |
|||
format, |
|||
getDate, |
|||
getMonth, |
|||
getYear, |
|||
isAfter, |
|||
isBefore, |
|||
isSameDay, |
|||
isToday, |
|||
isYesterday, |
|||
parseISO, |
|||
setDate, |
|||
setMonth, |
|||
sub |
|||
} from 'date-fns'; |
|||
import { getToday, getYesterday, resetHours } from 'libs/helper/src'; |
|||
import { cloneDeep, isEmpty } from 'lodash'; |
|||
import * as roundTo from 'round-to'; |
|||
|
|||
import { UserWithSettings } from '../app/interfaces/user-with-settings'; |
|||
import { OrderWithPlatform } from '../app/order/interfaces/order-with-platform.type'; |
|||
import { DateRange } from '../app/portfolio/interfaces/date-range.type'; |
|||
import { PortfolioPerformance } from '../app/portfolio/interfaces/portfolio-performance.interface'; |
|||
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface'; |
|||
import { PortfolioReport } from '../app/portfolio/interfaces/portfolio-report.interface'; |
|||
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 { 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'; |
|||
import { PlatformClusterRiskCurrentInvestment } from './rules/platform-cluster-risk/current-investment'; |
|||
import { PlatformClusterRiskInitialInvestment } from './rules/platform-cluster-risk/initial-investment'; |
|||
import { PlatformClusterRiskSinglePlatform } from './rules/platform-cluster-risk/single-platform'; |
|||
|
|||
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()); |
|||
|
|||
let 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, |
|||
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity |
|||
}; |
|||
}); |
|||
|
|||
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( |
|||
({ |
|||
currency, |
|||
fee, |
|||
date, |
|||
id, |
|||
platform, |
|||
quantity, |
|||
symbol, |
|||
type, |
|||
unitPrice |
|||
}) => { |
|||
this.orders.push( |
|||
new Order({ |
|||
currency, |
|||
fee, |
|||
date, |
|||
id, |
|||
platform, |
|||
quantity, |
|||
symbol, |
|||
type, |
|||
unitPrice |
|||
}) |
|||
); |
|||
} |
|||
); |
|||
|
|||
portfolioItems.forEach( |
|||
({ date, grossPerformancePercent, investment, positions, value }) => { |
|||
this.portfolioItems.push({ |
|||
date, |
|||
grossPerformancePercent, |
|||
investment, |
|||
positions, |
|||
value |
|||
}); |
|||
} |
|||
); |
|||
|
|||
this.setUser(user); |
|||
|
|||
return this; |
|||
} |
|||
|
|||
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; |
|||
} |
|||
} |
|||
|
|||
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 platforms: PortfolioPosition['platforms'] = {}; |
|||
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 (platforms[orderOfSymbol.getPlatform()?.name || 'Other']?.current) { |
|||
platforms[ |
|||
orderOfSymbol.getPlatform()?.name || 'Other' |
|||
].current += currentValueOfSymbol; |
|||
platforms[ |
|||
orderOfSymbol.getPlatform()?.name || 'Other' |
|||
].original += originalValueOfSymbol; |
|||
} else { |
|||
platforms[orderOfSymbol.getPlatform()?.name || 'Other'] = { |
|||
current: currentValueOfSymbol, |
|||
original: originalValueOfSymbol |
|||
}; |
|||
} |
|||
}); |
|||
|
|||
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], |
|||
platforms, |
|||
symbol, |
|||
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, |
|||
shareCurrent: |
|||
this.exchangeRateDataService.toCurrency( |
|||
portfolioItem.positions[symbol].quantity * now, |
|||
data[symbol]?.currency, |
|||
this.user.Settings.currency |
|||
) / value, |
|||
shareInvestment: portfolioItem.positions[symbol].investment / investment |
|||
}; |
|||
}); |
|||
|
|||
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: { |
|||
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 } |
|||
), |
|||
platformClusterRisk: await this.rulesService.evaluate( |
|||
this, |
|||
[ |
|||
new PlatformClusterRiskSinglePlatform(this.exchangeRateDataService), |
|||
new PlatformClusterRiskInitialInvestment( |
|||
this.exchangeRateDataService |
|||
), |
|||
new PlatformClusterRiskCurrentInvestment( |
|||
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() { |
|||
return this.orders; |
|||
} |
|||
|
|||
private getOrdersByType(aFilter: string[]) { |
|||
return this.orders.filter((order) => { |
|||
return aFilter.includes(order.getType()); |
|||
}); |
|||
} |
|||
|
|||
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: OrderWithPlatform[]) { |
|||
this.orders = []; |
|||
|
|||
// Map data
|
|||
aOrders.forEach((order) => { |
|||
this.orders.push( |
|||
new Order({ |
|||
currency: <any>order.currency, |
|||
date: order.date.toISOString(), |
|||
fee: order.fee, |
|||
platform: order.Platform, |
|||
quantity: order.quantity, |
|||
symbol: order.symbol, |
|||
type: <any>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 |
|||
}; |
|||
}); |
|||
|
|||
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 |
|||
}; |
|||
}); |
|||
|
|||
this.portfolioItems.push( |
|||
cloneDeep({ |
|||
date: yesterday.toISOString(), |
|||
grossPerformancePercent: 0, |
|||
investment: 0, |
|||
positions: positions, |
|||
value: 0 |
|||
}) |
|||
); |
|||
} |
|||
|
|||
this.updatePortfolioItems(); |
|||
} |
|||
|
|||
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(); |
|||
|
|||
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');
|
|||
} |
|||
} |
@ -0,0 +1,63 @@ |
|||
import { Currency } from '@prisma/client'; |
|||
import { groupBy } from 'libs/helper/src'; |
|||
|
|||
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface'; |
|||
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 { |
|||
private name: string; |
|||
|
|||
public constructor( |
|||
public exchangeRateDataService: ExchangeRateDataService, |
|||
{ |
|||
name |
|||
}: { |
|||
name: string; |
|||
} |
|||
) { |
|||
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 |
|||
) { |
|||
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 |
|||
) |
|||
})); |
|||
} |
|||
} |
@ -0,0 +1,64 @@ |
|||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface'; |
|||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; |
|||
|
|||
import { Rule } from '../../rule'; |
|||
|
|||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { |
|||
public constructor(public exchangeRateDataService: ExchangeRateDataService) { |
|||
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, |
|||
'currency', |
|||
ruleSettings.baseCurrency |
|||
); |
|||
|
|||
let maxItem = positionsGroupedByCurrency[0]; |
|||
let totalValue = 0; |
|||
|
|||
positionsGroupedByCurrency.forEach((groupItem) => { |
|||
// Calculate total value
|
|||
totalValue += groupItem.value; |
|||
|
|||
// Find maximum
|
|||
if (groupItem.investment > maxItem.investment) { |
|||
maxItem = groupItem; |
|||
} |
|||
}); |
|||
|
|||
const baseCurrencyItem = positionsGroupedByCurrency.find((item) => { |
|||
return item.groupKey === ruleSettings.baseCurrency; |
|||
}); |
|||
|
|||
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0; |
|||
|
|||
if (maxItem.groupKey !== ruleSettings.baseCurrency) { |
|||
return { |
|||
evaluation: `The major part of your current investment is not in your base currency (${( |
|||
baseCurrencyValueRatio * 100 |
|||
).toPrecision(3)}% in ${ruleSettings.baseCurrency})`,
|
|||
value: false |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
evaluation: `The major part of your current investment is in your base currency (${( |
|||
baseCurrencyValueRatio * 100 |
|||
).toPrecision(3)}% in ${ruleSettings.baseCurrency})`,
|
|||
value: true |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,65 @@ |
|||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface'; |
|||
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) { |
|||
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, |
|||
'currency', |
|||
ruleSettings.baseCurrency |
|||
); |
|||
|
|||
let maxItem = positionsGroupedByCurrency[0]; |
|||
let totalInvestment = 0; |
|||
|
|||
positionsGroupedByCurrency.forEach((groupItem) => { |
|||
// Calculate total investment
|
|||
totalInvestment += groupItem.investment; |
|||
|
|||
// Find maximum
|
|||
if (groupItem.investment > maxItem.investment) { |
|||
maxItem = groupItem; |
|||
} |
|||
}); |
|||
|
|||
const baseCurrencyItem = positionsGroupedByCurrency.find((item) => { |
|||
return item.groupKey === ruleSettings.baseCurrency; |
|||
}); |
|||
|
|||
const baseCurrencyInvestmentRatio = |
|||
baseCurrencyItem?.investment / totalInvestment || 0; |
|||
|
|||
if (maxItem.groupKey !== ruleSettings.baseCurrency) { |
|||
return { |
|||
evaluation: `The major part of your initial investment is not in your base currency (${( |
|||
baseCurrencyInvestmentRatio * 100 |
|||
).toPrecision(3)}% in ${ruleSettings.baseCurrency})`,
|
|||
value: false |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
evaluation: `The major part of your initial investment is in your base currency (${( |
|||
baseCurrencyInvestmentRatio * 100 |
|||
).toPrecision(3)}% in ${ruleSettings.baseCurrency})`,
|
|||
value: true |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,64 @@ |
|||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface'; |
|||
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) { |
|||
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, |
|||
'currency', |
|||
ruleSettings.baseCurrency |
|||
); |
|||
|
|||
let maxItem = positionsGroupedByCurrency[0]; |
|||
let totalValue = 0; |
|||
|
|||
positionsGroupedByCurrency.forEach((groupItem) => { |
|||
// Calculate total value
|
|||
totalValue += groupItem.value; |
|||
|
|||
// Find maximum
|
|||
if (groupItem.value > maxItem.value) { |
|||
maxItem = groupItem; |
|||
} |
|||
}); |
|||
|
|||
const maxValueRatio = maxItem.value / totalValue; |
|||
|
|||
if (maxValueRatio > ruleSettings.threshold) { |
|||
return { |
|||
evaluation: `Over ${ |
|||
ruleSettings.threshold * 100 |
|||
}% of your current investment is in ${maxItem.groupKey} (${( |
|||
maxValueRatio * 100 |
|||
).toPrecision(3)}%)`,
|
|||
value: false |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
evaluation: `The major part of your current investment is in ${ |
|||
maxItem.groupKey |
|||
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${ |
|||
ruleSettings.threshold * 100 |
|||
}%`,
|
|||
value: true |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,64 @@ |
|||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface'; |
|||
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) { |
|||
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, |
|||
'currency', |
|||
ruleSettings.baseCurrency |
|||
); |
|||
|
|||
let maxItem = positionsGroupedByCurrency[0]; |
|||
let totalInvestment = 0; |
|||
|
|||
positionsGroupedByCurrency.forEach((groupItem) => { |
|||
// Calculate total investment
|
|||
totalInvestment += groupItem.investment; |
|||
|
|||
// Find maximum
|
|||
if (groupItem.investment > maxItem.investment) { |
|||
maxItem = groupItem; |
|||
} |
|||
}); |
|||
|
|||
const maxInvestmentRatio = maxItem.investment / totalInvestment; |
|||
|
|||
if (maxInvestmentRatio > ruleSettings.threshold) { |
|||
return { |
|||
evaluation: `Over ${ |
|||
ruleSettings.threshold * 100 |
|||
}% of your initial investment is in ${maxItem.groupKey} (${( |
|||
maxInvestmentRatio * 100 |
|||
).toPrecision(3)}%)`,
|
|||
value: false |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
evaluation: `The major part of your initial investment is in ${ |
|||
maxItem.groupKey |
|||
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${ |
|||
ruleSettings.threshold * 100 |
|||
}%`,
|
|||
value: true |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,53 @@ |
|||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface'; |
|||
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) { |
|||
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; |
|||
|
|||
if (feeRatio > ruleSettings.threshold) { |
|||
return { |
|||
evaluation: `The fees do exceed ${ |
|||
ruleSettings.threshold * 100 |
|||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
|||
value: false |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
evaluation: `The fees do not exceed ${ |
|||
ruleSettings.threshold * 100 |
|||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
|||
value: true |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,83 @@ |
|||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface'; |
|||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; |
|||
|
|||
import { Rule } from '../../rule'; |
|||
|
|||
export class PlatformClusterRiskCurrentInvestment extends Rule { |
|||
public constructor(public exchangeRateDataService: ExchangeRateDataService) { |
|||
super(exchangeRateDataService, { |
|||
name: 'Current Investment' |
|||
}); |
|||
} |
|||
|
|||
public evaluate( |
|||
aPositions: { [symbol: string]: PortfolioPosition }, |
|||
aFees: number, |
|||
aRuleSettingsMap?: { |
|||
[key: string]: any; |
|||
} |
|||
) { |
|||
const ruleSettings = |
|||
aRuleSettingsMap[PlatformClusterRiskCurrentInvestment.name]; |
|||
|
|||
const platforms: { |
|||
[symbol: string]: Pick<PortfolioPosition, 'name'> & { |
|||
investment: number; |
|||
}; |
|||
} = {}; |
|||
|
|||
Object.values(aPositions).forEach((position) => { |
|||
for (const [platform, { current }] of Object.entries( |
|||
position.platforms |
|||
)) { |
|||
if (platforms[platform]?.investment) { |
|||
platforms[platform].investment += current; |
|||
} else { |
|||
platforms[platform] = { |
|||
investment: current, |
|||
name: platform |
|||
}; |
|||
} |
|||
} |
|||
}); |
|||
|
|||
let maxItem; |
|||
let totalInvestment = 0; |
|||
|
|||
Object.values(platforms).forEach((platform) => { |
|||
if (!maxItem) { |
|||
maxItem = platform; |
|||
} |
|||
|
|||
// Calculate total investment
|
|||
totalInvestment += platform.investment; |
|||
|
|||
// Find maximum
|
|||
if (platform.investment > maxItem?.investment) { |
|||
maxItem = platform; |
|||
} |
|||
}); |
|||
|
|||
const maxInvestmentRatio = maxItem.investment / totalInvestment; |
|||
|
|||
if (maxInvestmentRatio > ruleSettings.threshold) { |
|||
return { |
|||
evaluation: `Over ${ |
|||
ruleSettings.threshold * 100 |
|||
}% of your current investment is at ${maxItem.name} (${( |
|||
maxInvestmentRatio * 100 |
|||
).toPrecision(3)}%)`,
|
|||
value: false |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
evaluation: `The major part of your current investment is at ${ |
|||
maxItem.name |
|||
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${ |
|||
ruleSettings.threshold * 100 |
|||
}%`,
|
|||
value: true |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,83 @@ |
|||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface'; |
|||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; |
|||
|
|||
import { Rule } from '../../rule'; |
|||
|
|||
export class PlatformClusterRiskInitialInvestment extends Rule { |
|||
public constructor(public exchangeRateDataService: ExchangeRateDataService) { |
|||
super(exchangeRateDataService, { |
|||
name: 'Initial Investment' |
|||
}); |
|||
} |
|||
|
|||
public evaluate( |
|||
aPositions: { [symbol: string]: PortfolioPosition }, |
|||
aFees: number, |
|||
aRuleSettingsMap?: { |
|||
[key: string]: any; |
|||
} |
|||
) { |
|||
const ruleSettings = |
|||
aRuleSettingsMap[PlatformClusterRiskInitialInvestment.name]; |
|||
|
|||
const platforms: { |
|||
[symbol: string]: Pick<PortfolioPosition, 'name'> & { |
|||
investment: number; |
|||
}; |
|||
} = {}; |
|||
|
|||
Object.values(aPositions).forEach((position) => { |
|||
for (const [platform, { original }] of Object.entries( |
|||
position.platforms |
|||
)) { |
|||
if (platforms[platform]?.investment) { |
|||
platforms[platform].investment += original; |
|||
} else { |
|||
platforms[platform] = { |
|||
investment: original, |
|||
name: platform |
|||
}; |
|||
} |
|||
} |
|||
}); |
|||
|
|||
let maxItem; |
|||
let totalInvestment = 0; |
|||
|
|||
Object.values(platforms).forEach((platform) => { |
|||
if (!maxItem) { |
|||
maxItem = platform; |
|||
} |
|||
|
|||
// Calculate total investment
|
|||
totalInvestment += platform.investment; |
|||
|
|||
// Find maximum
|
|||
if (platform.investment > maxItem?.investment) { |
|||
maxItem = platform; |
|||
} |
|||
}); |
|||
|
|||
const maxInvestmentRatio = maxItem.investment / totalInvestment; |
|||
|
|||
if (maxInvestmentRatio > ruleSettings.threshold) { |
|||
return { |
|||
evaluation: `Over ${ |
|||
ruleSettings.threshold * 100 |
|||
}% of your initial investment is at ${maxItem.name} (${( |
|||
maxInvestmentRatio * 100 |
|||
).toPrecision(3)}%)`,
|
|||
value: false |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
evaluation: `The major part of your initial investment is at ${ |
|||
maxItem.name |
|||
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${ |
|||
ruleSettings.threshold * 100 |
|||
}%`,
|
|||
value: true |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,36 @@ |
|||
import { PortfolioPosition } from 'apps/api/src/app/portfolio/interfaces/portfolio-position.interface'; |
|||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; |
|||
|
|||
import { Rule } from '../../rule'; |
|||
|
|||
export class PlatformClusterRiskSinglePlatform extends Rule { |
|||
public constructor(public exchangeRateDataService: ExchangeRateDataService) { |
|||
super(exchangeRateDataService, { |
|||
name: 'Single Platform' |
|||
}); |
|||
} |
|||
|
|||
public evaluate(positions: { [symbol: string]: PortfolioPosition }) { |
|||
const platforms: string[] = []; |
|||
|
|||
Object.values(positions).forEach((position) => { |
|||
for (const [platform] of Object.entries(position.platforms)) { |
|||
if (!platforms.includes(platform)) { |
|||
platforms.push(platform); |
|||
} |
|||
} |
|||
}); |
|||
|
|||
if (platforms.length === 1) { |
|||
return { |
|||
evaluation: `All your investment is managed by a single platform`, |
|||
value: false |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
evaluation: `Your investment is managed by ${platforms.length} platforms`, |
|||
value: true |
|||
}; |
|||
} |
|||
} |
@ -0,0 +1,23 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Cron, CronExpression } from '@nestjs/schedule'; |
|||
|
|||
import { DataGatheringService } from './data-gathering.service'; |
|||
import { ExchangeRateDataService } from './exchange-rate-data.service'; |
|||
|
|||
@Injectable() |
|||
export class CronService { |
|||
public constructor( |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService |
|||
) {} |
|||
|
|||
@Cron(CronExpression.EVERY_MINUTE) |
|||
public async runEveryMinute() { |
|||
await this.dataGatheringService.gather7Days(); |
|||
} |
|||
|
|||
@Cron(CronExpression.EVERY_12_HOURS) |
|||
public async runEveryTwelveHours() { |
|||
await this.exchangeRateDataService.loadCurrencies(); |
|||
} |
|||
} |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue