commit
c616312233
@ -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,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 have changed in this diff Show More
Loading…
Reference in new issue