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