Initial commit

pull/1/head
Thomas 3 years ago
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"
}
}
]
}

48
.gitignore vendored

@ -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,8 @@
{
"endOfLine": "auto",
"printWidth": 80,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}

@ -0,0 +1,8 @@
{
"recommendations": [
"angular.ng-template",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner",
"nrwl.angular-console"
]
}

@ -0,0 +1,32 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Jest File",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
"args": [
"test",
"--codeCoverage=false",
"--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts"
],
"cwd": "${workspaceFolder}",
"console": "internalConsole"
},
{
"envFile": "${workspaceFolder}/.env",
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/apps/api/src/main.ts",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
"autoAttachChildProcesses": true,
"skipFiles": [
"${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js"
]
}
]
}

@ -0,0 +1,4 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}

@ -0,0 +1 @@
network-timeout 600000

@ -0,0 +1,586 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 0.85.0 - 12.04.2021
### Changed
- Refactored many frontend components
## 0.84.0 - 11.04.2021
### Fixed
- Fixed static portfolio analysis rules (_Currency Cluster Risk_) if no positions in base currency
- Initial Investment: Base Currency
- Current Investment: Base Currency
## 0.83.0 - 11.04.2021
### Added
- Added a new static portfolio analysis rule: Fees in relation to the initial investment
### Changed
- Reset the cache on the server start
### Fixed
- Fixed an issue in the portfolio update on deleting a transaction
- Fixed an issue in the _X-Ray_ section (missing redirection on logout)
## 0.82.0 - 10.04.2021
### Added
- Added a gradient to the line charts
- Added a selector to set the base currency on the account page
## 0.81.0 - 06.04.2021
### Added
- Added support for assets in `GBP`
- Added an error handling with messages in the client
### Changed
- Changed the _Ghostfolio_ SaaS (cloud) from a `nano` to a `micro` instance for a better performance
## 0.80.0 - 05.04.2021
### Changed
- Improved the spacing in the header
- Upgraded `chart.js` from version `2.9.4` to `3.0.2`
## 0.79.0 - 04.04.2021
### Changed
- Refactored the data management services
- Upgraded `bootstrap` from version `4.5.3` to `4.6.0`
- Upgraded `date-fns` from version `2.16.1` to `2.19.0`
- Upgraded `ionicons` from version `5.4.0` to `5.5.1`
- Upgraded `lodash` from version `4.17.20` to `4.17.21`
- Upgraded `ngx-markdown` from version `11.1.0` to `11.1.2`
- Upgraded `ngx-skeleton-loader` from version `2.6.2` to `2.9.1`
- Upgraded `prisma` from version `2.18.0` to `2.20.1`
## 0.78.0 - 04.04.2021
### Added
- Added a spinner to the create or edit transaction dialog
- Added support for the back button in
- portfolio performance chart dialog
- position detail dialog
- create transaction dialog
- edit transaction dialog
### Changed
- Improved the single platform rule by adding the number of platforms
## 0.77.1 - 03.04.2021
### Changed
- Minor improvements
## 0.77.0 - 03.04.2021
### Added
- Added support for base currency in user settings
- Added an investment risk disclaimer to the footer
- Added two more static portfolio analysis rules:
- _Currency Cluster Risk_ (current investment)
- _Platform Cluster Risk_ (current investment)
### Changed
- Grouped the _X-Ray_ section visually in _Currency Cluster Risk_ and _Platform Cluster Risk_
## 0.76.0 - 02.04.2021
### Added
- Added two more static portfolio analysis rules:
- _Currency Cluster Risk_ (base currency)
- _Platform Cluster Risk_ (single platform)
### Fixed
- Fixed an issue in the _X-Ray_ section (empty portfolio)
## 0.75.0 - 01.04.2021
### Fixed
- Fixed an issue in the exchange rate service occurring on the first day of the month
## 0.74.0 - 01.04.2021
### Added
- Added a _Create Account_ message in the _Live Demo_
- Added skeleton loaders to the _X-Ray_ section
### Changed
- Improved the alignment of the _Why Ghostfolio?_ section
- Improved the styling of the _Fear & Greed Index_ (market mood)
## 0.73.0 - 31.03.2021
### Added
- Added the _Fear & Greed Index_ (market mood) to the portfolio performance chart dialog
- Added a link to the info box on the analysis page
### Changed
- Improved the intro text in the _X-Ray_ section
### Fixed
- Fixed the flickering of the _Sign in_ button in the header
## 0.72.1 - 30.03.2021
### Fixed
- Fixed an issue with updating or resetting the platform of a transaction
## 0.72.0 - 30.03.2021
### Added
- Added an intro text to the _X-Ray_ section
### Changed
- Improved the editing of transactions
- Harmonized the page titles
### Fixed
- Fixed an issue with wrong transaction dates
## 0.71.0 - 28.03.2021
### Added
- Added the second static portfolio analysis rule: _Platform Cluster Risk_
### Changed
- Improved the styling in the _X-Ray_ section
## 0.70.0 - 27.03.2021
### Added
- Added the current _Fear & Greed Index_ as text
- Extended the landing page text: _Ghostfolio_ empowers busy folks...
- Added the first static portfolio analysis rule in the brand new _X-Ray_ section
### Changed
- Improved the spacing in the footer
## 0.69.0 - 27.03.2021
### Added
- Added the current _Fear & Greed Index_ to the resources page
## 0.68.0 - 26.03.2021
### Changed
- Improved the performance of the position detail dialog
### Fixed
- Fixed a scroll issue in dialogs
## 0.67.0 - 26.03.2021
### Added
- Added an experimental API to get historical data for benchmarks
## 0.66.0 - 25.03.2021
### Added
- Added a chevron to the position
- Added an experimental API to get benchmark data
## 0.65.0 - 24.03.2021
### Added
- Added a legend to the portfolio performance chart
- Added a placeholder to the filter of the transactions table
### Changed
- Changed the regular data management check to a smarter approach
## 0.64.0 - 23.03.2021
### Added
- Added an index to the market data database table
### Changed
- Optimized the other dialogs for mobile (full screen and close button)
## 0.63.0 - 22.03.2021
### Changed
- Improved the transactions table
- Optimized the position detail dialog for mobile (full screen and close button)
## 0.62.0 - 21.03.2021
### Fixed
- Fixed an issue while loading data concurrently via the date range component
## 0.61.0 - 21.03.2021
### Fixed
- Fixed an issue in the performance calculation if there are only transactions from today
## 0.60.0 - 20.03.2021
### Added
- Added a button to create the first transaction on the analysis page
### Fixed
- Fixed an issue on the analysis page if there are only transactions from today
## 0.59.0 - 20.03.2021
### Added
- Extended the landing page text: Why _Ghostfolio_?
- Extended the glossary of the resources page
## 0.58.0 - 20.03.2021
### Added
- Added meta data for _Open Graph_ and _Twitter Cards_
- Added meta data: `description` and `keywords`
### Changed
- Improved the icon
### Fixed
- Fixed the `sitemap.xml` file
## 0.57.0 - 19.03.2021
### Added
- Added the `sitemap.xml` file
- Added a resources page
- Added a chart to the landing page
### Changed
- Improved the performance chart
- Improved the average buy price in the position detail chart
- Improved the style of the active page in the navigation on mobile
## 0.56.0 - 18.03.2021
### Added
- Added the quantity and investment in the position detail dialog
### Changed
- Improved the performance chart
- Improved the performance calculation
- Improved the average buy price in the position detail chart
## 0.55.0 - 16.03.2021
### Changed
- Improved the performance calculation
## 0.54.0 - 15.03.2021
### Added
- Added another _Create Account_ button at the end of the landing page
### Fixed
- Fixed an issue in the position detail chart if the position has been bought today (no historical data)
- Fixed an issue in the transaction service with unordered items
## 0.53.0 - 14.03.2021
### Added
- Set up database backup
### Changed
- Improved `site.webmanifest`
## 0.52.0 - 14.03.2021
### Changed
- Added the membership status to the account page
### Fixed
- Fixed an issue in the chart (empty portfolio)
## 0.51.0 - 14.03.2021
### Changed
- Changed the default number of rows from 10 to 7 in the positions table
## 0.50.1 - 13.03.2021
### Fixed
- Fixed the button to expand rows in the positions table
## 0.50.0 - 13.03.2021
### Added
- Added filters to switch between _Original Shares_ vs. _Current Shares_ in pie charts
- Added a button to expand rows in the positions table
### Changed
- Ordered platforms by name in edit transaction dialog
- Modularized the date range component
### Fixed
- Fixed the error handling for the data management (errors in nested data)
## 0.49.0 - 13.03.2021
### Added
- Added additional portfolio filters for `1Y` and `5Y`
- Added an error handling for the data management
### Changed
- Improved the pricing section
## 0.48.1 - 11.03.2021
### Fixed
- Fixed the about page for unauthorized users
## 0.48.0 - 11.03.2021
### Added
- Added a pricing section
### Changed
- Improved the positions and transactions table
- Harmonized alignment
- Enabled position detail dialog
## 0.47.0 - 10.03.2021
### Added
- Added a positions table with information about _Original Shares_ vs. _Current Shares_
- Added data management to control panel
## 0.46.0 - 09.03.2021
### Added
- Added permission based access-control
- Added an admin control panel
## 0.45.0 - 08.03.2021
### Changed
- Changed the data management of benchmarks with extended persistency
- Changed the data management of currencies with extended persistency
## 0.44.0 - 07.03.2021
### Changed
- Changed the data management with extended persistency
- Upgraded `prisma` from version `2.16.1` to `2.18.0`
- Upgraded `angular` from version `11.0.9` to `11.2.4`
## 0.43.0 - 04.03.2021
### Fixed
- Fixed missing columns (_Quantity_, _Unit Price_ and _Fee_) in transactions table
- Fixed displaying edit transaction dialog in impersonation mode
- Fixed `/.well-known/assetlinks.json` for TWA
## 0.42.0 - 03.03.2021
### Changed
- Improved the skeleton loader (minor)
### Fixed
- Fixed the portfolio unit tests
## 0.41.0 - 02.03.2021
### Added
- Added the possibility to create or edit a transaction with a platform
### Changed
- Increased the token expiration duration
### Fixed
- Only show relevant data in the position detail dialog
- Improved the performance chart styling in Safari
## 0.40.0 - 01.03.2021
### Fixed
- Fixed the calculation issues occurring on the first day of each month
- Harmonized the percent value formatting
## 0.39.0 - 28.02.2021
### Changed
- Improved the buy price in the position detail dialog
### Fixed
- Fixed the (hidden) header issue
## 0.38.0 - 26.02.2021
### Added
- Added `/.well-known/assetlinks.json` for TWA
## 0.37.0 - 25.02.2021
### Added
- Added a benchmark (_S&P 500_) to the portfolio performance chart
## 0.36.1 - 24.02.2021
### Changed
- Minor improvements in the transactions table
## 0.36.0 - 24.02.2021
### Added
- Added the possibility to edit a transaction
## 0.35.0 - 23.02.2021
### Changed
- Added transparent background to header
- Harmonized currency value formatting
### Fixed
- Fixed header issue with (not) signed in
## 0.34.0 - 21.02.2021
### Changed
- Improved skeleton loader of position
- Simplified sign in / sign up flow
## 0.33.0 - 21.02.2021
### Added
- Added favicon and `site.webmanifest`
### Changed
- Set font style of numbers to tabular
- Rename _Orders_ to _Transactions_
### Security
- Additionally hash the _Security Token_ (no more stored in plain text)
## 0.32.0 - 20.02.2021
### Added
- Added a landing page text: How does _Ghostfolio_ work?
- Added the _Independent & Bootstrapped_ badge to the about page
## 0.31.0 - 20.02.2021
### Added
- Added a changelog to the about page
- Added a twitter account to the about page
- Added the version to the about page
## 0.30.0 - 19.02.2021
### Added
- Added an about page
## 0.29.0 - 19.02.2021
### Added
- Added a landing page text: Why _Ghostfolio_?
## 0.28.2 - 17.02.2021
### Added
- Added caching for the portfolio (Redis)

@ -0,0 +1,9 @@
FROM node:14
# Create app directory
WORKDIR /app
COPY . .
EXPOSE 3333
CMD [ "npm", "run", "start:prod" ]

@ -0,0 +1,38 @@
<div align="center">
<h1>Ghostfolio</h1>
<p>
<strong>Privacy-first Portfolio Tracker</strong>
</p>
<p>
<a href="https://www.ghostfol.io"><strong>Ghostfolio</strong></a>
</p>
</div>
## Features
- ✅ Dark Mode
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org/en/download)
- [Yarn](https://yarnpkg.com/en/docs/install)
- [Docker](https://www.docker.com/products/docker-desktop)
### Setup
1. Run `yarn install`
2. Run `yarn docker:dockerize`
3. Copy `.env.sample` to `docker/.env`
4. Run `cd docker/<version>`
5. Run `docker-compose build`
6. Run `docker-compose up -d`
## Development
- Start server
- Serve: Run `yarn start:server`
- Debug: Run `yarn watch:server` and run "Launch Program" in _Visual Studio Code_
- Start client
- Run `yarn start:client`

@ -0,0 +1,228 @@
{
"version": 1,
"cli": {
"defaultCollection": "@nrwl/nest"
},
"defaultProject": "api",
"schematics": {
"@nrwl/angular:application": {
"unitTestRunner": "jest",
"e2eTestRunner": "cypress"
},
"@nrwl/angular:library": {
"unitTestRunner": "jest"
},
"@nrwl/nest": {}
},
"projects": {
"api": {
"root": "apps/api",
"sourceRoot": "apps/api/src",
"projectType": "application",
"prefix": "api",
"schematics": {},
"architect": {
"build": {
"builder": "@nrwl/node:build",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"]
},
"configurations": {
"production": {
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
},
"outputs": ["{options.outputPath}"]
},
"serve": {
"builder": "@nrwl/node:execute",
"options": {
"buildTarget": "api:build"
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/api/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.js",
"passWithNoTests": true
},
"outputs": ["coverage/apps/api"]
}
}
},
"client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "apps/client",
"sourceRoot": "apps/client/src",
"prefix": "gf",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/apps/client",
"index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"aot": true,
"assets": [
"apps/client/src/assets",
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./"
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "apps/client/src/environments/environment.ts",
"with": "apps/client/src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
},
"outputs": ["{options.outputPath}"]
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json"
},
"configurations": {
"production": {
"browserTarget": "client:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "client:build"
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/client/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.js",
"passWithNoTests": true
},
"outputs": ["coverage/apps/client"]
}
}
},
"client-e2e": {
"root": "apps/client-e2e",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/client-e2e/cypress.json",
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
"devServerTarget": "client:serve"
},
"configurations": {
"production": {
"devServerTarget": "client:serve:production"
}
}
}
}
},
"helper": {
"root": "libs/helper",
"sourceRoot": "libs/helper/src",
"projectType": "library",
"architect": {
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/helper/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/helper"],
"options": {
"jestConfig": "libs/helper/jest.config.js",
"passWithNoTests": true
}
}
}
}
}
}

@ -0,0 +1,22 @@
{
"extends": "../../.eslintrc.json",
"ignorePatterns": ["!**/*"],
"rules": {},
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"parserOptions": {
"project": ["apps/api/tsconfig.*?.json"]
},
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

@ -0,0 +1,15 @@
module.exports = {
displayName: 'api',
preset: '../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json'
}
},
transform: {
'^.+\\.[tj]s$': 'ts-jest'
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000
};

@ -0,0 +1,32 @@
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { AccessService } from './access.service';
import { Access } from './interfaces/access.interface';
@Controller('access')
export class AccessController {
public constructor(
private readonly accessService: AccessService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({
include: {
GranteeUser: true
},
where: { userId: this.request.user.id }
});
return accessesWithGranteeUser.map((access) => {
return {
granteeAlias: access.GranteeUser.alias
};
});
}
}

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../services/prisma.service';
import { AccessController } from './access.controller';
import { AccessService } from './access.service';
@Module({
imports: [],
controllers: [AccessController],
providers: [AccessService, PrismaService]
})
export class AccessModule {}

@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../services/prisma.service';
import { AccessWithGranteeUser } from './interfaces/access-with-grantee-user.type';
@Injectable()
export class AccessService {
public constructor(private prisma: PrismaService) {}
public async accesses(params: {
include?: Prisma.AccessInclude;
skip?: number;
take?: number;
cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByInput;
}): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.access.findMany({
cursor,
include,
orderBy,
skip,
take,
where
});
}
}

@ -0,0 +1,3 @@
import { Access, User } from '@prisma/client';
export type AccessWithGranteeUser = Access & { GranteeUser?: User };

@ -0,0 +1,3 @@
export interface Access {
granteeAlias: string;
}

@ -0,0 +1,64 @@
import {
Controller,
Get,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
import { DataGatheringService } from '../../services/data-gathering.service';
import { AdminService } from './admin.service';
import { AdminData } from './interfaces/admin-data.interface';
@Controller('admin')
export class AdminController {
public constructor(
private readonly adminService: AdminService,
private readonly dataGatheringService: DataGatheringService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getAdminData(): Promise<AdminData> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.get();
}
@Post('gather/max')
@UseGuards(AuthGuard('jwt'))
public async gatherMax(): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherMax();
return;
}
}

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { DataGatheringService } from '../../services/data-gathering.service';
import { DataProviderService } from '../../services/data-provider.service';
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { PrismaService } from '../../services/prisma.service';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
imports: [],
controllers: [AdminController],
providers: [
AdminService,
AlphaVantageService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class AdminModule {}

@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { PrismaService } from '../../services/prisma.service';
import { AdminData } from './interfaces/admin-data.interface';
@Injectable()
export class AdminService {
public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService
) {}
public async get(): Promise<AdminData> {
return {
analytics: await this.getUserAnalytics(),
exchangeRates: [
{
label1: Currency.EUR,
label2: Currency.CHF,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.EUR,
Currency.CHF
)
},
{
label1: Currency.GBP,
label2: Currency.CHF,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.GBP,
Currency.CHF
)
},
{
label1: Currency.USD,
label2: Currency.CHF,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.USD,
Currency.CHF
)
},
{
label1: Currency.USD,
label2: Currency.EUR,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.USD,
Currency.EUR
)
},
{
label1: Currency.USD,
label2: Currency.GBP,
value: await this.exchangeRateDataService.toCurrency(
1,
Currency.USD,
Currency.GBP
)
}
],
lastDataGathering: await this.getLastDataGathering(),
transactionCount: await this.prisma.order.count(),
userCount: await this.prisma.user.count()
};
}
private async getLastDataGathering() {
const lastDataGathering = await this.prisma.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' }
});
if (lastDataGathering?.value) {
return new Date(lastDataGathering.value);
}
const dataGatheringInProgress = await this.prisma.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' }
});
if (dataGatheringInProgress) {
return 'IN_PROGRESS';
}
return null;
}
private async getUserAnalytics() {
return await this.prisma.analytics.findMany({
orderBy: { updatedAt: 'desc' },
select: {
activityCount: true,
updatedAt: true,
User: {
select: {
alias: true,
createdAt: true,
id: true
}
}
},
take: 20
});
}
}

@ -0,0 +1,14 @@
export interface AdminData {
analytics: {
activityCount: number;
updatedAt: Date;
User: {
alias: string;
id: string;
};
}[];
exchangeRates: { label1: string; label2: string; value: number }[];
lastDataGathering: Date | 'IN_PROGRESS';
transactionCount: number;
userCount: number;
}

@ -0,0 +1,31 @@
import { Controller } from '@nestjs/common';
import { PrismaService } from '../services/prisma.service';
import { RedisCacheService } from './redis-cache/redis-cache.service';
@Controller()
export class AppController {
public constructor(
private prisma: PrismaService,
private readonly redisCacheService: RedisCacheService
) {
this.initialize();
}
private async initialize() {
this.redisCacheService.reset();
const isDataGatheringLocked = await this.prisma.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' }
});
if (!isDataGatheringLocked) {
// Prepare for automatical data gather if not locked
await this.prisma.property.deleteMany({
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
}
}
}

@ -0,0 +1,71 @@
import { join } from 'path';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { CronService } from '../services/cron.service';
import { DataGatheringService } from '../services/data-gathering.service';
import { DataProviderService } from '../services/data-provider.service';
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { PrismaService } from '../services/prisma.service';
import { AccessModule } from './access/access.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { ExperimentalModule } from './experimental/experimental.module';
import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module';
@Module({
imports: [
AdminModule,
AccessModule,
AuthModule,
CacheModule,
ConfigModule.forRoot(),
ExperimentalModule,
InfoModule,
OrderModule,
PortfolioModule,
RedisCacheModule,
ScheduleModule.forRoot(),
ServeStaticModule.forRoot({
serveStaticOptions: {
/*etag: false // Disable etag header to fix PWA
setHeaders: (res, path) => {
if (path.includes('ngsw.json')) {
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
// https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
}
}*/
},
rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*']
}),
SymbolModule,
UserModule
],
controllers: [AppController],
providers: [
AlphaVantageService,
CronService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class AppModule {}

@ -0,0 +1,52 @@
import {
Controller,
Get,
HttpException,
Param,
Req,
Res,
UseGuards
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
public constructor(private readonly authService: AuthService) {}
@Get('anonymous/:accessToken')
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
try {
const authToken = await this.authService.validateAnonymousLogin(
accessToken
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Get('google')
@UseGuards(AuthGuard('google'))
public googleLogin() {
// Initiates the Google OAuth2 login flow
}
@Get('google/callback')
@UseGuards(AuthGuard('google'))
public googleLoginCallback(@Req() req, @Res() res) {
// Handles the Google OAuth2 callback
const jwt: string = req.user.jwt;
if (jwt) {
res.redirect(`${process.env.ROOT_URL}/auth/${jwt}`);
} else {
res.redirect(`${process.env.ROOT_URL}/auth`);
}
}
}

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PrismaService } from '../../services/prisma.service';
import { UserService } from '../user/user.service';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy';
@Module({
controllers: [AuthController],
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
})
],
providers: [
AuthService,
GoogleStrategy,
JwtStrategy,
PrismaService,
UserService
]
})
export class AuthModule {}

@ -0,0 +1,67 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UserService } from '../user/user.service';
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@Injectable()
export class AuthService {
public constructor(
private jwtService: JwtService,
private readonly userService: UserService
) {}
public async validateAnonymousLogin(accessToken: string) {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken(
accessToken,
process.env.ACCESS_TOKEN_SALT
);
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken }
});
if (user) {
const jwt: string = this.jwtService.sign({
id: user.id
});
resolve(jwt);
} else {
throw new Error();
}
} catch {
reject();
}
});
}
public async validateOAuthLogin({
provider,
thirdPartyId
}: ValidateOAuthLoginParams): Promise<string> {
try {
let [user] = await this.userService.users({
where: { provider, thirdPartyId }
});
if (!user) {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId
});
}
const jwt: string = this.jwtService.sign({
id: user.id
});
return jwt;
} catch (err) {
throw new InternalServerErrorException('validateOAuthLogin', err.message);
}
}
}

@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { Strategy } from 'passport-google-oauth20';
import { AuthService } from './auth.service';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
public constructor(private readonly authService: AuthService) {
super({
callbackURL: `${process.env.ROOT_URL}/api/auth/google/callback`,
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_SECRET,
passReqToCallback: true,
scope: ['email', 'profile']
});
}
public async validate(
request: any,
token: string,
refreshToken: string,
profile,
done: Function,
done2: Function
) {
try {
const jwt: string = await this.authService.validateOAuthLogin({
provider: Provider.GOOGLE,
thirdPartyId: profile.id
});
const user = {
jwt
};
done(null, user);
} catch (err) {
console.error(err);
done(err, false);
}
}
}

@ -0,0 +1,6 @@
import { Provider } from '@prisma/client';
export interface ValidateOAuthLoginParams {
provider: Provider;
thirdPartyId: string;
}

@ -0,0 +1,39 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../services/prisma.service';
import { UserService } from '../user/user.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
public constructor(
private prisma: PrismaService,
private readonly userService: UserService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET_KEY
});
}
public async validate({ id }: { id: string }) {
try {
const user = await this.userService.user({ id });
if (user) {
await this.prisma.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
where: { userId: user.id }
});
return user;
} else {
throw '';
}
} catch (err) {
throw new UnauthorizedException('unauthorized', err.message);
}
}
}

@ -0,0 +1,26 @@
import { Controller, Inject, Param, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CacheService } from './cache.service';
@Controller('cache')
export class CacheController {
public constructor(
private readonly cacheService: CacheService,
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {
this.redisCacheService.reset();
}
@Post('flush')
@UseGuards(AuthGuard('jwt'))
public async flushCache(): Promise<void> {
this.redisCacheService.reset();
return this.cacheService.flush(this.request.user.id);
}
}

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../services/prisma.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { CacheController } from './cache.controller';
import { CacheService } from './cache.service';
@Module({
imports: [RedisCacheModule],
controllers: [CacheController],
providers: [CacheService, PrismaService]
})
export class CacheModule {}

@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { Prisma, User } from '@prisma/client';
import { PrismaService } from '../../services/prisma.service';
@Injectable()
export class CacheService {
public constructor(private prisma: PrismaService) {}
public async flush(aUserId: string): Promise<void> {
await this.prisma.property.deleteMany({
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
return;
}
}

@ -0,0 +1,22 @@
import { Currency, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateOrderDto {
@IsString()
currency: Currency;
@IsISO8601()
date: string;
@IsNumber()
quantity: number;
@IsString()
symbol: string;
@IsString()
type: Type;
@IsNumber()
unitPrice: number;
}

@ -0,0 +1,88 @@
import {
Body,
Controller,
Get,
Headers,
HttpException,
Inject,
Param,
Post
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { parse } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { baseCurrency, benchmarks } from 'libs/helper/src';
import { isApiTokenAuthorized } from 'libs/helper/src';
import { CreateOrderDto } from './create-order.dto';
import { ExperimentalService } from './experimental.service';
import { Data } from './interfaces/data.interface';
@Controller('experimental')
export class ExperimentalController {
public constructor(
private readonly experimentalService: ExperimentalService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('benchmarks')
public async getBenchmarks(
@Headers('Authorization') apiToken: string
): Promise<string[]> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return benchmarks;
}
@Get('benchmarks/:symbol')
public async getBenchmark(
@Headers('Authorization') apiToken: string,
@Param('symbol') symbol: string
): Promise<{ date: Date; marketPrice: number }[]> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const marketData = await this.experimentalService.getBenchmark(symbol);
if (marketData?.length === 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return marketData;
}
@Post('value/:dateString?')
public async getValue(
@Body() orders: CreateOrderDto[],
@Headers('Authorization') apiToken: string,
@Param('dateString') dateString: string
): Promise<Data> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let date = new Date();
if (dateString) {
date = parse(dateString, 'yyyy-MM-dd', new Date());
}
return this.experimentalService.getValue(orders, date, baseCurrency);
}
}

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { DataProviderService } from '../../services/data-provider.service';
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { PrismaService } from '../../services/prisma.service';
import { RulesService } from '../../services/rules.service';
import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service';
@Module({
imports: [],
controllers: [ExperimentalController],
providers: [
AlphaVantageService,
DataProviderService,
ExchangeRateDataService,
ExperimentalService,
PrismaService,
RakutenRapidApiService,
RulesService,
YahooFinanceService
]
})
export class ExperimentalModule {}

@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { Currency, Type } from '@prisma/client';
import { parseISO } from 'date-fns';
import { Portfolio } from '../../models/portfolio';
import { DataProviderService } from '../../services/data-provider.service';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { PrismaService } from '../../services/prisma.service';
import { RulesService } from '../../services/rules.service';
import { OrderWithPlatform } from '../order/interfaces/order-with-platform.type';
import { CreateOrderDto } from './create-order.dto';
import { Data } from './interfaces/data.interface';
@Injectable()
export class ExperimentalService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService,
private readonly rulesService: RulesService
) {}
public async getBenchmark(aSymbol: string) {
return this.prisma.marketData.findMany({
orderBy: { date: 'asc' },
select: { date: true, marketPrice: true },
where: { symbol: aSymbol }
});
}
public async getValue(
aOrders: CreateOrderDto[],
aDate: Date,
aBaseCurrency: Currency
): Promise<Data> {
const ordersWithPlatform: OrderWithPlatform[] = aOrders.map((order) => {
return {
...order,
createdAt: new Date(),
date: parseISO(order.date),
fee: 0,
id: undefined,
platformId: undefined,
type: Type.BUY,
updatedAt: undefined,
userId: undefined
};
});
const portfolio = new Portfolio(
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
);
await portfolio.setOrders(ordersWithPlatform);
return {
currency: aBaseCurrency,
value: portfolio.getValue(aDate)
};
}
}

@ -0,0 +1,6 @@
import { Currency } from '@prisma/client';
export interface Data {
currency: Currency;
value: number;
}

@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import { InfoService } from './info.service';
import { InfoItem } from './interfaces/info-item.interface';
@Controller('info')
export class InfoController {
public constructor(private readonly infoService: InfoService) {}
@Get()
public async getInfo(): Promise<InfoItem> {
return this.infoService.get();
}
}

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PrismaService } from '../../services/prisma.service';
import { InfoController } from './info.controller';
import { InfoService } from './info.service';
@Module({
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
})
],
controllers: [InfoController],
providers: [InfoService, PrismaService]
})
export class InfoModule {}

@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client';
import { PrismaService } from '../../services/prisma.service';
import { InfoItem } from './interfaces/info-item.interface';
@Injectable()
export class InfoService {
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
public constructor(
private jwtService: JwtService,
private prisma: PrismaService
) {}
public async get(): Promise<InfoItem> {
const platforms = await this.prisma.platform.findMany({
orderBy: { name: 'asc' },
select: { id: true, name: true }
});
return {
platforms,
currencies: Object.values(Currency),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering()
};
}
private getDemoAuthToken() {
return this.jwtService.sign({
id: InfoService.DEMO_USER_ID
});
}
private async getLastDataGathering() {
const lastDataGathering = await this.prisma.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' }
});
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
}
}

@ -0,0 +1,12 @@
import { Currency } from '@prisma/client';
export interface InfoItem {
currencies: Currency[];
demoAuthToken: string;
lastDataGathering?: Date;
message?: {
text: string;
type: string;
};
platforms: { id: string; name: string }[];
}

@ -0,0 +1,3 @@
import { UserWithSettings } from './user-with-settings';
export type RequestWithUser = Request & { user: UserWithSettings };

@ -0,0 +1,3 @@
import { Settings, User } from '@prisma/client';
export type UserWithSettings = User & { Settings: Settings };

@ -0,0 +1,29 @@
import { Currency, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateOrderDto {
@IsString()
currency: Currency;
@IsISO8601()
date: string;
@IsNumber()
fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsNumber()
quantity: number;
@IsString()
symbol: string;
@IsString()
type: Type;
@IsNumber()
unitPrice: number;
}

@ -0,0 +1,3 @@
import { Order, Platform } from '@prisma/client';
export type OrderWithPlatform = Order & { Platform?: Platform };

@ -0,0 +1,218 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Order as OrderModel } from '@prisma/client';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
import { nullifyValuesInObjects } from '../../helper/object.helper';
import { ImpersonationService } from '../../services/impersonation.service';
import { CreateOrderDto } from './create-order.dto';
import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@Controller('order')
export class OrderController {
public constructor(
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteOrder
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.orderService.deleteOrder(
{
id_userId: {
id,
userId: this.request.user.id
}
},
this.request.user.id
);
}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getAllOrders(
@Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let orders = await this.orderService.orders({
include: {
Platform: true
},
orderBy: { date: 'desc' },
where: { userId: impersonationUserId || this.request.user.id }
});
if (
impersonationUserId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
}
return orders;
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getOrderById(@Param('id') id: string): Promise<OrderModel> {
return this.orderService.order({
id_userId: {
id,
userId: this.request.user.id
}
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.createOrder
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(data.date);
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.orderService.createOrder(
{
...data,
date,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
} else {
delete data.platformId;
return this.orderService.createOrder(
{
...data,
date,
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.updateOrder
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalOrder = await this.orderService.order({
id_userId: {
id,
userId: this.request.user.id
}
});
const date = parseISO(data.date);
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
} else {
// platformId is null, remove it
delete data.platformId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Platform: originalOrder.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
}
}
}

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { DataGatheringService } from '../../services/data-gathering.service';
import { DataProviderService } from '../../services/data-provider.service';
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ImpersonationService } from '../../services/impersonation.service';
import { PrismaService } from '../../services/prisma.service';
import { CacheService } from '../cache/cache.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';
@Module({
imports: [RedisCacheModule],
controllers: [OrderController],
providers: [
AlphaVantageService,
CacheService,
DataGatheringService,
DataProviderService,
ImpersonationService,
OrderService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class OrderModule {}

@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { Order, Prisma } from '@prisma/client';
import { DataGatheringService } from '../../services/data-gathering.service';
import { PrismaService } from '../../services/prisma.service';
import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { OrderWithPlatform } from './interfaces/order-with-platform.type';
@Injectable()
export class OrderService {
public constructor(
private readonly cacheService: CacheService,
private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
) {}
public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> {
return this.prisma.order.findUnique({
where: orderWhereUniqueInput
});
}
public async orders(params: {
include?: Prisma.OrderInclude;
skip?: number;
take?: number;
cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByInput;
}): Promise<OrderWithPlatform[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.order.findMany({
cursor,
include,
orderBy,
skip,
take,
where
});
}
public async createOrder(
data: Prisma.OrderCreateInput,
aUserId: string
): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
// Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([
{
date: <Date>data.date,
symbol: data.symbol
}
]);
await this.cacheService.flush(aUserId);
return this.prisma.order.create({
data
});
}
public async deleteOrder(
where: Prisma.OrderWhereUniqueInput,
aUserId: string
): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
return this.prisma.order.delete({
where
});
}
public async updateOrder(
params: {
where: Prisma.OrderWhereUniqueInput;
data: Prisma.OrderUpdateInput;
},
aUserId: string
): Promise<Order> {
const { data, where } = params;
this.redisCacheService.remove(`${aUserId}.portfolio`);
// Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([
{
date: <Date>data.date,
symbol: <string>data.symbol
}
]);
await this.cacheService.flush(aUserId);
return this.prisma.order.update({
data,
where
});
}
}

@ -0,0 +1,32 @@
import { Currency, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateOrderDto {
@IsString()
currency: Currency;
@IsISO8601()
date: string;
@IsNumber()
fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsString()
id: string;
@IsNumber()
quantity: number;
@IsString()
symbol: string;
@IsString()
type: Type;
@IsNumber()
unitPrice: number;
}

@ -0,0 +1 @@
export type DateRange = '1d' | '1y' | '5y' | 'max' | 'ytd';

@ -0,0 +1,19 @@
import { Currency } from '@prisma/client';
export interface PortfolioItem {
date: string;
grossPerformancePercent: number;
investment: number;
positions: { [symbol: string]: Position };
value: number;
}
export interface Position {
averagePrice: number;
currency: Currency;
firstBuyDate: string;
investment: number;
investmentInOriginalCurrency?: number;
marketPrice?: number;
quantity: number;
}

@ -0,0 +1,7 @@
export interface PortfolioOverview {
committedFunds: number;
fees: number;
ordersCount: number;
totalBuy: number;
totalSell: number;
}

@ -0,0 +1,7 @@
export interface PortfolioPerformance {
currentGrossPerformance: number;
currentGrossPerformancePercent: number;
currentNetPerformance: number;
currentNetPerformancePercent: number;
currentValue: number;
}

@ -0,0 +1,21 @@
export interface PortfolioPositionDetail {
averagePrice: number;
currency: string;
firstBuyDate: string;
grossPerformance: number;
grossPerformancePercent: number;
historicalData: HistoricalDataItem[];
investment: number;
marketPrice: number;
maxPrice: number;
minPrice: number;
quantity: number;
symbol: string;
}
export interface HistoricalDataItem {
averagePrice?: number;
date: string;
grossPerformancePercent?: number;
value: number;
}

@ -0,0 +1,25 @@
import { Currency } from '@prisma/client';
export interface PortfolioPosition {
currency: Currency;
exchange?: string;
grossPerformance: number;
grossPerformancePercent: number;
industry?: string;
investment: number;
isMarketOpen: boolean;
marketChange?: number;
marketChangePercent?: number;
marketPrice: number;
name: string;
platforms: {
[name: string]: { current: number; original: number };
};
quantity: number;
sector?: string;
shareCurrent: number;
shareInvestment: number;
symbol: string;
type?: string;
url?: string;
}

@ -0,0 +1,9 @@
export interface PortfolioReport {
rules: { [group: string]: PortfolioReportRule[] };
}
export interface PortfolioReportRule {
evaluation: string;
name: string;
value: boolean;
}

@ -0,0 +1,326 @@
import {
Controller,
Get,
Headers,
HttpException,
Inject,
Param,
Query,
Res,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { getPermissions, hasPermission, permissions } from 'libs/helper/src';
import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '../../helper/object.helper';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { ImpersonationService } from '../../services/impersonation.service';
import { RequestWithUser } from '../interfaces/request-with-user.type';
import { PortfolioItem } from './interfaces/portfolio-item.interface';
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
import { PortfolioPerformance } from './interfaces/portfolio-performance.interface';
import {
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { PortfolioPosition } from './interfaces/portfolio-position.interface';
import { PortfolioReport } from './interfaces/portfolio-report.interface';
import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
export class PortfolioController {
public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
private portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async findAll(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioItem[]> {
let portfolio = await this.portfolioService.findAll(impersonationId);
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
portfolio = portfolio.map((portfolioItem) => {
Object.keys(portfolioItem.positions).forEach((symbol) => {
portfolioItem.positions[symbol].investment =
portfolioItem.positions[symbol].investment > 0 ? 1 : 0;
portfolioItem.positions[symbol].investmentInOriginalCurrency =
portfolioItem.positions[symbol].investmentInOriginalCurrency > 0
? 1
: 0;
portfolioItem.positions[symbol].quantity =
portfolioItem.positions[symbol].quantity > 0 ? 1 : 0;
});
portfolioItem.investment = null;
return portfolioItem;
});
}
return portfolio;
}
@Get('chart')
@UseGuards(AuthGuard('jwt'))
public async getChart(
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
): Promise<HistoricalDataItem[]> {
let chartData = await this.portfolioService.getChart(
impersonationId,
range
);
let hasNullValue = false;
chartData.forEach((chartDataItem) => {
if (hasNotDefinedValuesInObject(chartDataItem)) {
hasNullValue = true;
}
});
if (hasNullValue) {
res.status(StatusCodes.ACCEPTED);
}
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
let maxValue = 0;
chartData.forEach((portfolioItem) => {
if (portfolioItem.value > maxValue) {
maxValue = portfolioItem.value;
}
});
chartData = chartData.map((historicalDataItem) => {
return {
...historicalDataItem,
marketPrice: Number((historicalDataItem.value / maxValue).toFixed(2))
};
});
}
return <any>res.json(chartData);
}
@Get('details')
@UseGuards(AuthGuard('jwt'))
public async getDetails(
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
): Promise<{ [symbol: string]: PortfolioPosition }> {
let details: { [symbol: string]: PortfolioPosition } = {};
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
);
try {
details = await portfolio.getDetails(range);
} catch (error) {
console.error(error);
res.status(StatusCodes.ACCEPTED);
}
if (hasNotDefinedValuesInObject(details)) {
res.status(StatusCodes.ACCEPTED);
}
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
const totalInvestment = Object.values(details)
.map((portfolioPosition) => {
return portfolioPosition.investment;
})
.reduce((a, b) => a + b, 0);
const totalValue = Object.values(details)
.map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user.Settings.currency
);
})
.reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(details)) {
portfolioPosition.grossPerformance = null;
portfolioPosition.investment =
portfolioPosition.investment / totalInvestment;
for (const [platform, { current, original }] of Object.entries(
portfolioPosition.platforms
)) {
portfolioPosition.platforms[platform].current = current / totalValue;
portfolioPosition.platforms[platform].original =
original / totalInvestment;
}
portfolioPosition.quantity = null;
}
}
return <any>res.json(details);
}
@Get('overview')
@UseGuards(AuthGuard('jwt'))
public async getOverview(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioOverview> {
let overview = await this.portfolioService.getOverview(impersonationId);
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
overview = nullifyValuesInObject(overview, [
'committedFunds',
'fees',
'totalBuy',
'totalSell'
]);
}
return overview;
}
@Get('performance')
@UseGuards(AuthGuard('jwt'))
public async getPerformance(
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
): Promise<PortfolioPerformance> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
);
let performance = await portfolio.getPerformance(range);
if (hasNotDefinedValuesInObject(performance)) {
res.status(StatusCodes.ACCEPTED);
}
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
performance = nullifyValuesInObject(performance, [
'currentGrossPerformance',
'currentNetPerformance',
'currentValue'
]);
}
return <any>res.json(performance);
}
@Get('position/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getPosition(
@Headers('impersonation-id') impersonationId,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition(
impersonationId,
symbol
);
if (position) {
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
position = nullifyValuesInObject(position, ['grossPerformance']);
}
return position;
}
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
@Get('report')
@UseGuards(AuthGuard('jwt'))
public async getReport(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioReport> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
);
let report = await portfolio.getReport();
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
// TODO: Filter out absolute numbers
}
return report;
}
}

@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { DataGatheringService } from '../../services/data-gathering.service';
import { DataProviderService } from '../../services/data-provider.service';
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { ImpersonationService } from '../../services/impersonation.service';
import { PrismaService } from '../../services/prisma.service';
import { RulesService } from '../../services/rules.service';
import { CacheService } from '../cache/cache.service';
import { OrderService } from '../order/order.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { UserService } from '../user/user.service';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
@Module({
imports: [RedisCacheModule],
controllers: [PortfolioController],
providers: [
AlphaVantageService,
CacheService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
ImpersonationService,
OrderService,
PortfolioService,
PrismaService,
RakutenRapidApiService,
RulesService,
UserService,
YahooFinanceService
]
})
export class PortfolioModule {}

@ -0,0 +1,385 @@
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import {
add,
format,
getDate,
getMonth,
getYear,
isAfter,
isSameDay,
parse,
parseISO,
setDate,
setMonth,
sub
} from 'date-fns';
import { isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { Portfolio } from '../../models/portfolio';
import { DataProviderService } from '../../services/data-provider.service';
import { ExchangeRateDataService } from '../../services/exchange-rate-data.service';
import { ImpersonationService } from '../../services/impersonation.service';
import { IOrder } from '../../services/interfaces/interfaces';
import { RulesService } from '../../services/rules.service';
import { OrderService } from '../order/order.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { UserService } from '../user/user.service';
import { DateRange } from './interfaces/date-range.type';
import { PortfolioItem } from './interfaces/portfolio-item.interface';
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
import {
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
@Injectable()
export class PortfolioService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService,
private readonly userService: UserService
) {}
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
let currentDate = new Date();
const normalizedMinDate =
getDate(aMinDate) === 1
? aMinDate
: add(setDate(aMinDate, 1), { months: 1 });
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
currentDate = new Date(Date.UTC(year, month, day, 0));
switch (aDateRange) {
case '1d':
return sub(currentDate, {
days: 1
});
case 'ytd':
currentDate = setDate(currentDate, 1);
currentDate = setMonth(currentDate, 0);
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '1y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 1
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '5y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 5
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
default:
// Gets handled as all data
return undefined;
}
}
public async createPortfolio(aUserId: string): Promise<Portfolio> {
let portfolio: Portfolio;
let stringifiedPortfolio = await this.redisCacheService.get(
`${aUserId}.portfolio`
);
const user = await this.userService.user({ id: aUserId });
if (stringifiedPortfolio) {
// Get portfolio from redis
const {
orders,
portfolioItems
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = JSON.parse(
stringifiedPortfolio
);
portfolio = new Portfolio(
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
).createFromData({ orders, portfolioItems, user });
} else {
// Get portfolio from database
const orders = await this.orderService.orders({
include: {
Platform: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
});
portfolio = new Portfolio(
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
);
portfolio.setUser(user);
await portfolio.setOrders(orders);
// Cache data for the next time...
const portfolioData = {
orders: portfolio.getOrders(),
portfolioItems: portfolio.getPortfolioItems()
};
await this.redisCacheService.set(
`${aUserId}.portfolio`,
JSON.stringify(portfolioData)
);
}
// Enrich portfolio with current data
return await portfolio.addCurrentPortfolioItems();
}
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
try {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
return portfolio.get();
} catch (error) {
console.error(error);
}
}
public async getChart(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
if (portfolio.getOrders().length <= 0) {
return [];
}
const dateRangeDate = this.convertDateRangeToDate(
aDateRange,
portfolio.getMinDate()
);
return portfolio
.get()
.filter((portfolioItem) => {
if (dateRangeDate === undefined) {
return true;
}
return (
isSameDay(parseISO(portfolioItem.date), dateRangeDate) ||
isAfter(parseISO(portfolioItem.date), dateRangeDate)
);
})
.map((portfolioItem) => {
return {
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
grossPerformancePercent: portfolioItem.grossPerformancePercent,
marketPrice: portfolioItem.value || null,
value: portfolioItem.value || null
};
});
}
public async getOverview(
aImpersonationId: string
): Promise<PortfolioOverview> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const committedFunds = portfolio.getCommittedFunds();
const fees = portfolio.getFees();
return {
committedFunds,
fees,
ordersCount: portfolio.getOrders().length,
totalBuy: portfolio.getTotalBuy(),
totalSell: portfolio.getTotalSell()
};
}
public async getPosition(
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const positions = portfolio.getPositions(new Date())[aSymbol];
if (positions) {
let {
averagePrice,
currency,
firstBuyDate,
investment,
marketPrice,
quantity
} = portfolio.getPositions(new Date())[aSymbol];
const historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
parseISO(firstBuyDate),
new Date()
);
if (marketPrice === 0) {
marketPrice = averagePrice;
}
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = marketPrice;
let minPrice = marketPrice;
if (historicalData[aSymbol]) {
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
)) {
historicalDataArray.push({
averagePrice,
date,
value: marketPrice
});
if (
marketPrice &&
(marketPrice > maxPrice || maxPrice === undefined)
) {
maxPrice = marketPrice;
}
if (
marketPrice &&
(marketPrice < minPrice || minPrice === undefined)
) {
minPrice = marketPrice;
}
}
}
return {
averagePrice,
currency,
firstBuyDate,
investment,
marketPrice,
maxPrice,
minPrice,
quantity,
grossPerformance: this.exchangeRateDataService.toCurrency(
marketPrice - averagePrice,
currency,
this.request.user.Settings.currency
),
grossPerformancePercent: roundTo(
(marketPrice - averagePrice) / averagePrice,
4
),
historicalData: historicalDataArray,
symbol: aSymbol
};
} else if (portfolio.getMinDate()) {
const currentData = await this.dataProviderService.get([aSymbol]);
let historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
portfolio.getMinDate(),
new Date()
);
if (isEmpty(historicalData)) {
historicalData = await this.dataProviderService.getHistoricalRaw(
[aSymbol],
portfolio.getMinDate(),
new Date()
);
}
const historicalDataArray: HistoricalDataItem[] = [];
for (const [date, { marketPrice, performance }] of Object.entries(
historicalData[aSymbol]
).reverse()) {
historicalDataArray.push({
date,
value: marketPrice
});
}
return {
averagePrice: undefined,
currency: currentData[aSymbol].currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: undefined,
marketPrice: currentData[aSymbol].marketPrice,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol
};
}
return {
averagePrice: undefined,
currency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: [],
investment: undefined,
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol
};
}
}

@ -0,0 +1,24 @@
import { CacheModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import * as redisStore from 'cache-manager-redis-store';
import { RedisCacheService } from './redis-cache.service';
@Module({
imports: [
CacheModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
host: configService.get('REDIS_HOST'),
max: configService.get('MAX_ITEM_IN_CACHE'),
port: configService.get('REDIS_PORT'),
store: redisStore,
ttl: configService.get('CACHE_TTL')
})
})
],
providers: [RedisCacheService],
exports: [RedisCacheService]
})
export class RedisCacheModule {}

@ -0,0 +1,23 @@
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
@Injectable()
export class RedisCacheService {
public constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
public async get(key: string): Promise<string> {
return await this.cache.get(key);
}
public async remove(key: string) {
await this.cache.del(key);
}
public async reset() {
await this.cache.reset();
}
public async set(key: string, value: string) {
await this.cache.set(key, value, { ttl: Number(process.env.CACHE_TTL) });
}
}

@ -0,0 +1,4 @@
export interface LookupItem {
name: string;
symbol: string;
}

@ -0,0 +1,6 @@
import { Currency } from '@prisma/client';
export interface SymbolItem {
currency: Currency;
marketPrice: number;
}

@ -0,0 +1,50 @@
import {
Controller,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { RequestWithUser } from 'apps/api/src/app/interfaces/request-with-user.type';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
import { SymbolService } from './symbol.service';
@Controller('symbol')
export class SymbolController {
public constructor(
private readonly symbolService: SymbolService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
/**
* Must be before /:symbol
*/
@Get('lookup')
@UseGuards(AuthGuard('jwt'))
public async lookupSymbol(@Query() { query }): Promise<LookupItem[]> {
try {
return this.symbolService.lookup(query);
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
/**
* Must be after /lookup
*/
@Get(':symbol')
@UseGuards(AuthGuard('jwt'))
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> {
return this.symbolService.get(symbol);
}
}

@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { DataProviderService } from '../../services/data-provider.service';
import { AlphaVantageService } from '../../services/data-provider/alpha-vantage/alpha-vantage.service';
import { RakutenRapidApiService } from '../../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../../services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '../../services/prisma.service';
import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service';
@Module({
imports: [],
controllers: [SymbolController],
providers: [
AlphaVantageService,
DataProviderService,
PrismaService,
RakutenRapidApiService,
SymbolService,
YahooFinanceService
]
})
export class SymbolModule {}

@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { convertFromYahooSymbol } from 'apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service';
import * as bent from 'bent';
import { DataProviderService } from '../../services/data-provider.service';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable()
export class SymbolService {
public constructor(
private readonly dataProviderService: DataProviderService
) {}
public async get(aSymbol: string): Promise<SymbolItem> {
const response = await this.dataProviderService.get([aSymbol]);
const { currency, marketPrice } = response[aSymbol];
return {
marketPrice,
currency: <Currency>(<unknown>currency)
};
}
public async lookup(aQuery: string): Promise<LookupItem[]> {
const get = bent(
`https://query1.finance.yahoo.com/v1/finance/search?q=${aQuery}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=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…
Cancel
Save