Compare commits
2 Commits
2.99.0
...
feature/ch
Author | SHA1 | Date | |
---|---|---|---|
5799b9e71c | |||
188389d26c |
25
.env.dev
25
.env.dev
@ -1,25 +0,0 @@
|
|||||||
COMPOSE_PROJECT_NAME=ghostfolio-development
|
|
||||||
|
|
||||||
# CACHE
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
|
||||||
|
|
||||||
# POSTGRES
|
|
||||||
POSTGRES_DB=ghostfolio-db
|
|
||||||
POSTGRES_USER=user
|
|
||||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
|
||||||
|
|
||||||
# VARIOUS
|
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
|
||||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
|
||||||
|
|
||||||
# DEVELOPMENT
|
|
||||||
|
|
||||||
# Nx 18 enables using plugins to infer targets by default
|
|
||||||
# This is disabled for existing workspaces to maintain compatibility
|
|
||||||
# For more info, see: https://nx.dev/concepts/inferred-tasks
|
|
||||||
NX_ADD_PLUGINS=false
|
|
||||||
|
|
||||||
NX_NATIVE_COMMAND_RUNNER=false
|
|
@ -1,4 +1,4 @@
|
|||||||
COMPOSE_PROJECT_NAME=ghostfolio
|
COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||||
|
|
||||||
# CACHE
|
# CACHE
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
@ -10,7 +10,6 @@ POSTGRES_DB=ghostfolio-db
|
|||||||
POSTGRES_USER=user
|
POSTGRES_USER=user
|
||||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
# VARIOUS
|
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
|
@ -24,18 +24,12 @@
|
|||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx"],
|
"files": ["*.ts", "*.tsx"],
|
||||||
"extends": ["plugin:@nx/typescript"],
|
"extends": ["plugin:@nx/typescript"],
|
||||||
"rules": {
|
"rules": {}
|
||||||
"@typescript-eslint/no-extra-semi": "error",
|
|
||||||
"no-extra-semi": "off"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.js", "*.jsx"],
|
"files": ["*.js", "*.jsx"],
|
||||||
"extends": ["plugin:@nx/javascript"],
|
"extends": ["plugin:@nx/javascript"],
|
||||||
"rules": {
|
"rules": {}
|
||||||
"@typescript-eslint/no-extra-semi": "error",
|
|
||||||
"no-extra-semi": "off"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.ts"],
|
"files": ["*.ts"],
|
||||||
|
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,13 +6,7 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
**Important Notice**
|
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
The issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
|
||||||
|
|
||||||
Incomplete or non-reproducible issues may be closed, but we are here to help! If you encounter difficulties reproducing the bug or need assistance, please reach out to our community channels mentioned above.
|
|
||||||
|
|
||||||
Thank you for your understanding and cooperation!
|
|
||||||
|
|
||||||
**Bug Description**
|
**Bug Description**
|
||||||
|
|
||||||
@ -42,9 +36,8 @@ Thank you for your understanding and cooperation!
|
|||||||
|
|
||||||
<!-- Please complete the following information -->
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
- Ghostfolio Version X.Y.Z
|
|
||||||
- Cloud or Self-hosted
|
- Cloud or Self-hosted
|
||||||
- Experimental Features enabled or disabled
|
- Ghostfolio Version X.Y.Z
|
||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
|
||||||
|
12
.github/workflows/build-code.yml
vendored
12
.github/workflows/build-code.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node_version:
|
node_version:
|
||||||
- 20
|
- 18
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@ -24,16 +24,16 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node_version }}
|
node-version: ${{ matrix.node_version }}
|
||||||
cache: 'npm'
|
cache: 'yarn'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: npm run format:check
|
run: yarn format:check
|
||||||
|
|
||||||
- name: Execute tests
|
- name: Execute tests
|
||||||
run: npm test
|
run: yarn test
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: npm run build:production
|
run: yarn build:production
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -5,8 +5,8 @@
|
|||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
/.yarn
|
||||||
/node_modules
|
/node_modules
|
||||||
npm-debug.log
|
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
/.idea
|
/.idea
|
||||||
@ -28,14 +28,15 @@ npm-debug.log
|
|||||||
.env
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
.nx/cache
|
.nx/cache
|
||||||
.nx/workspace-data
|
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/dist
|
/dist
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
|
npm-debug.log
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
/.nx/cache
|
/.nx/cache
|
||||||
/.nx/workspace-data
|
|
||||||
/apps/client/src/polyfills.ts
|
|
||||||
/dist
|
/dist
|
||||||
/test/import
|
/test/import
|
||||||
|
21
.prettierrc
21
.prettierrc
@ -9,26 +9,7 @@
|
|||||||
],
|
],
|
||||||
"attributeSort": "ASC",
|
"attributeSort": "ASC",
|
||||||
"endOfLine": "auto",
|
"endOfLine": "auto",
|
||||||
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
|
"plugins": ["prettier-plugin-organize-attributes"],
|
||||||
"importOrderSeparation": true,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "*.html",
|
|
||||||
"options": {
|
|
||||||
"parser": "angular"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": "*.ts",
|
|
||||||
"options": {
|
|
||||||
"importOrderParserPlugins": ["decorators-legacy", "typescript"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"plugins": [
|
|
||||||
"prettier-plugin-organize-attributes",
|
|
||||||
"@trivago/prettier-plugin-sort-imports"
|
|
||||||
],
|
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
|
1049
CHANGELOG.md
1049
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,7 @@ Remove permission in `UserService` using `without()`
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
@ -30,26 +30,26 @@ Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
|||||||
|
|
||||||
#### Upgrade
|
#### Upgrade
|
||||||
|
|
||||||
1. Run `npx nx migrate latest`
|
1. Run `yarn nx migrate latest`
|
||||||
1. Make sure `package.json` changes make sense and then run `npm install`
|
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||||
1. Run `npx nx migrate --run-migrations`
|
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
#### Access database via GUI
|
#### Access database via GUI
|
||||||
|
|
||||||
Run `npm run database:gui`
|
Run `yarn database:gui`
|
||||||
|
|
||||||
https://www.prisma.io/studio
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
#### Synchronize schema with database for prototyping
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
Run `npm run database:push`
|
Run `yarn database:push`
|
||||||
|
|
||||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||||
|
|
||||||
#### Create schema migration
|
#### Create schema migration
|
||||||
|
|
||||||
Run `npm run prisma migrate dev --name added_job_title`
|
Run `yarn prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
||||||
|
42
Dockerfile
42
Dockerfile
@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
@ -8,17 +8,18 @@ WORKDIR /ghostfolio
|
|||||||
COPY ./CHANGELOG.md CHANGELOG.md
|
COPY ./CHANGELOG.md CHANGELOG.md
|
||||||
COPY ./LICENSE LICENSE
|
COPY ./LICENSE LICENSE
|
||||||
COPY ./package.json package.json
|
COPY ./package.json package.json
|
||||||
COPY ./package-lock.json package-lock.json
|
COPY ./yarn.lock yarn.lock
|
||||||
|
COPY ./.yarnrc .yarnrc
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
g++ \
|
git \
|
||||||
git \
|
g++ \
|
||||||
make \
|
make \
|
||||||
openssl \
|
openssl \
|
||||||
python3 \
|
python3 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN npm install
|
RUN yarn install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
@ -32,34 +33,29 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
|||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
COPY ./apps apps
|
COPY ./apps apps
|
||||||
|
|
||||||
RUN npm run build:production
|
RUN yarn build:production
|
||||||
|
|
||||||
# Prepare the dist image with additional node_modules
|
# Prepare the dist image with additional node_modules
|
||||||
WORKDIR /ghostfolio/dist/apps/api
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
# package.json was generated by the build process, however the original
|
# package.json was generated by the build process, however the original
|
||||||
# package-lock.json needs to be used to ensure the same versions
|
# yarn.lock needs to be used to ensure the same versions
|
||||||
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||||
|
|
||||||
RUN npm install
|
RUN yarn
|
||||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||||
|
|
||||||
# Overwrite the generated package.json with the original one to ensure having
|
# Overwrite the generated package.json with the original one to ensure having
|
||||||
# all the scripts
|
# all the scripts
|
||||||
COPY package.json /ghostfolio/dist/apps/api
|
COPY package.json /ghostfolio/dist/apps/api
|
||||||
RUN npm run database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:20-slim
|
FROM node:18-slim
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
|
||||||
|
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
curl \
|
openssl \
|
||||||
openssl \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE ${PORT:-3333}
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "/ghostfolio/entrypoint.sh" ]
|
CMD [ "yarn", "start:production" ]
|
||||||
|
106
README.md
106
README.md
@ -7,12 +7,14 @@
|
|||||||
**Open Source Wealth Management Software**
|
**Open Source Wealth Management Software**
|
||||||
|
|
||||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
|
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||||
|
|
||||||
[](https://www.buymeacoffee.com/ghostfolio)
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
[](#contributing)
|
[](#contributing)
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
|
|
||||||
|
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||||
@ -47,7 +49,7 @@ Ghostfolio is for you if you are...
|
|||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ Multi account management
|
||||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||||
- ✅ Various charts
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Import and export transactions
|
- ✅ Import and export transactions
|
||||||
@ -85,23 +87,19 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Type | Default Value | Description |
|
| Name | Default Value | Description |
|
||||||
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
| `API_KEY_COINGECKO_DEMO` | `string` (optional) | | The _CoinGecko_ Demo API key |
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
|
|
||||||
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
|
|
||||||
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
|
|
||||||
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
|
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -117,7 +115,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file ./.env -f docker/docker-compose.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### b. Build and run environment
|
#### b. Build and run environment
|
||||||
@ -125,8 +123,8 @@ docker compose --env-file ./.env -f docker/docker-compose.yml up -d
|
|||||||
Run the following commands to build and start the Docker images:
|
Run the following commands to build and start the Docker images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file ./.env -f docker/docker-compose.build.yml build
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||||
docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup
|
#### Setup
|
||||||
@ -137,61 +135,61 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
|||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
1. Run the following command to start the new Docker image: `docker compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
At each start, the container will automatically apply the database schema migrations if needed.
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
### Home Server Systems (Community)
|
### Home Server Systems (Community)
|
||||||
|
|
||||||
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||||
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- Create a local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `npm install`
|
1. Run `yarn install`
|
||||||
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `npm run database:setup` to initialize the database schema
|
1. Run `yarn database:setup` to initialize the database schema
|
||||||
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
1. Open https://localhost:4200/en in your browser
|
1. Open http://localhost:4200/en in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
|
|
||||||
### Start Server
|
### Start Server
|
||||||
|
|
||||||
#### Debug
|
#### Debug
|
||||||
|
|
||||||
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
|
|
||||||
#### Serve
|
#### Serve
|
||||||
|
|
||||||
Run `npm run start:server`
|
Run `yarn start:server`
|
||||||
|
|
||||||
### Start Client
|
### Start Client
|
||||||
|
|
||||||
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
||||||
|
|
||||||
### Start _Storybook_
|
### Start _Storybook_
|
||||||
|
|
||||||
Run `npm run start:storybook`
|
Run `yarn start:storybook`
|
||||||
|
|
||||||
### Migrate Database
|
### Migrate Database
|
||||||
|
|
||||||
With the following command you can keep your database schema in sync:
|
With the following command you can keep your database schema in sync:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run database:push
|
yarn database:push
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run `npm test`
|
Run `yarn test`
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
@ -203,7 +201,7 @@ Set the header for each request as follows:
|
|||||||
"Authorization": "Bearer eyJh..."
|
"Authorization": "Bearer eyJh..."
|
||||||
```
|
```
|
||||||
|
|
||||||
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
|
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
||||||
|
|
||||||
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||||
|
|
||||||
@ -232,18 +230,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
|
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
|
||||||
| `accountId` | `string` (optional) | Id of the account |
|
| accountId | string (`optional`) | Id of the account |
|
||||||
| `comment` | `string` (optional) | Comment of the activity |
|
| comment | string (`optional`) | Comment of the activity |
|
||||||
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
|
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
| `date` | `string` | Date in the format `ISO-8601` |
|
| date | string | Date in the format `ISO-8601` |
|
||||||
| `fee` | `number` | Fee of the activity |
|
| fee | number | Fee of the activity |
|
||||||
| `quantity` | `number` | Quantity of the activity |
|
| quantity | number | Quantity of the activity |
|
||||||
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
|
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||||
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||||
| `unitPrice` | `number` | Price per unit of the activity |
|
| unitPrice | number | Price per unit of the activity |
|
||||||
|
|
||||||
#### Response
|
#### Response
|
||||||
|
|
||||||
@ -274,16 +272,12 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
|||||||
|
|
||||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
|
|
||||||
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
|
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
## Analytics
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)
|
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
@ -13,6 +13,7 @@ export default {
|
|||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
|
testTimeout: 10000,
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
preset: '../../jest.preset.js'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -7,15 +7,14 @@
|
|||||||
"generators": {},
|
"generators": {},
|
||||||
"targets": {
|
"targets": {
|
||||||
"build": {
|
"build": {
|
||||||
"executor": "@nx/webpack:webpack",
|
"executor": "@nrwl/webpack:webpack",
|
||||||
"options": {
|
"options": {
|
||||||
"compiler": "tsc",
|
|
||||||
"deleteOutputPath": false,
|
|
||||||
"main": "apps/api/src/main.ts",
|
|
||||||
"outputPath": "dist/apps/api",
|
"outputPath": "dist/apps/api",
|
||||||
"sourceMap": true,
|
"main": "apps/api/src/main.ts",
|
||||||
"target": "node",
|
|
||||||
"tsConfig": "apps/api/tsconfig.app.json",
|
"tsConfig": "apps/api/tsconfig.app.json",
|
||||||
|
"assets": ["apps/api/src/assets"],
|
||||||
|
"target": "node",
|
||||||
|
"compiler": "tsc",
|
||||||
"webpackConfig": "apps/api/webpack.config.js"
|
"webpackConfig": "apps/api/webpack.config.js"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
@ -34,26 +33,6 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["{options.outputPath}"]
|
"outputs": ["{options.outputPath}"]
|
||||||
},
|
},
|
||||||
"copy-assets": {
|
|
||||||
"executor": "nx:run-commands",
|
|
||||||
"options": {
|
|
||||||
"commands": [
|
|
||||||
{
|
|
||||||
"command": "shx rm -rf dist/apps/api"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "shx mkdir -p dist/apps/api/assets/locales"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parallel": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nx/js:node",
|
"executor": "@nx/js:node",
|
||||||
"options": {
|
"options": {
|
||||||
@ -61,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"executor": "@nx/eslint:lint",
|
"executor": "@nrwl/linter:eslint",
|
||||||
"options": {
|
"options": {
|
||||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -28,12 +24,11 @@ import { CreateAccessDto } from './create-access.dto';
|
|||||||
export class AccessController {
|
export class AccessController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getAllAccesses(): Promise<Access[]> {
|
public async getAllAccesses(): Promise<Access[]> {
|
||||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||||
include: {
|
include: {
|
||||||
@ -43,38 +38,32 @@ export class AccessController {
|
|||||||
where: { userId: this.request.user.id }
|
where: { userId: this.request.user.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
return accessesWithGranteeUser.map(
|
return accessesWithGranteeUser.map((access) => {
|
||||||
({ alias, GranteeUser, id, permissions }) => {
|
if (access.GranteeUser) {
|
||||||
if (GranteeUser) {
|
|
||||||
return {
|
|
||||||
alias,
|
|
||||||
id,
|
|
||||||
permissions,
|
|
||||||
grantee: GranteeUser?.id,
|
|
||||||
type: 'PRIVATE'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
alias: access.alias,
|
||||||
id,
|
grantee: access.GranteeUser?.id,
|
||||||
permissions,
|
id: access.id,
|
||||||
grantee: 'Public',
|
type: 'RESTRICTED_VIEW'
|
||||||
type: 'PUBLIC'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
return {
|
||||||
|
alias: access.alias,
|
||||||
|
grantee: 'Public',
|
||||||
|
id: access.id,
|
||||||
|
type: 'PUBLIC'
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.createAccess)
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async createAccess(
|
public async createAccess(
|
||||||
@Body() data: CreateAccessDto
|
@Body() data: CreateAccessDto
|
||||||
): Promise<AccessModel> {
|
): Promise<AccessModel> {
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -82,30 +71,25 @@ export class AccessController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return this.accessService.createAccess({
|
||||||
return this.accessService.createAccess({
|
alias: data.alias || undefined,
|
||||||
alias: data.alias || undefined,
|
GranteeUser: data.granteeUserId
|
||||||
GranteeUser: data.granteeUserId
|
? { connect: { id: data.granteeUserId } }
|
||||||
? { connect: { id: data.granteeUserId } }
|
: undefined,
|
||||||
: undefined,
|
User: { connect: { id: this.request.user.id } }
|
||||||
permissions: data.permissions,
|
});
|
||||||
User: { connect: { id: this.request.user.id } }
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
|
||||||
StatusCodes.BAD_REQUEST
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HasPermission(permissions.deleteAccess)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
||||||
const access = await this.accessService.access({ id });
|
const access = await this.accessService.access({ id });
|
||||||
|
|
||||||
if (!access || access.userId !== this.request.user.id) {
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
|
||||||
|
!access ||
|
||||||
|
access.userId !== this.request.user.id
|
||||||
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccessController } from './access.controller';
|
import { AccessController } from './access.controller';
|
||||||
@ -9,7 +7,7 @@ import { AccessService } from './access.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
exports: [AccessService],
|
exports: [AccessService],
|
||||||
imports: [ConfigurationModule, PrismaModule],
|
imports: [PrismaModule],
|
||||||
providers: [AccessService]
|
providers: [AccessService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Access, Prisma } from '@prisma/client';
|
import { Access, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { AccessPermission } from '@prisma/client';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateAccessDto {
|
export class CreateAccessDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -7,10 +6,10 @@ export class CreateAccessDto {
|
|||||||
alias?: string;
|
alias?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsString()
|
||||||
granteeUserId?: string;
|
granteeUserId?: string;
|
||||||
|
|
||||||
@IsEnum(AccessPermission, { each: true })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
permissions?: AccessPermission[];
|
@IsString()
|
||||||
|
type?: 'PUBLIC';
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,7 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Body,
|
|
||||||
Post,
|
|
||||||
Delete,
|
Delete,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
@ -20,56 +14,31 @@ import { AccountBalance } from '@prisma/client';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AccountBalanceService } from './account-balance.service';
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
|
||||||
|
|
||||||
@Controller('account-balance')
|
@Controller('account-balance')
|
||||||
export class AccountBalanceController {
|
export class AccountBalanceController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountBalanceService: AccountBalanceService,
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly accountService: AccountService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HasPermission(permissions.createAccountBalance)
|
|
||||||
@Post()
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async createAccountBalance(
|
|
||||||
@Body() data: CreateAccountBalanceDto
|
|
||||||
): Promise<AccountBalance> {
|
|
||||||
const account = await this.accountService.account({
|
|
||||||
id_userId: {
|
|
||||||
id: data.accountId,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.accountBalanceService.createOrUpdateAccountBalance({
|
|
||||||
accountId: account.id,
|
|
||||||
balance: data.balance,
|
|
||||||
date: data.date,
|
|
||||||
userId: account.userId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@HasPermission(permissions.deleteAccountBalance)
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteAccountBalance(
|
public async deleteAccountBalance(
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountBalance> {
|
): Promise<AccountBalance> {
|
||||||
const accountBalance = await this.accountBalanceService.accountBalance({
|
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||||
id,
|
id
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!accountBalance) {
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.deleteAccountBalance
|
||||||
|
) ||
|
||||||
|
!accountBalance ||
|
||||||
|
accountBalance.userId !== this.request.user.id
|
||||||
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -77,8 +46,7 @@ export class AccountBalanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.accountBalanceService.deleteAccountBalance({
|
return this.accountBalanceService.deleteAccountBalance({
|
||||||
id: accountBalance.id,
|
id
|
||||||
userId: accountBalance.userId
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccountBalanceController } from './account-balance.controller';
|
import { AccountBalanceController } from './account-balance.controller';
|
||||||
@ -11,6 +9,6 @@ import { AccountBalanceService } from './account-balance.service';
|
|||||||
controllers: [AccountBalanceController],
|
controllers: [AccountBalanceController],
|
||||||
exports: [AccountBalanceService],
|
exports: [AccountBalanceService],
|
||||||
imports: [ExchangeRateDataModule, PrismaModule],
|
imports: [ExchangeRateDataModule, PrismaModule],
|
||||||
providers: [AccountBalanceService, AccountService]
|
providers: [AccountBalanceService]
|
||||||
})
|
})
|
||||||
export class AccountBalanceModule {}
|
export class AccountBalanceModule {}
|
||||||
|
@ -1,21 +1,13 @@
|
|||||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
|
||||||
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import { AccountBalance, Prisma } from '@prisma/client';
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
import { parseISO } from 'date-fns';
|
|
||||||
|
|
||||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountBalanceService {
|
export class AccountBalanceService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly eventEmitter: EventEmitter2,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
@ -31,63 +23,20 @@ export class AccountBalanceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createOrUpdateAccountBalance({
|
public async createAccountBalance(
|
||||||
accountId,
|
data: Prisma.AccountBalanceCreateInput
|
||||||
balance,
|
): Promise<AccountBalance> {
|
||||||
date,
|
return this.prismaService.accountBalance.create({
|
||||||
userId
|
data
|
||||||
}: CreateAccountBalanceDto & {
|
|
||||||
userId: string;
|
|
||||||
}): Promise<AccountBalance> {
|
|
||||||
const accountBalance = await this.prismaService.accountBalance.upsert({
|
|
||||||
create: {
|
|
||||||
Account: {
|
|
||||||
connect: {
|
|
||||||
id_userId: {
|
|
||||||
userId,
|
|
||||||
id: accountId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
date: resetHours(parseISO(date)),
|
|
||||||
value: balance
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
value: balance
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
accountId_date: {
|
|
||||||
accountId,
|
|
||||||
date: resetHours(parseISO(date))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return accountBalance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAccountBalance(
|
public async deleteAccountBalance(
|
||||||
where: Prisma.AccountBalanceWhereUniqueInput
|
where: Prisma.AccountBalanceWhereUniqueInput
|
||||||
): Promise<AccountBalance> {
|
): Promise<AccountBalance> {
|
||||||
const accountBalance = await this.prismaService.accountBalance.delete({
|
return this.prismaService.accountBalance.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: <string>where.userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return accountBalance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccountBalances({
|
public async getAccountBalances({
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateAccountBalanceDto {
|
|
||||||
@IsUUID()
|
|
||||||
accountId: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
balance: number;
|
|
||||||
|
|
||||||
@IsISO8601()
|
|
||||||
date: string;
|
|
||||||
}
|
|
@ -1,20 +1,17 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AccountBalancesResponse,
|
AccountBalancesResponse,
|
||||||
Accounts
|
Accounts
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -50,9 +47,17 @@ export class AccountController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HasPermission(permissions.deleteAccount)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const account = await this.accountService.accountWithOrders(
|
const account = await this.accountService.accountWithOrders(
|
||||||
{
|
{
|
||||||
id_userId: {
|
id_userId: {
|
||||||
@ -63,7 +68,7 @@ export class AccountController {
|
|||||||
{ Order: true }
|
{ Order: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!account || account?.Order.length > 0) {
|
if (account?.isDefault || account?.Order.length > 0) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -82,7 +87,7 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||||
@ -97,7 +102,7 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountById(
|
public async getAccountById(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@ -117,7 +122,7 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/balances')
|
@Get(':id/balances')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountBalancesById(
|
public async getAccountBalancesById(
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
@ -128,12 +133,20 @@ export class AccountController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.createAccount)
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async createAccount(
|
public async createAccount(
|
||||||
@Body() data: CreateAccountDto
|
@Body() data: CreateAccountDto
|
||||||
): Promise<AccountModel> {
|
): Promise<AccountModel> {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.platformId) {
|
if (data.platformId) {
|
||||||
const platformId = data.platformId;
|
const platformId = data.platformId;
|
||||||
delete data.platformId;
|
delete data.platformId;
|
||||||
@ -159,12 +172,20 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.updateAccount)
|
|
||||||
@Post('transfer-balance')
|
@Post('transfer-balance')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async transferAccountBalance(
|
public async transferAccountBalance(
|
||||||
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const accountsOfUser = await this.accountService.getAccounts(
|
const accountsOfUser = await this.accountService.getAccounts(
|
||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
@ -213,10 +234,18 @@ export class AccountController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.updateAccount)
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const originalAccount = await this.accountService.account({
|
const originalAccount = await this.accountService.account({
|
||||||
id_userId: {
|
id_userId: {
|
||||||
id,
|
id,
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccountController } from './account.controller';
|
import { AccountController } from './account.controller';
|
||||||
@ -17,11 +18,13 @@ import { AccountService } from './account.service';
|
|||||||
imports: [
|
imports: [
|
||||||
AccountBalanceModule,
|
AccountBalanceModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedactValuesInResponseModule
|
RedisCacheModule,
|
||||||
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [AccountService]
|
providers: [AccountService]
|
||||||
})
|
})
|
||||||
|
@ -1,15 +1,10 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import Big from 'big.js';
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
@ -18,7 +13,6 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
|||||||
export class AccountService {
|
export class AccountService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountBalanceService: AccountBalanceService,
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
@ -26,8 +20,10 @@ export class AccountService {
|
|||||||
public async account({
|
public async account({
|
||||||
id_userId
|
id_userId
|
||||||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||||
|
const { id, userId } = id_userId;
|
||||||
|
|
||||||
const [account] = await this.accounts({
|
const [account] = await this.accounts({
|
||||||
where: id_userId
|
where: { id, userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
@ -90,20 +86,17 @@ export class AccountService {
|
|||||||
data
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
await this.prismaService.accountBalance.create({
|
||||||
accountId: account.id,
|
data: {
|
||||||
balance: data.balance,
|
Account: {
|
||||||
date: format(new Date(), DATE_FORMAT),
|
connect: {
|
||||||
userId: aUserId
|
id_userId: { id: account.id, userId: aUserId }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: data.balance
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: account.userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,18 +104,9 @@ export class AccountService {
|
|||||||
where: Prisma.AccountWhereUniqueInput,
|
where: Prisma.AccountWhereUniqueInput,
|
||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
const account = await this.prismaService.account.delete({
|
return this.prismaService.account.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: account.userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string): Promise<Account[]> {
|
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||||
@ -174,8 +158,8 @@ export class AccountService {
|
|||||||
ACCOUNT: filtersByAccount,
|
ACCOUNT: filtersByAccount,
|
||||||
ASSET_CLASS: filtersByAssetClass,
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
TAG: filtersByTag
|
TAG: filtersByTag
|
||||||
} = groupBy(filters, ({ type }) => {
|
} = groupBy(filters, (filter) => {
|
||||||
return type;
|
return filter.type;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filtersByAccount?.length > 0) {
|
if (filtersByAccount?.length > 0) {
|
||||||
@ -213,26 +197,21 @@ export class AccountService {
|
|||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
|
|
||||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
await this.prismaService.accountBalance.create({
|
||||||
accountId: <string>data.id,
|
data: {
|
||||||
balance: <number>data.balance,
|
Account: {
|
||||||
date: format(new Date(), DATE_FORMAT),
|
connect: {
|
||||||
userId: aUserId
|
id_userId: where.id_userId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: <number>data.balance
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const account = await this.prismaService.account.update({
|
return this.prismaService.account.update({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: account.userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateAccountBalance({
|
public async updateAccountBalance({
|
||||||
@ -264,11 +243,17 @@ export class AccountService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (amountInCurrencyOfAccount) {
|
if (amountInCurrencyOfAccount) {
|
||||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
await this.accountBalanceService.createAccountBalance({
|
||||||
accountId,
|
date,
|
||||||
userId,
|
Account: {
|
||||||
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
|
connect: {
|
||||||
date: date.toISOString()
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
|
||||||
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@ -21,7 +19,7 @@ export class CreateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsCurrencyCode()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
|
||||||
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@ -21,7 +19,7 @@ export class UpdateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsCurrencyCode()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ -1,30 +1,27 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
|
||||||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
getAssetProfileIdentifier,
|
||||||
|
resetHours
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
EnhancedSymbolProfile
|
EnhancedSymbolProfile
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
MarketDataPreset,
|
MarketDataPreset,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -32,7 +29,6 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Logger,
|
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@ -58,34 +54,65 @@ export class AdminController {
|
|||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly manualService: ManualService,
|
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getAdminData(): Promise<AdminData> {
|
public async getAdminData(): Promise<AdminData> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.adminService.get();
|
return this.adminService.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('gather')
|
@Post('gather')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gather7Days(): Promise<void> {
|
public async gather7Days(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gather7Days();
|
this.dataGatheringService.gather7Days();
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
const assetProfileIdentifiers =
|
if (
|
||||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -94,8 +121,7 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -104,15 +130,25 @@ export class AdminController {
|
|||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherProfileData(): Promise<void> {
|
||||||
const assetProfileIdentifiers =
|
if (
|
||||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -121,21 +157,31 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherProfileDataForSymbol(
|
public async gatherProfileDataForSymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue({
|
await this.dataGatheringService.addJobToQueue({
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -144,32 +190,53 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
public async gatherSymbol(
|
public async gatherSymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('gather/:dataSource/:symbol/:dateString')
|
@Post('gather/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherSymbolForDate(
|
public async gatherSymbolForDate(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<MarketData> {
|
): Promise<MarketData> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const date = parseISO(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
if (!isDate(date)) {
|
if (!isDate(date)) {
|
||||||
@ -187,8 +254,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
@Query('presetId') presetId?: MarketDataPreset,
|
@Query('presetId') presetId?: MarketDataPreset,
|
||||||
@ -198,6 +264,18 @@ export class AdminController {
|
|||||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@Query('take') take?: number
|
@Query('take') take?: number
|
||||||
): Promise<AdminMarketData> {
|
): Promise<AdminMarketData> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAssetSubClasses,
|
filterByAssetSubClasses,
|
||||||
filterBySearchQuery
|
filterBySearchQuery
|
||||||
@ -214,53 +292,51 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getMarketDataBySymbol(
|
public async getMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<AdminMarketDataDetails> {
|
): Promise<AdminMarketDataDetails> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('market-data/:dataSource/:symbol/test')
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async testMarketData(
|
|
||||||
@Body() data: { scraperConfiguration: string },
|
|
||||||
@Param('dataSource') dataSource: DataSource,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
): Promise<{ price: number }> {
|
|
||||||
try {
|
|
||||||
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
|
|
||||||
const price = await this.manualService.test(scraperConfiguration);
|
|
||||||
|
|
||||||
if (price) {
|
|
||||||
return { price };
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Could not parse the current market price');
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error);
|
|
||||||
|
|
||||||
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('market-data/:dataSource/:symbol')
|
@Post('market-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateMarketData(
|
public async updateMarketData(
|
||||||
@Body() data: UpdateBulkMarketDataDto,
|
@Body() data: UpdateBulkMarketDataDto,
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||||
({ date, marketPrice }) => ({
|
({ date, marketPrice }) => ({
|
||||||
dataSource,
|
dataSource,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
symbol,
|
symbol,
|
||||||
date: parseISO(date),
|
date: resetHours(parseISO(date)),
|
||||||
state: 'CLOSE'
|
state: 'CLOSE'
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -273,15 +349,26 @@ export class AdminController {
|
|||||||
/**
|
/**
|
||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(
|
public async update(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@Body() data: UpdateMarketDataDto
|
@Body() data: UpdateMarketDataDto
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const date = parseISO(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
return this.marketDataService.updateMarketData({
|
return this.marketDataService.updateMarketData({
|
||||||
@ -296,14 +383,24 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('profile-data/:dataSource/:symbol')
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async addProfileData(
|
public async addProfileData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<SymbolProfile | never> {
|
): Promise<SymbolProfile | never> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.adminService.addAssetProfile({
|
return this.adminService.addAssetProfile({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
@ -312,23 +409,45 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deleteProfileData(
|
public async deleteProfileData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Patch('profile-data/:dataSource/:symbol')
|
@Patch('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async patchAssetProfileData(
|
public async patchAssetProfileData(
|
||||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<EnhancedSymbolProfile> {
|
): Promise<EnhancedSymbolProfile> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.adminService.patchAssetProfileData({
|
return this.adminService.patchAssetProfileData({
|
||||||
...assetProfileData,
|
...assetProfileData,
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -336,13 +455,24 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
@Param('key') key: string,
|
@Param('key') key: string,
|
||||||
@Body() data: PropertyDto
|
@Body() data: PropertyDto
|
||||||
) {
|
) {
|
||||||
return this.adminService.putSetting(key, data.value);
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.adminService.putSetting(key, data.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,4 @@
|
|||||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
@ -11,7 +8,6 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
@ -21,19 +17,16 @@ import { QueueModule } from './queue/queue.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ApiModule,
|
ApiModule,
|
||||||
BenchmarkModule,
|
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
OrderModule,
|
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule
|
||||||
TransformDataSourceInRequestModule
|
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [AdminService],
|
providers: [AdminService],
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
|
||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
@ -15,25 +13,20 @@ import {
|
|||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
EnhancedSymbolProfile,
|
|
||||||
Filter,
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
DataSource,
|
DataSource,
|
||||||
Prisma,
|
Prisma,
|
||||||
PrismaClient,
|
|
||||||
Property,
|
Property,
|
||||||
SymbolProfile
|
SymbolProfile
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
@ -43,12 +36,10 @@ import { groupBy } from 'lodash';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly benchmarkService: BenchmarkService,
|
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly orderService: OrderService,
|
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
@ -79,7 +70,7 @@ export class AdminService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.symbolProfileService.add(
|
return await this.symbolProfileService.add(
|
||||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -154,16 +145,7 @@ export class AdminService {
|
|||||||
[{ symbol: 'asc' }];
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
if (presetId === 'BENCHMARKS') {
|
if (presetId === 'CURRENCIES') {
|
||||||
const benchmarkAssetProfiles =
|
|
||||||
await this.benchmarkService.getBenchmarkAssetProfiles();
|
|
||||||
|
|
||||||
where.id = {
|
|
||||||
in: benchmarkAssetProfiles.map(({ id }) => {
|
|
||||||
return id;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
} else if (presetId === 'CURRENCIES') {
|
|
||||||
return this.getMarketDataForCurrencies();
|
return this.getMarketDataForCurrencies();
|
||||||
} else if (
|
} else if (
|
||||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||||
@ -194,7 +176,6 @@ export class AdminService {
|
|||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
|
||||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||||
@ -213,129 +194,101 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extendedPrismaClient = this.getExtendedPrismaClient();
|
let [assetProfiles, count] = await Promise.all([
|
||||||
|
this.prismaService.symbolProfile.findMany({
|
||||||
try {
|
orderBy,
|
||||||
let [assetProfiles, count] = await Promise.all([
|
skip,
|
||||||
extendedPrismaClient.symbolProfile.findMany({
|
take,
|
||||||
orderBy,
|
where,
|
||||||
skip,
|
select: {
|
||||||
take,
|
_count: {
|
||||||
where,
|
select: { Order: true }
|
||||||
select: {
|
},
|
||||||
_count: {
|
assetClass: true,
|
||||||
select: { Order: true }
|
assetSubClass: true,
|
||||||
},
|
comment: true,
|
||||||
assetClass: true,
|
countries: true,
|
||||||
assetSubClass: true,
|
currency: true,
|
||||||
comment: true,
|
dataSource: true,
|
||||||
countries: true,
|
name: true,
|
||||||
currency: true,
|
Order: {
|
||||||
dataSource: true,
|
orderBy: [{ date: 'asc' }],
|
||||||
id: true,
|
select: { date: true },
|
||||||
isUsedByUsersWithSubscription: true,
|
take: 1
|
||||||
name: true,
|
},
|
||||||
Order: {
|
scraperConfiguration: true,
|
||||||
orderBy: [{ date: 'asc' }],
|
sectors: true,
|
||||||
select: { date: true },
|
symbol: true
|
||||||
take: 1
|
|
||||||
},
|
|
||||||
scraperConfiguration: true,
|
|
||||||
sectors: true,
|
|
||||||
symbol: true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
this.prismaService.symbolProfile.count({ where })
|
|
||||||
]);
|
|
||||||
|
|
||||||
let marketData: AdminMarketDataItem[] = await Promise.all(
|
|
||||||
assetProfiles.map(
|
|
||||||
async ({
|
|
||||||
_count,
|
|
||||||
assetClass,
|
|
||||||
assetSubClass,
|
|
||||||
comment,
|
|
||||||
countries,
|
|
||||||
currency,
|
|
||||||
dataSource,
|
|
||||||
id,
|
|
||||||
isUsedByUsersWithSubscription,
|
|
||||||
name,
|
|
||||||
Order,
|
|
||||||
sectors,
|
|
||||||
symbol
|
|
||||||
}) => {
|
|
||||||
const countriesCount = countries
|
|
||||||
? Object.keys(countries).length
|
|
||||||
: 0;
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketDataItems.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === dataSource &&
|
|
||||||
marketDataItem.symbol === symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
assetClass,
|
|
||||||
assetSubClass,
|
|
||||||
comment,
|
|
||||||
currency,
|
|
||||||
countriesCount,
|
|
||||||
dataSource,
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
symbol,
|
|
||||||
marketDataItemCount,
|
|
||||||
sectorsCount,
|
|
||||||
activitiesCount: _count.Order,
|
|
||||||
date: Order?.[0]?.date,
|
|
||||||
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (presetId) {
|
|
||||||
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
|
||||||
marketData = marketData.filter(({ countriesCount }) => {
|
|
||||||
return countriesCount === 0;
|
|
||||||
});
|
|
||||||
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
|
||||||
marketData = marketData.filter(({ sectorsCount }) => {
|
|
||||||
return sectorsCount === 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
this.prismaService.symbolProfile.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
count = marketData.length;
|
let marketData = assetProfiles.map(
|
||||||
|
({
|
||||||
|
_count,
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
comment,
|
||||||
|
countries,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
name,
|
||||||
|
Order,
|
||||||
|
sectors,
|
||||||
|
symbol
|
||||||
|
}) => {
|
||||||
|
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketDataItems.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
comment,
|
||||||
|
currency,
|
||||||
|
countriesCount,
|
||||||
|
dataSource,
|
||||||
|
name,
|
||||||
|
symbol,
|
||||||
|
marketDataItemCount,
|
||||||
|
sectorsCount,
|
||||||
|
activitiesCount: _count.Order,
|
||||||
|
date: Order?.[0]?.date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (presetId) {
|
||||||
|
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
||||||
|
marketData = marketData.filter(({ countriesCount }) => {
|
||||||
|
return countriesCount === 0;
|
||||||
|
});
|
||||||
|
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
||||||
|
marketData = marketData.filter(({ sectorsCount }) => {
|
||||||
|
return sectorsCount === 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
count = marketData.length;
|
||||||
count,
|
|
||||||
marketData
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
await extendedPrismaClient.$disconnect();
|
|
||||||
|
|
||||||
Logger.debug('Disconnect extended prisma client', 'AdminService');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
marketData
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketDataBySymbol({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
|
||||||
let currency: EnhancedSymbolProfile['currency'] = '-';
|
|
||||||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
|
||||||
|
|
||||||
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
|
||||||
currency = getCurrencyFromSymbol(symbol);
|
|
||||||
({ activitiesCount, dateOfFirstActivity } =
|
|
||||||
await this.orderService.getStatisticsByCurrency(currency));
|
|
||||||
}
|
|
||||||
|
|
||||||
const [[assetProfile], marketData] = await Promise.all([
|
const [[assetProfile], marketData] = await Promise.all([
|
||||||
this.symbolProfileService.getSymbolProfiles([
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
{
|
{
|
||||||
@ -354,20 +307,11 @@ export class AdminService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (assetProfile) {
|
|
||||||
assetProfile.dataProviderInfo = this.dataProviderService
|
|
||||||
.getDataProvider(assetProfile.dataSource)
|
|
||||||
.getDataProviderInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketData,
|
marketData,
|
||||||
assetProfile: assetProfile ?? {
|
assetProfile: assetProfile ?? {
|
||||||
activitiesCount,
|
symbol,
|
||||||
currency,
|
currency: '-'
|
||||||
dataSource,
|
|
||||||
dateOfFirstActivity,
|
|
||||||
symbol
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -376,48 +320,22 @@ export class AdminService {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
countries,
|
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
holdings,
|
|
||||||
name,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
sectors,
|
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping,
|
symbolMapping
|
||||||
url
|
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
const symbolProfileOverrides = {
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
assetClass: assetClass as AssetClass,
|
assetClass,
|
||||||
assetSubClass: assetSubClass as AssetSubClass,
|
assetSubClass,
|
||||||
name: name as string,
|
comment,
|
||||||
url: url as string
|
dataSource,
|
||||||
};
|
name,
|
||||||
|
scraperConfiguration,
|
||||||
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
|
symbol,
|
||||||
{
|
symbolMapping
|
||||||
comment,
|
});
|
||||||
countries,
|
|
||||||
currency,
|
|
||||||
dataSource,
|
|
||||||
holdings,
|
|
||||||
scraperConfiguration,
|
|
||||||
sectors,
|
|
||||||
symbol,
|
|
||||||
symbolMapping,
|
|
||||||
...(dataSource === 'MANUAL'
|
|
||||||
? { assetClass, assetSubClass, name, url }
|
|
||||||
: {
|
|
||||||
SymbolProfileOverrides: {
|
|
||||||
upsert: {
|
|
||||||
create: symbolProfileOverrides,
|
|
||||||
update: symbolProfileOverrides
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
|
||||||
|
|
||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
{
|
{
|
||||||
@ -447,97 +365,35 @@ export class AdminService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getExtendedPrismaClient() {
|
|
||||||
Logger.debug('Connect extended prisma client', 'AdminService');
|
|
||||||
|
|
||||||
const symbolProfileExtension = Prisma.defineExtension((client) => {
|
|
||||||
return client.$extends({
|
|
||||||
result: {
|
|
||||||
symbolProfile: {
|
|
||||||
isUsedByUsersWithSubscription: {
|
|
||||||
compute: async ({ id }) => {
|
|
||||||
const { _count } =
|
|
||||||
await this.prismaService.symbolProfile.findUnique({
|
|
||||||
select: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
Order: {
|
|
||||||
where: {
|
|
||||||
User: {
|
|
||||||
Subscription: {
|
|
||||||
some: {
|
|
||||||
expiresAt: {
|
|
||||||
gt: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return _count.Order > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return new PrismaClient().$extends(symbolProfileExtension);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
const marketDataPromise: Promise<AdminMarketDataItem>[] =
|
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
|
||||||
this.exchangeRateDataService
|
.getCurrencyPairs()
|
||||||
.getCurrencyPairs()
|
.map(({ dataSource, symbol }) => {
|
||||||
.map(async ({ dataSource, symbol }) => {
|
const marketDataItemCount =
|
||||||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
marketDataItems.find((marketDataItem) => {
|
||||||
let currency: EnhancedSymbolProfile['currency'] = '-';
|
return (
|
||||||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
|
||||||
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
return {
|
||||||
currency = getCurrencyFromSymbol(symbol);
|
dataSource,
|
||||||
({ activitiesCount, dateOfFirstActivity } =
|
marketDataItemCount,
|
||||||
await this.orderService.getStatisticsByCurrency(currency));
|
symbol,
|
||||||
}
|
assetClass: 'CASH',
|
||||||
|
countriesCount: 0,
|
||||||
|
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||||
|
name: symbol,
|
||||||
|
sectorsCount: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketDataItems.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === dataSource &&
|
|
||||||
marketDataItem.symbol === symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
activitiesCount,
|
|
||||||
currency,
|
|
||||||
dataSource,
|
|
||||||
marketDataItemCount,
|
|
||||||
symbol,
|
|
||||||
assetClass: AssetClass.LIQUIDITY,
|
|
||||||
assetSubClass: AssetSubClass.CASH,
|
|
||||||
countriesCount: 0,
|
|
||||||
date: dateOfFirstActivity,
|
|
||||||
id: undefined,
|
|
||||||
name: symbol,
|
|
||||||
sectorsCount: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const marketData = await Promise.all(marketDataPromise);
|
|
||||||
return { marketData, count: marketData.length };
|
return { marketData, count: marketData.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -576,14 +432,13 @@ export class AdminService {
|
|||||||
},
|
},
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
id: true,
|
id: true,
|
||||||
role: true,
|
|
||||||
Subscription: true
|
Subscription: true
|
||||||
},
|
},
|
||||||
take: 30
|
take: 30
|
||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, Analytics, createdAt, id, role, Subscription }) => {
|
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics
|
const engagement = Analytics
|
||||||
@ -593,17 +448,13 @@ export class AdminService {
|
|||||||
const subscription = this.configurationService.get(
|
const subscription = this.configurationService.get(
|
||||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
)
|
)
|
||||||
? this.subscriptionService.getSubscription({
|
? this.subscriptionService.getSubscription(Subscription)
|
||||||
createdAt,
|
|
||||||
subscriptions: Subscription
|
|
||||||
})
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createdAt,
|
createdAt,
|
||||||
engagement,
|
engagement,
|
||||||
id,
|
id,
|
||||||
role,
|
|
||||||
subscription,
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
country: Analytics?.country,
|
country: Analytics?.country,
|
||||||
|
@ -1,56 +1,87 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { JobStatus } from 'bull';
|
import { JobStatus } from 'bull';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { QueueService } from './queue.service';
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
@Controller('admin/queue')
|
@Controller('admin/queue')
|
||||||
export class QueueController {
|
export class QueueController {
|
||||||
public constructor(private readonly queueService: QueueService) {}
|
public constructor(
|
||||||
|
private readonly queueService: QueueService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
@Delete('job')
|
@Delete('job')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deleteJobs(
|
public async deleteJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
return this.queueService.deleteJobs({ status });
|
return this.queueService.deleteJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('job')
|
@Get('job')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getJobs(
|
public async getJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<AdminJobs> {
|
): Promise<AdminJobs> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
return this.queueService.getJobs({ status });
|
return this.queueService.getJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('job/:id')
|
@Delete('job/:id')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deleteJob(@Param('id') id: string): Promise<void> {
|
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.queueService.deleteJob(id);
|
return this.queueService.deleteJob(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('job/:id/execute')
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async executeJob(@Param('id') id: string): Promise<void> {
|
|
||||||
return this.queueService.executeJob(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { QueueController } from './queue.controller';
|
import { QueueController } from './queue.controller';
|
||||||
|
@ -3,7 +3,6 @@ import {
|
|||||||
QUEUE_JOB_STATUS_LIST
|
QUEUE_JOB_STATUS_LIST
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JobStatus, Queue } from 'bull';
|
import { JobStatus, Queue } from 'bull';
|
||||||
@ -32,10 +31,6 @@ export class QueueService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async executeJob(aId: string) {
|
|
||||||
return (await this.dataGatheringQueue.getJob(aId))?.promote();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getJobs({
|
public async getJobs({
|
||||||
limit = 1000,
|
limit = 1000,
|
||||||
status = QUEUE_JOB_STATUS_LIST
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
@ -58,7 +53,6 @@ export class QueueService {
|
|||||||
finishedOn: job.finishedOn,
|
finishedOn: job.finishedOn,
|
||||||
id: job.id,
|
id: job.id,
|
||||||
name: job.name,
|
name: job.name,
|
||||||
opts: job.opts,
|
|
||||||
stacktrace: job.stacktrace,
|
stacktrace: job.stacktrace,
|
||||||
state: await job.getState(),
|
state: await job.getState(),
|
||||||
timestamp: job.timestamp
|
timestamp: job.timestamp
|
||||||
|
@ -1,14 +1,5 @@
|
|||||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
|
||||||
|
|
||||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||||
import {
|
import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
IsArray,
|
|
||||||
IsEnum,
|
|
||||||
IsObject,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUrl
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
@IsEnum(AssetClass, { each: true })
|
@IsEnum(AssetClass, { each: true })
|
||||||
@ -23,14 +14,6 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
@IsOptional()
|
|
||||||
countries?: Prisma.InputJsonArray;
|
|
||||||
|
|
||||||
@IsCurrencyCode()
|
|
||||||
@IsOptional()
|
|
||||||
currency?: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -39,20 +22,9 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
scraperConfiguration?: Prisma.InputJsonObject;
|
scraperConfiguration?: Prisma.InputJsonObject;
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
@IsOptional()
|
|
||||||
sectors?: Prisma.InputJsonArray;
|
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
symbolMapping?: {
|
symbolMapping?: {
|
||||||
[dataProvider: string]: string;
|
[dataProvider: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl({
|
|
||||||
protocols: ['https'],
|
|
||||||
require_protocol: true
|
|
||||||
})
|
|
||||||
url?: string;
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
|
@ -1,31 +1,27 @@
|
|||||||
import { EventsModule } from '@ghostfolio/api/events/events.module';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
SUPPORTED_LANGUAGE_CODES
|
SUPPORTED_LANGUAGE_CODES
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
import { AccessModule } from './access/access.module';
|
import { AccessModule } from './access/access.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AssetModule } from './asset/asset.module';
|
|
||||||
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||||
@ -47,18 +43,15 @@ import { TagModule } from './tag/tag.module';
|
|||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AppController],
|
|
||||||
imports: [
|
imports: [
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AccessModule,
|
AccessModule,
|
||||||
AccountModule,
|
AccountModule,
|
||||||
AssetModule,
|
|
||||||
AuthDeviceModule,
|
AuthDeviceModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
BenchmarkModule,
|
BenchmarkModule,
|
||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
redis: {
|
redis: {
|
||||||
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
|
||||||
host: process.env.REDIS_HOST,
|
host: process.env.REDIS_HOST,
|
||||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||||
password: process.env.REDIS_PASSWORD
|
password: process.env.REDIS_PASSWORD
|
||||||
@ -69,8 +62,6 @@ import { UserModule } from './user/user.module';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
EventEmitterModule.forRoot(),
|
|
||||||
EventsModule,
|
|
||||||
ExchangeRateModule,
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
@ -82,7 +73,6 @@ import { UserModule } from './user/user.module';
|
|||||||
PlatformModule,
|
PlatformModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
@ -116,6 +106,7 @@ import { UserModule } from './user/user.module';
|
|||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
|
controllers: [AppController],
|
||||||
providers: [CronService]
|
providers: [CronService]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
@ -1,29 +0,0 @@
|
|||||||
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
|
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
|
||||||
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
|
|
||||||
|
|
||||||
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
|
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { pick } from 'lodash';
|
|
||||||
|
|
||||||
@Controller('asset')
|
|
||||||
export class AssetController {
|
|
||||||
public constructor(private readonly adminService: AdminService) {}
|
|
||||||
|
|
||||||
@Get(':dataSource/:symbol')
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
||||||
public async getAsset(
|
|
||||||
@Param('dataSource') dataSource: DataSource,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
): Promise<AdminMarketDataDetails> {
|
|
||||||
const { assetProfile, marketData } =
|
|
||||||
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
|
||||||
|
|
||||||
return {
|
|
||||||
marketData,
|
|
||||||
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
|
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
|
||||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { AssetController } from './asset.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [AssetController],
|
|
||||||
imports: [
|
|
||||||
AdminModule,
|
|
||||||
TransformDataSourceInRequestModule,
|
|
||||||
TransformDataSourceInResponseModule
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class AssetModule {}
|
|
@ -1,19 +1,40 @@
|
|||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import {
|
||||||
|
Controller,
|
||||||
import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
|
Delete,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@Controller('auth-device')
|
@Controller('auth-device')
|
||||||
export class AuthDeviceController {
|
export class AuthDeviceController {
|
||||||
public constructor(private readonly authDeviceService: AuthDeviceService) {}
|
public constructor(
|
||||||
|
private readonly authDeviceService: AuthDeviceService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HasPermission(permissions.deleteAuthDevice)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.deleteAuthDevice
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.authDeviceService.deleteAuthDevice({ id });
|
await this.authDeviceService.deleteAuthDevice({ id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthDeviceController],
|
controllers: [AuthDeviceController],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AuthDevice, Prisma } from '@prisma/client';
|
import { AuthDevice, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthDeviceService {
|
export class AuthDeviceService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
public async authDevice(
|
public async authDevice(
|
||||||
where: Prisma.AuthDeviceWhereUniqueInput
|
where: Prisma.AuthDeviceWhereUniqueInput
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -120,13 +118,13 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('webauthn/generate-registration-options')
|
@Get('webauthn/generate-registration-options')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async generateRegistrationOptions() {
|
public async generateRegistrationOptions() {
|
||||||
return this.webAuthService.generateRegistrationOptions();
|
return this.webAuthService.generateRegistrationOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('webauthn/verify-attestation')
|
@Post('webauthn/verify-attestation')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async verifyAttestation(
|
public async verifyAttestation(
|
||||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||||
) {
|
) {
|
||||||
|
@ -5,7 +5,6 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
|
||||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
import { Profile, Strategy } from 'passport-google-oauth20';
|
import { Strategy } from 'passport-google-oauth20';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@ -11,7 +10,7 @@ import { AuthService } from './auth.service';
|
|||||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly configurationService: ConfigurationService
|
readonly configurationService: ConfigurationService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
callbackURL: `${configurationService.get(
|
callbackURL: `${configurationService.get(
|
||||||
@ -20,7 +19,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
||||||
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
||||||
passReqToCallback: true,
|
passReqToCallback: true,
|
||||||
scope: ['profile']
|
scope: ['email', 'profile']
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,17 +27,20 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
request: any,
|
request: any,
|
||||||
token: string,
|
token: string,
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
profile: Profile,
|
profile,
|
||||||
done: Function,
|
done: Function,
|
||||||
done2: Function
|
done2: Function
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const jwt = await this.authService.validateOAuthLogin({
|
const jwt: string = await this.authService.validateOAuthLogin({
|
||||||
provider: Provider.GOOGLE,
|
provider: Provider.GOOGLE,
|
||||||
thirdPartyId: profile.id
|
thirdPartyId: profile.id
|
||||||
});
|
});
|
||||||
|
const user = {
|
||||||
|
jwt
|
||||||
|
};
|
||||||
|
|
||||||
done(null, { jwt });
|
done(null, user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'GoogleStrategy');
|
Logger.error(error, 'GoogleStrategy');
|
||||||
done(error, false);
|
done(error, false);
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||||
|
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
|
||||||
export interface AuthDeviceDialogParams {
|
export interface AuthDeviceDialogParams {
|
||||||
|
@ -2,12 +2,9 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||||
import { hasRole } from '@ghostfolio/common/permissions';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -31,13 +28,6 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
if (hasRole(user, 'INACTIVE')) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const country =
|
const country =
|
||||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||||
|
|
||||||
@ -54,20 +44,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
} else {
|
} else {
|
||||||
throw new HttpException(
|
throw '';
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
|
|
||||||
throw error;
|
|
||||||
} else {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
|
||||||
StatusCodes.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new UnauthorizedException('unauthorized', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,6 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
@ -41,7 +40,7 @@ export class WebAuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
get rpID() {
|
get rpID() {
|
||||||
return new URL(this.configurationService.get('ROOT_URL')).hostname;
|
return this.configurationService.get('WEB_AUTH_RP_ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
get expectedOrigin() {
|
get expectedOrigin() {
|
||||||
|
@ -1,16 +1,12 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
|
||||||
import type {
|
import type {
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -20,7 +16,6 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -38,10 +33,21 @@ export class BenchmarkController {
|
|||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const benchmark = await this.benchmarkService.addBenchmark({
|
const benchmark = await this.benchmarkService.addBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -65,12 +71,23 @@ export class BenchmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':dataSource/:symbol')
|
@Delete(':dataSource/:symbol')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deleteBenchmark(
|
public async deleteBenchmark(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const benchmark = await this.benchmarkService.deleteBenchmark({
|
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -103,26 +120,19 @@ export class BenchmarkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:startDateString')
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getBenchmarkMarketDataForUser(
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('startDateString') startDateString: string,
|
@Param('startDateString') startDateString: string,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string
|
||||||
@Query('range') dateRange: DateRange = 'max'
|
|
||||||
): Promise<BenchmarkMarketDataDetails> {
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
const { endDate, startDate } = getInterval(
|
const startDate = new Date(startDateString);
|
||||||
dateRange,
|
|
||||||
new Date(startDateString)
|
|
||||||
);
|
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
|
||||||
|
|
||||||
return this.benchmarkService.getMarketDataForUser({
|
return this.benchmarkService.getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
endDate,
|
|
||||||
startDate,
|
startDate,
|
||||||
symbol,
|
symbol
|
||||||
userCurrency
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { BenchmarkController } from './benchmark.controller';
|
import { BenchmarkController } from './benchmark.controller';
|
||||||
@ -18,16 +15,14 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
controllers: [BenchmarkController],
|
controllers: [BenchmarkController],
|
||||||
exports: [BenchmarkService],
|
exports: [BenchmarkService],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule
|
||||||
TransformDataSourceInRequestModule,
|
|
||||||
TransformDataSourceInResponseModule
|
|
||||||
],
|
],
|
||||||
providers: [BenchmarkService]
|
providers: [BenchmarkService]
|
||||||
})
|
})
|
||||||
|
@ -11,7 +11,6 @@ describe('BenchmarkService', () => {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
@ -12,9 +11,7 @@ import {
|
|||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
calculateBenchmarkTrend,
|
calculateBenchmarkTrend
|
||||||
parseDate,
|
|
||||||
resetHours
|
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
Benchmark,
|
Benchmark,
|
||||||
@ -24,18 +21,11 @@ import {
|
|||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import { format, subDays } from 'date-fns';
|
||||||
differenceInDays,
|
import { uniqBy } from 'lodash';
|
||||||
eachDayOfInterval,
|
|
||||||
format,
|
|
||||||
isSameDay,
|
|
||||||
subDays
|
|
||||||
} from 'date-fns';
|
|
||||||
import { isNumber, last, uniqBy } from 'lodash';
|
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -44,7 +34,6 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@ -117,9 +106,7 @@ export class BenchmarkService {
|
|||||||
const quotes = await this.dataProviderService.getQuotes({
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
}),
|
})
|
||||||
requestTimeout: ms('30 seconds'),
|
|
||||||
useCache: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
@ -135,7 +122,7 @@ export class BenchmarkService {
|
|||||||
Promise.all(promisesAllTimeHighs),
|
Promise.all(promisesAllTimeHighs),
|
||||||
Promise.all(promisesBenchmarkTrends)
|
Promise.all(promisesBenchmarkTrends)
|
||||||
]);
|
]);
|
||||||
let storeInCache = useCache;
|
let storeInCache = true;
|
||||||
|
|
||||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
const { marketPrice } =
|
const { marketPrice } =
|
||||||
@ -153,7 +140,6 @@ export class BenchmarkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dataSource: benchmarkAssetProfiles[index].dataSource,
|
|
||||||
marketCondition: this.getMarketCondition(
|
marketCondition: this.getMarketCondition(
|
||||||
performancePercentFromAllTimeHigh
|
performancePercentFromAllTimeHigh
|
||||||
),
|
),
|
||||||
@ -161,13 +147,9 @@ export class BenchmarkService {
|
|||||||
performances: {
|
performances: {
|
||||||
allTimeHigh: {
|
allTimeHigh: {
|
||||||
date: allTimeHigh?.date,
|
date: allTimeHigh?.date,
|
||||||
performancePercent:
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
performancePercentFromAllTimeHigh >= 0
|
|
||||||
? 0
|
|
||||||
: performancePercentFromAllTimeHigh
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
symbol: benchmarkAssetProfiles[index].symbol,
|
|
||||||
trend50d: benchmarkTrends[index].trend50d,
|
trend50d: benchmarkTrends[index].trend50d,
|
||||||
trend200d: benchmarkTrends[index].trend200d
|
trend200d: benchmarkTrends[index].trend200d
|
||||||
};
|
};
|
||||||
@ -177,7 +159,7 @@ export class BenchmarkService {
|
|||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
this.CACHE_KEY_BENCHMARKS,
|
this.CACHE_KEY_BENCHMARKS,
|
||||||
JSON.stringify(benchmarks),
|
JSON.stringify(benchmarks),
|
||||||
ms('2 hours') / 1000
|
ms('4 hours') / 1000
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,30 +200,11 @@ export class BenchmarkService {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketDataForUser({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
endDate = new Date(),
|
|
||||||
startDate,
|
startDate,
|
||||||
symbol,
|
symbol
|
||||||
userCurrency
|
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||||
}: {
|
|
||||||
endDate?: Date;
|
|
||||||
startDate: Date;
|
|
||||||
userCurrency: string;
|
|
||||||
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
|
||||||
const marketData: { date: string; value: number }[] = [];
|
|
||||||
|
|
||||||
const days = differenceInDays(endDate, startDate) + 1;
|
|
||||||
const dates = eachDayOfInterval(
|
|
||||||
{
|
|
||||||
start: startDate,
|
|
||||||
end: endDate
|
|
||||||
},
|
|
||||||
{ step: Math.round(days / Math.min(days, MAX_CHART_ITEMS)) }
|
|
||||||
).map((date) => {
|
|
||||||
return resetHours(date);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
this.symbolService.get({
|
this.symbolService.get({
|
||||||
dataGatheringItem: {
|
dataGatheringItem: {
|
||||||
@ -257,92 +220,50 @@ export class BenchmarkService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: {
|
date: {
|
||||||
in: dates
|
gte: startDate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const exchangeRates =
|
const step = Math.round(
|
||||||
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||||
startDate,
|
|
||||||
currencies: [currentSymbolItem.currency],
|
|
||||||
targetCurrency: userCurrency
|
|
||||||
});
|
|
||||||
|
|
||||||
const exchangeRateAtStartDate =
|
|
||||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
|
||||||
format(startDate, DATE_FORMAT)
|
|
||||||
];
|
|
||||||
|
|
||||||
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
|
|
||||||
return isSameDay(date, startDate);
|
|
||||||
})?.marketPrice;
|
|
||||||
|
|
||||||
if (!marketPriceAtStartDate) {
|
|
||||||
Logger.error(
|
|
||||||
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
|
|
||||||
startDate,
|
|
||||||
DATE_FORMAT
|
|
||||||
)}`,
|
|
||||||
'BenchmarkService'
|
|
||||||
);
|
|
||||||
|
|
||||||
return { marketData };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let marketDataItem of marketDataItems) {
|
|
||||||
const exchangeRate =
|
|
||||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
|
||||||
format(marketDataItem.date, DATE_FORMAT)
|
|
||||||
];
|
|
||||||
|
|
||||||
const exchangeRateFactor =
|
|
||||||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
|
||||||
? exchangeRate / exchangeRateAtStartDate
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
marketData.push({
|
|
||||||
date: format(marketDataItem.date, DATE_FORMAT),
|
|
||||||
value:
|
|
||||||
marketPriceAtStartDate === 0
|
|
||||||
? 0
|
|
||||||
: this.calculateChangeInPercentage(
|
|
||||||
marketPriceAtStartDate,
|
|
||||||
marketDataItem.marketPrice * exchangeRateFactor
|
|
||||||
) * 100
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const includesEndDate = isSameDay(
|
|
||||||
parseDate(last(marketData).date),
|
|
||||||
endDate
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentSymbolItem?.marketPrice && !includesEndDate) {
|
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
||||||
const exchangeRate =
|
const response = {
|
||||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
marketData: [
|
||||||
format(endDate, DATE_FORMAT)
|
...marketDataItems
|
||||||
];
|
.filter((marketDataItem, index) => {
|
||||||
|
return index % step === 0;
|
||||||
|
})
|
||||||
|
.map((marketDataItem) => {
|
||||||
|
return {
|
||||||
|
date: format(marketDataItem.date, DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
marketPriceAtStartDate === 0
|
||||||
|
? 0
|
||||||
|
: this.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
marketDataItem.marketPrice
|
||||||
|
) * 100
|
||||||
|
};
|
||||||
|
})
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
const exchangeRateFactor =
|
if (currentSymbolItem?.marketPrice) {
|
||||||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
response.marketData.push({
|
||||||
? exchangeRate / exchangeRateAtStartDate
|
date: format(new Date(), DATE_FORMAT),
|
||||||
: 1;
|
|
||||||
|
|
||||||
marketData.push({
|
|
||||||
date: format(endDate, DATE_FORMAT),
|
|
||||||
value:
|
value:
|
||||||
this.calculateChangeInPercentage(
|
this.calculateChangeInPercentage(
|
||||||
marketPriceAtStartDate,
|
marketPriceAtStartDate,
|
||||||
currentSymbolItem.marketPrice * exchangeRateFactor
|
currentSymbolItem.marketPrice
|
||||||
) * 100
|
) * 100
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return response;
|
||||||
marketData
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addBenchmark({
|
public async addBenchmark({
|
||||||
@ -422,7 +343,7 @@ export class BenchmarkService {
|
|||||||
private getMarketCondition(
|
private getMarketCondition(
|
||||||
aPerformanceInPercent: number
|
aPerformanceInPercent: number
|
||||||
): Benchmark['marketCondition'] {
|
): Benchmark['marketCondition'] {
|
||||||
if (aPerformanceInPercent >= 0) {
|
if (aPerformanceInPercent === 0) {
|
||||||
return 'ALL_TIME_HIGH';
|
return 'ALL_TIME_HIGH';
|
||||||
} else if (aPerformanceInPercent <= -0.2) {
|
} else if (aPerformanceInPercent <= -0.2) {
|
||||||
return 'BEAR_MARKET';
|
return 'BEAR_MARKET';
|
||||||
|
36
apps/api/src/app/cache/cache.controller.ts
vendored
36
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,19 +1,39 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import {
|
||||||
|
Controller,
|
||||||
import { Controller, Post, UseGuards } from '@nestjs/common';
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Post,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@Controller('cache')
|
@Controller('cache')
|
||||||
export class CacheController {
|
export class CacheController {
|
||||||
public constructor(private readonly redisCacheService: RedisCacheService) {}
|
public constructor(
|
||||||
|
private readonly redisCacheService: RedisCacheService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('flush')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.redisCacheService.reset();
|
return this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
17
apps/api/src/app/cache/cache.module.ts
vendored
17
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,11 +1,24 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
imports: [RedisCacheModule]
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -21,7 +19,7 @@ export class ExchangeRateController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get(':symbol/:dateString')
|
@Get(':symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getExchangeRate(
|
public async getExchangeRate(
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExchangeRateController } from './exchange-rate.controller';
|
import { ExchangeRateController } from './exchange-rate.controller';
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
|
||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@ -12,28 +9,17 @@ import { ExportService } from './export.service';
|
|||||||
@Controller('export')
|
@Controller('export')
|
||||||
export class ExportController {
|
export class ExportController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly apiService: ApiService,
|
|
||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async export(
|
public async export(
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('activityIds') activityIds?: string[]
|
||||||
@Query('activityIds') activityIds?: string[],
|
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
|
||||||
@Query('tags') filterByTags?: string
|
|
||||||
): Promise<Export> {
|
): Promise<Export> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
||||||
filterByAccounts,
|
|
||||||
filterByAssetClasses,
|
|
||||||
filterByTags
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.exportService.export({
|
return this.exportService.export({
|
||||||
activityIds,
|
activityIds,
|
||||||
filters,
|
|
||||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
@ -1,14 +1,23 @@
|
|||||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController } from './export.controller';
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AccountModule, ApiModule, OrderModule],
|
imports: [
|
||||||
|
AccountModule,
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
OrderModule,
|
||||||
|
RedisCacheModule
|
||||||
|
],
|
||||||
controllers: [ExportController],
|
controllers: [ExportController],
|
||||||
providers: [ExportService]
|
providers: [ExportService]
|
||||||
})
|
})
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { Filter, Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -14,12 +13,10 @@ export class ExportService {
|
|||||||
|
|
||||||
public async export({
|
public async export({
|
||||||
activityIds,
|
activityIds,
|
||||||
filters,
|
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
filters?: Filter[];
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
@ -45,7 +42,6 @@ export class ExportService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let { activities } = await this.orderService.getOrders({
|
let { activities } = await this.orderService.getOrders({
|
||||||
filters,
|
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
@ -95,10 +91,7 @@ export class ExportService {
|
|||||||
: SymbolProfile.symbol
|
: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
),
|
)
|
||||||
user: {
|
|
||||||
settings: { currency: userCurrency }
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
@ -9,11 +8,7 @@ import { HealthService } from './health.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
imports: [
|
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
|
||||||
DataEnhancerModule,
|
|
||||||
DataProviderModule,
|
|
||||||
TransformDataSourceInRequestModule
|
|
||||||
],
|
|
||||||
providers: [HealthService]
|
providers: [HealthService]
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -37,18 +34,19 @@ export class ImportController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@HasPermission(permissions.createOrder)
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async import(
|
public async import(
|
||||||
@Body() importData: ImportDataDto,
|
@Body() importData: ImportDataDto,
|
||||||
@Query('dryRun') isDryRunParam = 'false'
|
@Query('dryRun') isDryRun?: boolean
|
||||||
): Promise<ImportResponse> {
|
): Promise<ImportResponse> {
|
||||||
const isDryRun = isDryRunParam === 'true';
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.createAccount
|
||||||
|
) ||
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -67,13 +65,16 @@ export class ImportController {
|
|||||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const activities = await this.importService.import({
|
const activities = await this.importService.import({
|
||||||
isDryRun,
|
isDryRun,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
|
userCurrency,
|
||||||
accountsDto: importData.accounts ?? [],
|
accountsDto: importData.accounts ?? [],
|
||||||
activitiesDto: importData.activities,
|
activitiesDto: importData.activities,
|
||||||
user: this.request.user
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
return { activities };
|
return { activities };
|
||||||
@ -91,7 +92,7 @@ export class ImportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('dividends/:dataSource/:symbol')
|
@Get('dividends/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async gatherDividends(
|
public async gatherDividends(
|
||||||
|
@ -4,15 +4,12 @@ import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
|||||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
|
||||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
@ -32,9 +29,7 @@ import { ImportService } from './import.service';
|
|||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule
|
||||||
TransformDataSourceInRequestModule,
|
|
||||||
TransformDataSourceInResponseModule
|
|
||||||
],
|
],
|
||||||
providers: [ImportService]
|
providers: [ImportService]
|
||||||
})
|
})
|
||||||
|
@ -13,7 +13,6 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
|
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getAssetProfileIdentifier,
|
getAssetProfileIdentifier,
|
||||||
@ -22,13 +21,11 @@ import {
|
|||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
AccountWithPlatform,
|
AccountWithPlatform,
|
||||||
OrderWithAccount,
|
OrderWithAccount
|
||||||
UserWithSettings
|
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import Big from 'big.js';
|
||||||
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@ -72,13 +69,9 @@ export class ImportService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = orders
|
const accounts = orders.map((order) => {
|
||||||
.filter(({ Account }) => {
|
return order.Account;
|
||||||
return !!Account;
|
});
|
||||||
})
|
|
||||||
.map(({ Account }) => {
|
|
||||||
return Account;
|
|
||||||
});
|
|
||||||
|
|
||||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||||
|
|
||||||
@ -117,13 +110,12 @@ export class ImportService {
|
|||||||
accountId: Account?.id,
|
accountId: Account?.id,
|
||||||
accountUserId: undefined,
|
accountUserId: undefined,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
currency: undefined,
|
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
fee: 0,
|
fee: 0,
|
||||||
feeInBaseCurrency: 0,
|
feeInBaseCurrency: 0,
|
||||||
id: assetProfile.id,
|
id: assetProfile.id,
|
||||||
isDraft: false,
|
isDraft: false,
|
||||||
SymbolProfile: assetProfile,
|
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||||
symbolProfileId: assetProfile.id,
|
symbolProfileId: assetProfile.id,
|
||||||
type: 'DIVIDEND',
|
type: 'DIVIDEND',
|
||||||
unitPrice: marketPrice,
|
unitPrice: marketPrice,
|
||||||
@ -146,16 +138,17 @@ export class ImportService {
|
|||||||
activitiesDto,
|
activitiesDto,
|
||||||
isDryRun = false,
|
isDryRun = false,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
user
|
userCurrency,
|
||||||
|
userId
|
||||||
}: {
|
}: {
|
||||||
accountsDto: Partial<CreateAccountDto>[];
|
accountsDto: Partial<CreateAccountDto>[];
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
isDryRun?: boolean;
|
isDryRun?: boolean;
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
user: UserWithSettings;
|
userCurrency: string;
|
||||||
|
userId: string;
|
||||||
}): Promise<Activity[]> {
|
}): Promise<Activity[]> {
|
||||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||||
const userCurrency = user.Settings.settings.baseCurrency;
|
|
||||||
|
|
||||||
if (!isDryRun && accountsDto?.length) {
|
if (!isDryRun && accountsDto?.length) {
|
||||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||||
@ -178,7 +171,7 @@ export class ImportService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// If there is no account or if the account belongs to a different user then create a new account
|
// If there is no account or if the account belongs to a different user then create a new account
|
||||||
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
|
if (!accountWithSameId || accountWithSameId.userId !== userId) {
|
||||||
let oldAccountId: string;
|
let oldAccountId: string;
|
||||||
const platformId = account.platformId;
|
const platformId = account.platformId;
|
||||||
|
|
||||||
@ -191,7 +184,7 @@ export class ImportService {
|
|||||||
|
|
||||||
let accountObject: Prisma.AccountCreateInput = {
|
let accountObject: Prisma.AccountCreateInput = {
|
||||||
...account,
|
...account,
|
||||||
User: { connect: { id: user.id } }
|
User: { connect: { id: userId } }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -207,7 +200,7 @@ export class ImportService {
|
|||||||
|
|
||||||
const newAccount = await this.accountService.createAccount(
|
const newAccount = await this.accountService.createAccount(
|
||||||
accountObject,
|
accountObject,
|
||||||
user.id
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store the new to old account ID mappings for updating activities
|
// Store the new to old account ID mappings for updating activities
|
||||||
@ -238,17 +231,16 @@ export class ImportService {
|
|||||||
|
|
||||||
const assetProfiles = await this.validateActivities({
|
const assetProfiles = await this.validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport
|
||||||
user
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId: user.id
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const accounts = (await this.accountService.getAccounts(user.id)).map(
|
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||||
({ id, name }) => {
|
({ id, name }) => {
|
||||||
return { id, name };
|
return { id, name };
|
||||||
}
|
}
|
||||||
@ -267,7 +259,6 @@ export class ImportService {
|
|||||||
{
|
{
|
||||||
accountId,
|
accountId,
|
||||||
comment,
|
comment,
|
||||||
currency,
|
|
||||||
date,
|
date,
|
||||||
error,
|
error,
|
||||||
fee,
|
fee,
|
||||||
@ -292,11 +283,11 @@ export class ImportService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
figi,
|
figi,
|
||||||
figiComposite,
|
figiComposite,
|
||||||
figiShareClass,
|
figiShareClass,
|
||||||
holdings,
|
|
||||||
id,
|
id,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
@ -349,12 +340,12 @@ export class ImportService {
|
|||||||
if (isDryRun) {
|
if (isDryRun) {
|
||||||
order = {
|
order = {
|
||||||
comment,
|
comment,
|
||||||
currency,
|
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
|
userId,
|
||||||
accountId: validatedAccount?.id,
|
accountId: validatedAccount?.id,
|
||||||
accountUserId: undefined,
|
accountUserId: undefined,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@ -365,11 +356,11 @@ export class ImportService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
figi,
|
figi,
|
||||||
figiComposite,
|
figiComposite,
|
||||||
figiShareClass,
|
figiShareClass,
|
||||||
holdings,
|
|
||||||
id,
|
id,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
@ -379,13 +370,11 @@ export class ImportService {
|
|||||||
symbolMapping,
|
symbolMapping,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
url,
|
url,
|
||||||
currency: assetProfile.currency,
|
|
||||||
comment: assetProfile.comment
|
comment: assetProfile.comment
|
||||||
},
|
},
|
||||||
Account: validatedAccount,
|
Account: validatedAccount,
|
||||||
symbolProfileId: undefined,
|
symbolProfileId: undefined,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date()
|
||||||
userId: user.id
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -399,13 +388,14 @@ export class ImportService {
|
|||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
|
userId,
|
||||||
accountId: validatedAccount?.id,
|
accountId: validatedAccount?.id,
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol
|
||||||
currency: assetProfile.currency
|
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
@ -416,14 +406,8 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateAccountBalance: false,
|
updateAccountBalance: false,
|
||||||
User: { connect: { id: user.id } },
|
User: { connect: { id: userId } }
|
||||||
userId: user.id
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.SymbolProfile?.symbol) {
|
|
||||||
// Update symbol that may have been assigned in createOrder()
|
|
||||||
assetProfile.symbol = order.SymbolProfile.symbol;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
@ -434,14 +418,14 @@ export class ImportService {
|
|||||||
value,
|
value,
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
fee,
|
fee,
|
||||||
assetProfile.currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
SymbolProfile: assetProfile,
|
SymbolProfile: assetProfile,
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
assetProfile.currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
@ -460,16 +444,15 @@ export class ImportService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dataGatheringService.gatherSymbols({
|
this.dataGatheringService.gatherSymbols(
|
||||||
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
|
uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
symbol: SymbolProfile.symbol
|
symbol: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}),
|
})
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return activities;
|
return activities;
|
||||||
@ -536,15 +519,22 @@ export class ImportService {
|
|||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
activitiesCount: undefined,
|
assetClass: null,
|
||||||
assetClass: undefined,
|
assetSubClass: null,
|
||||||
assetSubClass: undefined,
|
comment: null,
|
||||||
countries: undefined,
|
countries: null,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
holdings: undefined,
|
figi: null,
|
||||||
|
figiComposite: null,
|
||||||
|
figiShareClass: null,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
sectors: undefined,
|
isin: null,
|
||||||
updatedAt: undefined
|
name: null,
|
||||||
|
scraperConfiguration: null,
|
||||||
|
sectors: null,
|
||||||
|
symbolMapping: null,
|
||||||
|
updatedAt: undefined,
|
||||||
|
url: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -563,12 +553,10 @@ export class ImportService {
|
|||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport
|
||||||
user
|
|
||||||
}: {
|
}: {
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
user: UserWithSettings;
|
|
||||||
}) {
|
}) {
|
||||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
@ -578,53 +566,46 @@ export class ImportService {
|
|||||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
|
const uniqueActivitiesDto = uniqBy(
|
||||||
|
activitiesDto,
|
||||||
|
({ dataSource, symbol }) => {
|
||||||
|
return getAssetProfileIdentifier({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, symbol, type }
|
{ currency, dataSource, symbol }
|
||||||
] of activitiesDto.entries()) {
|
] of uniqueActivitiesDto.entries()) {
|
||||||
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (dataSource !== 'MANUAL') {
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
const assetProfile = (
|
||||||
user.subscription.type === 'Basic'
|
await this.dataProviderService.getAssetProfiles([
|
||||||
) {
|
{ dataSource, symbol }
|
||||||
const dataProvider = this.dataProviderService.getDataProvider(
|
])
|
||||||
DataSource[dataSource]
|
)?.[symbol];
|
||||||
);
|
|
||||||
|
|
||||||
if (dataProvider.getDataProviderInfo().isPremium) {
|
if (!assetProfile?.name) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
|
if (
|
||||||
const assetProfile = {
|
assetProfile.currency !== currency &&
|
||||||
currency,
|
!this.exchangeRateDataService.hasCurrencyPair(
|
||||||
...(
|
currency,
|
||||||
await this.dataProviderService.getAssetProfiles([
|
assetProfile.currency
|
||||||
{ dataSource, symbol }
|
)
|
||||||
])
|
) {
|
||||||
)?.[symbol]
|
throw new Error(
|
||||||
};
|
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
||||||
|
);
|
||||||
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
|
||||||
if (!assetProfile?.name) {
|
|
||||||
throw new Error(
|
|
||||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assetProfile.currency !== currency) {
|
|
||||||
throw new Error(
|
|
||||||
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||||
|
|
||||||
import { InfoService } from './info.service';
|
import { InfoService } from './info.service';
|
||||||
|
@ -2,15 +2,14 @@ import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'
|
|||||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -34,7 +33,6 @@ import { InfoService } from './info.service';
|
|||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
TagModule,
|
TagModule,
|
||||||
TransformDataSourceInResponseModule,
|
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [InfoService]
|
providers: [InfoService]
|
||||||
|
@ -8,6 +8,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
|||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
|
DEFAULT_REQUEST_TIMEOUT,
|
||||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
PROPERTY_DEMO_USER_ID,
|
PROPERTY_DEMO_USER_ID,
|
||||||
@ -28,7 +29,6 @@ import {
|
|||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
@ -60,6 +60,10 @@ export class InfoService {
|
|||||||
|
|
||||||
const globalPermissions: string[] = [];
|
const globalPermissions: string[] = [];
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
|
||||||
|
globalPermissions.push(permissions.enableBlog);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
info.fearAndGreedDataSource = encodeDataSource(
|
info.fearAndGreedDataSource = encodeDataSource(
|
||||||
@ -158,7 +162,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
const { pull_count } = await got(
|
const { pull_count } = await got(
|
||||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||||
@ -183,7 +187,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -192,11 +196,11 @@ export class InfoService {
|
|||||||
|
|
||||||
const $ = cheerio.load(body);
|
const $ = cheerio.load(body);
|
||||||
|
|
||||||
return extractNumberFromString({
|
return extractNumberFromString(
|
||||||
value: $(
|
$(
|
||||||
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||||
).text()
|
).text()
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService - GitHub');
|
Logger.error(error, 'InfoService - GitHub');
|
||||||
|
|
||||||
@ -210,7 +214,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
const { stargazers_count } = await got(
|
const { stargazers_count } = await got(
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||||
@ -338,7 +342,7 @@ export class InfoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
const { data } = await got(
|
const { data } = await got(
|
||||||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
@ -348,7 +352,7 @@ export class InfoService {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.configurationService.get(
|
Authorization: `Bearer ${this.configurationService.get(
|
||||||
'API_KEY_BETTER_UPTIME'
|
'BETTER_UPTIME_API_KEY'
|
||||||
)}`
|
)}`
|
||||||
},
|
},
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { LogoController } from './logo.controller';
|
import { LogoController } from './logo.controller';
|
||||||
@ -9,11 +7,7 @@ import { LogoService } from './logo.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [LogoController],
|
controllers: [LogoController],
|
||||||
imports: [
|
imports: [ConfigurationModule, SymbolProfileModule],
|
||||||
ConfigurationModule,
|
|
||||||
SymbolProfileModule,
|
|
||||||
TransformDataSourceInRequestModule
|
|
||||||
],
|
|
||||||
providers: [LogoService]
|
providers: [LogoService]
|
||||||
})
|
})
|
||||||
export class LogoModule {}
|
export class LogoModule {}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import got from 'got';
|
import got from 'got';
|
||||||
@ -10,7 +9,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class LogoService {
|
export class LogoService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -48,7 +46,7 @@ export class LogoService {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
return got(
|
return got(
|
||||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
|
@ -1,6 +1,3 @@
|
|||||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
|
||||||
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
@ -17,8 +14,7 @@ import {
|
|||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min,
|
Min
|
||||||
Validate
|
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -42,19 +38,14 @@ export class CreateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsCurrencyCode()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsCurrencyCode()
|
|
||||||
@IsOptional()
|
|
||||||
customCurrency?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(DataSource, { each: true })
|
@IsEnum(DataSource, { each: true })
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@Validate(IsAfter1970Constraint)
|
|
||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@ -1,19 +1,13 @@
|
|||||||
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { AccountWithPlatform } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { Order, Tag } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface Activities {
|
export interface Activities {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity extends Order {
|
export interface Activity extends OrderWithAccount {
|
||||||
Account?: AccountWithPlatform;
|
|
||||||
error?: ActivityError;
|
error?: ActivityError;
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
SymbolProfile?: EnhancedSymbolProfile;
|
|
||||||
tags?: Tag[];
|
|
||||||
updateAccountBalance?: boolean;
|
updateAccountBalance?: boolean;
|
||||||
value: number;
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
|
@ -1,19 +1,12 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import {
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
HEADER_KEY_IMPERSONATION
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -51,35 +44,32 @@ export class OrderController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete()
|
@Delete()
|
||||||
@HasPermission(permissions.deleteOrder)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
public async deleteOrders(): Promise<number> {
|
||||||
public async deleteOrders(
|
if (
|
||||||
@Query('accounts') filterByAccounts?: string,
|
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
) {
|
||||||
@Query('tags') filterByTags?: string
|
throw new HttpException(
|
||||||
): Promise<number> {
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
StatusCodes.FORBIDDEN
|
||||||
filterByAccounts,
|
);
|
||||||
filterByAssetClasses,
|
}
|
||||||
filterByTags
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.orderService.deleteOrders({
|
return this.orderService.deleteOrders({
|
||||||
filters,
|
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HasPermission(permissions.deleteOrder)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||||
const order = await this.orderService.order({
|
const order = await this.orderService.order({ id });
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!order) {
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
|
||||||
|
!order ||
|
||||||
|
order.userId !== this.request.user.id
|
||||||
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -92,27 +82,19 @@ export class OrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange?: DateRange,
|
|
||||||
@Query('skip') skip?: number,
|
@Query('skip') skip?: number,
|
||||||
@Query('sortColumn') sortColumn?: string,
|
@Query('sortColumn') sortColumn?: string,
|
||||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@Query('tags') filterByTags?: string,
|
@Query('tags') filterByTags?: string,
|
||||||
@Query('take') take?: number
|
@Query('take') take?: number
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
let endDate: Date;
|
|
||||||
let startDate: Date;
|
|
||||||
|
|
||||||
if (dateRange) {
|
|
||||||
({ endDate, startDate } = getInterval(dateRange));
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
@ -124,11 +106,9 @@ export class OrderController {
|
|||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
const { activities, count } = await this.orderService.getOrders({
|
const { activities, count } = await this.orderService.getOrders({
|
||||||
endDate,
|
|
||||||
filters,
|
filters,
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
startDate,
|
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
skip: isNaN(skip) ? undefined : skip,
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
@ -140,18 +120,17 @@ export class OrderController {
|
|||||||
return { activities, count };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.createOrder)
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||||
const currency = data.currency;
|
if (
|
||||||
const customCurrency = data.customCurrency;
|
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||||
|
) {
|
||||||
if (customCurrency) {
|
throw new HttpException(
|
||||||
data.currency = customCurrency;
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
delete data.customCurrency;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = await this.orderService.createOrder({
|
const order = await this.orderService.createOrder({
|
||||||
@ -160,7 +139,7 @@ export class OrderController {
|
|||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
currency,
|
currency: data.currency,
|
||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
},
|
},
|
||||||
@ -179,31 +158,31 @@ export class OrderController {
|
|||||||
if (data.dataSource && !order.isDraft) {
|
if (data.dataSource && !order.isDraft) {
|
||||||
// Gather symbol data in the background, if data source is set
|
// Gather symbol data in the background, if data source is set
|
||||||
// (not MANUAL) and not draft
|
// (not MANUAL) and not draft
|
||||||
this.dataGatheringService.gatherSymbols({
|
this.dataGatheringService.gatherSymbols([
|
||||||
dataGatheringItems: [
|
{
|
||||||
{
|
dataSource: data.dataSource,
|
||||||
dataSource: data.dataSource,
|
date: order.date,
|
||||||
date: order.date,
|
symbol: data.symbol
|
||||||
symbol: data.symbol
|
}
|
||||||
}
|
]);
|
||||||
],
|
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.updateOrder)
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||||
const originalOrder = await this.orderService.order({
|
const originalOrder = await this.orderService.order({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!originalOrder || originalOrder.userId !== this.request.user.id) {
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
|
||||||
|
!originalOrder ||
|
||||||
|
originalOrder.userId !== this.request.user.id
|
||||||
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -213,16 +192,8 @@ export class OrderController {
|
|||||||
const date = parseISO(data.date);
|
const date = parseISO(data.date);
|
||||||
|
|
||||||
const accountId = data.accountId;
|
const accountId = data.accountId;
|
||||||
const customCurrency = data.customCurrency;
|
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
|
|
||||||
if (customCurrency) {
|
|
||||||
data.currency = customCurrency;
|
|
||||||
|
|
||||||
delete data.customCurrency;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.orderService.updateOrder({
|
return this.orderService.updateOrder({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
@ -2,17 +2,15 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
|
||||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { OrderController } from './order.controller';
|
import { OrderController } from './order.controller';
|
||||||
@ -24,16 +22,15 @@ import { OrderService } from './order.service';
|
|||||||
imports: [
|
imports: [
|
||||||
ApiModule,
|
ApiModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedactValuesInResponseModule,
|
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
TransformDataSourceInRequestModule,
|
UserModule
|
||||||
TransformDataSourceInResponseModule
|
|
||||||
],
|
],
|
||||||
providers: [AccountBalanceService, AccountService, OrderService]
|
providers: [AccountBalanceService, AccountService, OrderService]
|
||||||
})
|
})
|
||||||
|
@ -1,24 +1,16 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import {
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
EnhancedSymbolProfile,
|
|
||||||
Filter,
|
|
||||||
UniqueAsset
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
@ -26,11 +18,11 @@ import {
|
|||||||
Order,
|
Order,
|
||||||
Prisma,
|
Prisma,
|
||||||
Tag,
|
Tag,
|
||||||
Type as ActivityType
|
Type as TypeOfOrder
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
import { groupBy, uniqBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { Activities } from './interfaces/activities.interface';
|
import { Activities } from './interfaces/activities.interface';
|
||||||
@ -40,7 +32,6 @@ export class OrderService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
@ -73,13 +64,20 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accountId = data.accountId;
|
const accountId = data.accountId;
|
||||||
|
let currency = data.currency;
|
||||||
const tags = data.tags ?? [];
|
const tags = data.tags ?? [];
|
||||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||||
const userId = data.userId;
|
const userId = data.userId;
|
||||||
|
|
||||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
if (
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
|
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||||
const dataSource: DataSource = 'MANUAL';
|
const dataSource: DataSource = 'MANUAL';
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||||
@ -87,6 +85,7 @@ export class OrderService {
|
|||||||
data.id = id;
|
data.id = id;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
||||||
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||||
data.SymbolProfile.connectOrCreate.create.name = name;
|
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
||||||
@ -108,8 +107,7 @@ export class OrderService {
|
|||||||
jobId: getAssetProfileIdentifier({
|
jobId: getAssetProfileIdentifier({
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
}),
|
})
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -122,6 +120,7 @@ export class OrderService {
|
|||||||
delete data.comment;
|
delete data.comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete data.currency;
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
@ -130,9 +129,13 @@ export class OrderService {
|
|||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)
|
const isDraft =
|
||||||
? false
|
data.type === 'FEE' ||
|
||||||
: isAfter(data.date as Date, endOfToday());
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
? false
|
||||||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
const order = await this.prismaService.order.create({
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
@ -144,8 +147,7 @@ export class OrderService {
|
|||||||
return { id };
|
return { id };
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
include: { SymbolProfile: true }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updateAccountBalance === true) {
|
if (updateAccountBalance === true) {
|
||||||
@ -154,26 +156,19 @@ export class OrderService {
|
|||||||
.plus(data.fee)
|
.plus(data.fee)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
if (['BUY', 'FEE'].includes(data.type)) {
|
if (data.type === 'BUY') {
|
||||||
amount = new Big(amount).mul(-1).toNumber();
|
amount = new Big(amount).mul(-1).toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.accountService.updateAccountBalance({
|
await this.accountService.updateAccountBalance({
|
||||||
accountId,
|
accountId,
|
||||||
amount,
|
amount,
|
||||||
|
currency,
|
||||||
userId,
|
userId,
|
||||||
currency: data.SymbolProfile.connectOrCreate.create.currency,
|
|
||||||
date: data.date as Date
|
date: data.date as Date
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: order.userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,142 +179,62 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
const [symbolProfile] =
|
|
||||||
await this.symbolProfileService.getSymbolProfilesByIds([
|
|
||||||
order.symbolProfileId
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) ||
|
order.type === 'FEE' ||
|
||||||
symbolProfile.activitiesCount === 0
|
order.type === 'INTEREST' ||
|
||||||
|
order.type === 'ITEM' ||
|
||||||
|
order.type === 'LIABILITY'
|
||||||
) {
|
) {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: order.userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteOrders({
|
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
|
||||||
filters,
|
|
||||||
userId
|
|
||||||
}: {
|
|
||||||
filters?: Filter[];
|
|
||||||
userId: string;
|
|
||||||
}): Promise<number> {
|
|
||||||
const { activities } = await this.getOrders({
|
|
||||||
filters,
|
|
||||||
userId,
|
|
||||||
includeDrafts: true,
|
|
||||||
userCurrency: undefined,
|
|
||||||
withExcludedAccounts: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const { count } = await this.prismaService.order.deleteMany({
|
const { count } = await this.prismaService.order.deleteMany({
|
||||||
where: {
|
where
|
||||||
id: {
|
|
||||||
in: activities.map(({ id }) => {
|
|
||||||
return id;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const symbolProfiles =
|
|
||||||
await this.symbolProfileService.getSymbolProfilesByIds(
|
|
||||||
activities.map(({ symbolProfileId }) => {
|
|
||||||
return symbolProfileId;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const { activitiesCount, id } of symbolProfiles) {
|
|
||||||
if (activitiesCount === 0) {
|
|
||||||
await this.symbolProfileService.deleteById(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({ userId })
|
|
||||||
);
|
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
|
|
||||||
return this.prismaService.order.findFirst({
|
|
||||||
orderBy: {
|
|
||||||
date: 'desc'
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
SymbolProfile: { dataSource, symbol }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
endDate,
|
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
skip,
|
skip,
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
startDate,
|
|
||||||
take = Number.MAX_SAFE_INTEGER,
|
take = Number.MAX_SAFE_INTEGER,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
withExcludedAccounts = false
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
endDate?: Date;
|
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
sortColumn?: string;
|
sortColumn?: string;
|
||||||
sortDirection?: Prisma.SortOrder;
|
sortDirection?: Prisma.SortOrder;
|
||||||
startDate?: Date;
|
|
||||||
take?: number;
|
take?: number;
|
||||||
types?: ActivityType[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<Activities> {
|
}): Promise<Activities> {
|
||||||
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
||||||
{ date: 'asc' },
|
{ date: 'asc' }
|
||||||
{ id: 'asc' }
|
|
||||||
];
|
];
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
if (endDate || startDate) {
|
|
||||||
where.AND = [];
|
|
||||||
|
|
||||||
if (endDate) {
|
|
||||||
where.AND.push({ date: { lte: endDate } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startDate) {
|
|
||||||
where.AND.push({ date: { gt: startDate } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ACCOUNT: filtersByAccount,
|
ACCOUNT: filtersByAccount,
|
||||||
ASSET_CLASS: filtersByAssetClass,
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
TAG: filtersByTag
|
TAG: filtersByTag
|
||||||
} = groupBy(filters, ({ type }) => {
|
} = groupBy(filters, (filter) => {
|
||||||
return type;
|
return filter.type;
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchQuery = filters?.find(({ type }) => {
|
|
||||||
return type === 'SEARCH_QUERY';
|
|
||||||
})?.id;
|
|
||||||
|
|
||||||
if (filtersByAccount?.length > 0) {
|
if (filtersByAccount?.length > 0) {
|
||||||
where.accountId = {
|
where.accountId = {
|
||||||
in: filtersByAccount.map(({ id }) => {
|
in: filtersByAccount.map(({ id }) => {
|
||||||
@ -361,30 +276,6 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery) {
|
|
||||||
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
|
|
||||||
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
|
||||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
|
||||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
|
||||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
|
||||||
];
|
|
||||||
|
|
||||||
if (where.SymbolProfile) {
|
|
||||||
where.SymbolProfile = {
|
|
||||||
AND: [
|
|
||||||
where.SymbolProfile,
|
|
||||||
{
|
|
||||||
OR: searchQueryWhereInput
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
where.SymbolProfile = {
|
|
||||||
OR: searchQueryWhereInput
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filtersByTag?.length > 0) {
|
if (filtersByTag?.length > 0) {
|
||||||
where.tags = {
|
where.tags = {
|
||||||
some: {
|
some: {
|
||||||
@ -396,18 +287,17 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sortColumn) {
|
if (sortColumn) {
|
||||||
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
where.type = { in: types };
|
where.OR = types.map((type) => {
|
||||||
}
|
return {
|
||||||
|
type: {
|
||||||
if (withExcludedAccounts === false) {
|
equals: type
|
||||||
where.OR = [
|
}
|
||||||
{ Account: null },
|
};
|
||||||
{ Account: { NOT: { isExcluded: true } } }
|
});
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [orders, count] = await Promise.all([
|
const [orders, count] = await Promise.all([
|
||||||
@ -431,76 +321,36 @@ export class OrderService {
|
|||||||
this.prismaService.order.count({ where })
|
this.prismaService.order.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const uniqueAssets = uniqBy(
|
const activities = orders
|
||||||
orders.map(({ SymbolProfile }) => {
|
.filter((order) => {
|
||||||
return {
|
|
||||||
dataSource: SymbolProfile.dataSource,
|
|
||||||
symbol: SymbolProfile.symbol
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
({ dataSource, symbol }) => {
|
|
||||||
return getAssetProfileIdentifier({
|
|
||||||
dataSource,
|
|
||||||
symbol
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const assetProfiles =
|
|
||||||
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
|
|
||||||
|
|
||||||
const activities = orders.map((order) => {
|
|
||||||
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
|
|
||||||
return (
|
return (
|
||||||
dataSource === order.SymbolProfile.dataSource &&
|
withExcludedAccounts ||
|
||||||
symbol === order.SymbolProfile.symbol
|
!order.Account ||
|
||||||
|
order.Account?.isExcluded === false
|
||||||
);
|
);
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
value,
|
||||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
order.fee,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
|
||||||
|
|
||||||
return {
|
|
||||||
...order,
|
|
||||||
value,
|
|
||||||
// TODO: Use exchange rate of date
|
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
order.fee,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
SymbolProfile: assetProfile,
|
|
||||||
// TODO: Use exchange rate of date
|
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
value,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { activities, count };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getStatisticsByCurrency(
|
|
||||||
currency: EnhancedSymbolProfile['currency']
|
|
||||||
): Promise<{
|
|
||||||
activitiesCount: EnhancedSymbolProfile['activitiesCount'];
|
|
||||||
dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
|
||||||
}> {
|
|
||||||
const { _count, _min } = await this.prismaService.order.aggregate({
|
|
||||||
_count: true,
|
|
||||||
_min: {
|
|
||||||
date: true
|
|
||||||
},
|
|
||||||
where: { SymbolProfile: { currency } }
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
activitiesCount: _count as number,
|
|
||||||
dateOfFirstActivity: _min.date
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async order(
|
public async order(
|
||||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||||
): Promise<Order | null> {
|
): Promise<Order | null> {
|
||||||
@ -520,10 +370,13 @@ export class OrderService {
|
|||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
type?: ActivityType;
|
|
||||||
};
|
};
|
||||||
where: Prisma.OrderWhereUniqueInput;
|
where: Prisma.OrderWhereUniqueInput;
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
|
if (data.Account.connect.id_userId.id === null) {
|
||||||
|
delete data.Account;
|
||||||
|
}
|
||||||
|
|
||||||
if (!data.comment) {
|
if (!data.comment) {
|
||||||
data.comment = null;
|
data.comment = null;
|
||||||
}
|
}
|
||||||
@ -532,12 +385,13 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
if (
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
|
|
||||||
if (data.Account?.connect?.id_userId?.id === null) {
|
|
||||||
data.Account = { disconnect: true };
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
|
|
||||||
@ -545,22 +399,19 @@ export class OrderService {
|
|||||||
|
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
// Gather symbol data of order in the background, if not draft
|
// Gather symbol data of order in the background, if not draft
|
||||||
this.dataGatheringService.gatherSymbols({
|
this.dataGatheringService.gatherSymbols([
|
||||||
dataGatheringItems: [
|
{
|
||||||
{
|
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
||||||
dataSource:
|
date: <Date>data.date,
|
||||||
data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
||||||
date: <Date>data.date,
|
}
|
||||||
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
]);
|
||||||
}
|
|
||||||
],
|
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
delete data.assetSubClass;
|
delete data.assetSubClass;
|
||||||
|
delete data.currency;
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
@ -571,7 +422,7 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
const order = await this.prismaService.order.update({
|
return this.prismaService.order.update({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
isDraft,
|
isDraft,
|
||||||
@ -583,15 +434,6 @@ export class OrderService {
|
|||||||
},
|
},
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: order.userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return order;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async orders(params: {
|
private async orders(params: {
|
||||||
|
@ -1,6 +1,3 @@
|
|||||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
|
||||||
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
@ -16,8 +13,7 @@ import {
|
|||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min,
|
Min
|
||||||
Validate
|
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -41,18 +37,13 @@ export class UpdateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsCurrencyCode()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsCurrencyCode()
|
|
||||||
@IsOptional()
|
|
||||||
customCurrency?: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@Validate(IsAfter1970Constraint)
|
|
||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
import { IsString, IsUrl } from 'class-validator';
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreatePlatformDto {
|
export class CreatePlatformDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsUrl({
|
@IsString()
|
||||||
protocols: ['https'],
|
|
||||||
require_protocol: true
|
|
||||||
})
|
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Platform } from '@prisma/client';
|
import { Platform } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -23,30 +23,49 @@ import { UpdatePlatformDto } from './update-platform.dto';
|
|||||||
|
|
||||||
@Controller('platform')
|
@Controller('platform')
|
||||||
export class PlatformController {
|
export class PlatformController {
|
||||||
public constructor(private readonly platformService: PlatformService) {}
|
public constructor(
|
||||||
|
private readonly platformService: PlatformService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPlatforms() {
|
public async getPlatforms() {
|
||||||
return this.platformService.getPlatformsWithAccountCount();
|
return this.platformService.getPlatformsWithAccountCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.createPlatform)
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async createPlatform(
|
public async createPlatform(
|
||||||
@Body() data: CreatePlatformDto
|
@Body() data: CreatePlatformDto
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.createPlatform)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.platformService.createPlatform(data);
|
return this.platformService.createPlatform(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.updatePlatform)
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updatePlatform(
|
public async updatePlatform(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() data: UpdatePlatformDto
|
@Body() data: UpdatePlatformDto
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const originalPlatform = await this.platformService.getPlatform({
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
@ -69,9 +88,17 @@ export class PlatformController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HasPermission(permissions.deletePlatform)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deletePlatform(@Param('id') id: string) {
|
public async deletePlatform(@Param('id') id: string) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const originalPlatform = await this.platformService.getPlatform({
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PlatformController } from './platform.controller';
|
import { PlatformController } from './platform.controller';
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Platform, Prisma } from '@prisma/client';
|
import { Platform, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsUrl } from 'class-validator';
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdatePlatformDto {
|
export class UpdatePlatformDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -7,9 +7,6 @@ export class UpdatePlatformDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsUrl({
|
@IsString()
|
||||||
protocols: ['https'],
|
|
||||||
require_protocol: true
|
|
||||||
})
|
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
@ -1,33 +0,0 @@
|
|||||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
|
||||||
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
|
|
||||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
|
||||||
|
|
||||||
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
|
||||||
protected calculateOverallPerformance(
|
|
||||||
positions: TimelinePosition[]
|
|
||||||
): PortfolioSnapshot {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getSymbolMetrics({
|
|
||||||
dataSource,
|
|
||||||
end,
|
|
||||||
exchangeRates,
|
|
||||||
isChartMode = false,
|
|
||||||
marketSymbolMap,
|
|
||||||
start,
|
|
||||||
step = 1,
|
|
||||||
symbol
|
|
||||||
}: {
|
|
||||||
end: Date;
|
|
||||||
exchangeRates: { [dateString: string]: number };
|
|
||||||
isChartMode?: boolean;
|
|
||||||
marketSymbolMap: {
|
|
||||||
[date: string]: { [symbol: string]: Big };
|
|
||||||
};
|
|
||||||
start: Date;
|
|
||||||
step?: number;
|
|
||||||
} & UniqueAsset): SymbolMetrics {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
export const activityDummyData = {
|
|
||||||
accountId: undefined,
|
|
||||||
accountUserId: undefined,
|
|
||||||
comment: undefined,
|
|
||||||
createdAt: new Date(),
|
|
||||||
currency: undefined,
|
|
||||||
feeInBaseCurrency: undefined,
|
|
||||||
id: undefined,
|
|
||||||
isDraft: false,
|
|
||||||
symbolProfileId: undefined,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
userId: undefined,
|
|
||||||
value: undefined,
|
|
||||||
valueInBaseCurrency: undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
export const symbolProfileDummyData = {
|
|
||||||
activitiesCount: undefined,
|
|
||||||
assetClass: undefined,
|
|
||||||
assetSubClass: undefined,
|
|
||||||
countries: [],
|
|
||||||
createdAt: undefined,
|
|
||||||
holdings: [],
|
|
||||||
id: undefined,
|
|
||||||
sectors: [],
|
|
||||||
updatedAt: undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
export const userDummyData = {
|
|
||||||
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
|
||||||
};
|
|
@ -1,81 +0,0 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
|
||||||
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
|
|
||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
|
||||||
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
|
|
||||||
|
|
||||||
export enum PerformanceCalculationType {
|
|
||||||
MWR = 'MWR', // Money-Weighted Rate of Return
|
|
||||||
TWR = 'TWR' // Time-Weighted Rate of Return
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PortfolioCalculatorFactory {
|
|
||||||
public constructor(
|
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly currentRateService: CurrentRateService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly redisCacheService: RedisCacheService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public createCalculator({
|
|
||||||
accountBalanceItems = [],
|
|
||||||
activities,
|
|
||||||
calculationType,
|
|
||||||
currency,
|
|
||||||
dateRange = 'max',
|
|
||||||
hasFilters,
|
|
||||||
isExperimentalFeatures = false,
|
|
||||||
userId
|
|
||||||
}: {
|
|
||||||
accountBalanceItems?: HistoricalDataItem[];
|
|
||||||
activities: Activity[];
|
|
||||||
calculationType: PerformanceCalculationType;
|
|
||||||
currency: string;
|
|
||||||
dateRange?: DateRange;
|
|
||||||
hasFilters: boolean;
|
|
||||||
isExperimentalFeatures?: boolean;
|
|
||||||
userId: string;
|
|
||||||
}): PortfolioCalculator {
|
|
||||||
const useCache = !hasFilters && isExperimentalFeatures;
|
|
||||||
|
|
||||||
switch (calculationType) {
|
|
||||||
case PerformanceCalculationType.MWR:
|
|
||||||
return new MWRPortfolioCalculator({
|
|
||||||
accountBalanceItems,
|
|
||||||
activities,
|
|
||||||
currency,
|
|
||||||
dateRange,
|
|
||||||
useCache,
|
|
||||||
userId,
|
|
||||||
configurationService: this.configurationService,
|
|
||||||
currentRateService: this.currentRateService,
|
|
||||||
exchangeRateDataService: this.exchangeRateDataService,
|
|
||||||
redisCacheService: this.redisCacheService
|
|
||||||
});
|
|
||||||
case PerformanceCalculationType.TWR:
|
|
||||||
return new TWRPortfolioCalculator({
|
|
||||||
accountBalanceItems,
|
|
||||||
activities,
|
|
||||||
currency,
|
|
||||||
currentRateService: this.currentRateService,
|
|
||||||
dateRange,
|
|
||||||
useCache,
|
|
||||||
userId,
|
|
||||||
configurationService: this.configurationService,
|
|
||||||
exchangeRateDataService: this.exchangeRateDataService,
|
|
||||||
redisCacheService: this.redisCacheService
|
|
||||||
});
|
|
||||||
default:
|
|
||||||
throw new Error('Invalid calculation type');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
@ -1,219 +0,0 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import {
|
|
||||||
activityDummyData,
|
|
||||||
symbolProfileDummyData,
|
|
||||||
userDummyData
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
|
||||||
import {
|
|
||||||
PortfolioCalculatorFactory,
|
|
||||||
PerformanceCalculationType
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
|
||||||
return RedisCacheServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let factory: PortfolioCalculatorFactory;
|
|
||||||
let redisCacheService: RedisCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
configurationService = new ConfigurationService();
|
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
redisCacheService = new RedisCacheService(null, null);
|
|
||||||
|
|
||||||
factory = new PortfolioCalculatorFactory(
|
|
||||||
configurationService,
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
redisCacheService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get current positions', () => {
|
|
||||||
it.only('with BALN.SW buy and sell in two activities', async () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
|
||||||
|
|
||||||
const activities: Activity[] = [
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2021-11-22'),
|
|
||||||
fee: 1.55,
|
|
||||||
quantity: 2,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'CHF',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
symbol: 'BALN.SW'
|
|
||||||
},
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: 142.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2021-11-30'),
|
|
||||||
fee: 1.65,
|
|
||||||
quantity: 1,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'CHF',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
symbol: 'BALN.SW'
|
|
||||||
},
|
|
||||||
type: 'SELL',
|
|
||||||
unitPrice: 136.6
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2021-11-30'),
|
|
||||||
fee: 0,
|
|
||||||
quantity: 1,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'CHF',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
symbol: 'BALN.SW'
|
|
||||||
},
|
|
||||||
type: 'SELL',
|
|
||||||
unitPrice: 136.6
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const portfolioCalculator = factory.createCalculator({
|
|
||||||
activities,
|
|
||||||
calculationType: PerformanceCalculationType.TWR,
|
|
||||||
currency: 'CHF',
|
|
||||||
hasFilters: false,
|
|
||||||
userId: userDummyData.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
|
||||||
start: parseDate('2021-11-22')
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
|
||||||
parseDate('2021-11-22')
|
|
||||||
);
|
|
||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
|
||||||
data: chartData,
|
|
||||||
groupBy: 'month'
|
|
||||||
});
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(portfolioSnapshot).toEqual({
|
|
||||||
currentValueInBaseCurrency: new Big('0'),
|
|
||||||
errors: [],
|
|
||||||
grossPerformance: new Big('-12.6'),
|
|
||||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'-0.04408677396780965649'
|
|
||||||
),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
|
||||||
hasErrors: false,
|
|
||||||
netPerformance: new Big('-15.8'),
|
|
||||||
netPerformancePercentage: new Big('-0.05528341497550734703'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'-0.05528341497550734703'
|
|
||||||
),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
averagePrice: new Big('0'),
|
|
||||||
currency: 'CHF',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
dividend: new Big('0'),
|
|
||||||
dividendInBaseCurrency: new Big('0'),
|
|
||||||
fee: new Big('3.2'),
|
|
||||||
feeInBaseCurrency: new Big('3.2'),
|
|
||||||
firstBuyDate: '2021-11-22',
|
|
||||||
grossPerformance: new Big('-12.6'),
|
|
||||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'-0.04408677396780965649'
|
|
||||||
),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
|
||||||
investment: new Big('0'),
|
|
||||||
investmentWithCurrencyEffect: new Big('0'),
|
|
||||||
netPerformance: new Big('-15.8'),
|
|
||||||
netPerformancePercentage: new Big('-0.05528341497550734703'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'-0.05528341497550734703'
|
|
||||||
),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
|
||||||
marketPrice: 148.9,
|
|
||||||
marketPriceInBaseCurrency: 148.9,
|
|
||||||
quantity: new Big('0'),
|
|
||||||
symbol: 'BALN.SW',
|
|
||||||
tags: [],
|
|
||||||
timeWeightedInvestment: new Big('285.80000000000000396627'),
|
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
|
||||||
'285.80000000000000396627'
|
|
||||||
),
|
|
||||||
transactionCount: 3,
|
|
||||||
valueInBaseCurrency: new Big('0')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
totalFeesWithCurrencyEffect: new Big('3.2'),
|
|
||||||
totalInterestWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInvestment: new Big('0'),
|
|
||||||
totalInvestmentWithCurrencyEffect: new Big('0'),
|
|
||||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalValuablesWithCurrencyEffect: new Big('0')
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(investments).toEqual([
|
|
||||||
{ date: '2021-11-22', investment: new Big('285.8') },
|
|
||||||
{ date: '2021-11-30', investment: new Big('0') }
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(investmentsByMonth).toEqual([
|
|
||||||
{ date: '2021-11-01', investment: 0 },
|
|
||||||
{ date: '2021-12-01', investment: 0 }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,202 +0,0 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import {
|
|
||||||
activityDummyData,
|
|
||||||
symbolProfileDummyData,
|
|
||||||
userDummyData
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
|
||||||
import {
|
|
||||||
PerformanceCalculationType,
|
|
||||||
PortfolioCalculatorFactory
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
|
||||||
return RedisCacheServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let factory: PortfolioCalculatorFactory;
|
|
||||||
let redisCacheService: RedisCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
configurationService = new ConfigurationService();
|
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
redisCacheService = new RedisCacheService(null, null);
|
|
||||||
|
|
||||||
factory = new PortfolioCalculatorFactory(
|
|
||||||
configurationService,
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
redisCacheService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get current positions', () => {
|
|
||||||
it.only('with BALN.SW buy and sell', async () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
|
||||||
|
|
||||||
const activities: Activity[] = [
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2021-11-22'),
|
|
||||||
fee: 1.55,
|
|
||||||
quantity: 2,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'CHF',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
symbol: 'BALN.SW'
|
|
||||||
},
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: 142.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2021-11-30'),
|
|
||||||
fee: 1.65,
|
|
||||||
quantity: 2,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'CHF',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
symbol: 'BALN.SW'
|
|
||||||
},
|
|
||||||
type: 'SELL',
|
|
||||||
unitPrice: 136.6
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const portfolioCalculator = factory.createCalculator({
|
|
||||||
activities,
|
|
||||||
calculationType: PerformanceCalculationType.TWR,
|
|
||||||
currency: 'CHF',
|
|
||||||
hasFilters: false,
|
|
||||||
userId: userDummyData.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
|
||||||
start: parseDate('2021-11-22')
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
|
||||||
parseDate('2021-11-22')
|
|
||||||
);
|
|
||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
|
||||||
data: chartData,
|
|
||||||
groupBy: 'month'
|
|
||||||
});
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(portfolioSnapshot).toEqual({
|
|
||||||
currentValueInBaseCurrency: new Big('0'),
|
|
||||||
errors: [],
|
|
||||||
grossPerformance: new Big('-12.6'),
|
|
||||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'-0.0440867739678096571'
|
|
||||||
),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
|
||||||
hasErrors: false,
|
|
||||||
netPerformance: new Big('-15.8'),
|
|
||||||
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'-0.0552834149755073478'
|
|
||||||
),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
averagePrice: new Big('0'),
|
|
||||||
currency: 'CHF',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
dividend: new Big('0'),
|
|
||||||
dividendInBaseCurrency: new Big('0'),
|
|
||||||
fee: new Big('3.2'),
|
|
||||||
feeInBaseCurrency: new Big('3.2'),
|
|
||||||
firstBuyDate: '2021-11-22',
|
|
||||||
grossPerformance: new Big('-12.6'),
|
|
||||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'-0.0440867739678096571'
|
|
||||||
),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
|
||||||
investment: new Big('0'),
|
|
||||||
investmentWithCurrencyEffect: new Big('0'),
|
|
||||||
netPerformance: new Big('-15.8'),
|
|
||||||
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'-0.0552834149755073478'
|
|
||||||
),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
|
||||||
marketPrice: 148.9,
|
|
||||||
marketPriceInBaseCurrency: 148.9,
|
|
||||||
quantity: new Big('0'),
|
|
||||||
symbol: 'BALN.SW',
|
|
||||||
tags: [],
|
|
||||||
timeWeightedInvestment: new Big('285.8'),
|
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
|
||||||
transactionCount: 2,
|
|
||||||
valueInBaseCurrency: new Big('0')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
totalFeesWithCurrencyEffect: new Big('3.2'),
|
|
||||||
totalInterestWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInvestment: new Big('0'),
|
|
||||||
totalInvestmentWithCurrencyEffect: new Big('0'),
|
|
||||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalValuablesWithCurrencyEffect: new Big('0')
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(investments).toEqual([
|
|
||||||
{ date: '2021-11-22', investment: new Big('285.8') },
|
|
||||||
{ date: '2021-11-30', investment: new Big('0') }
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(investmentsByMonth).toEqual([
|
|
||||||
{ date: '2021-11-01', investment: 0 },
|
|
||||||
{ date: '2021-12-01', investment: 0 }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,186 +0,0 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import {
|
|
||||||
activityDummyData,
|
|
||||||
symbolProfileDummyData,
|
|
||||||
userDummyData
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
|
||||||
import {
|
|
||||||
PortfolioCalculatorFactory,
|
|
||||||
PerformanceCalculationType
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
|
||||||
return RedisCacheServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let factory: PortfolioCalculatorFactory;
|
|
||||||
let redisCacheService: RedisCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
configurationService = new ConfigurationService();
|
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
redisCacheService = new RedisCacheService(null, null);
|
|
||||||
|
|
||||||
factory = new PortfolioCalculatorFactory(
|
|
||||||
configurationService,
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
redisCacheService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get current positions', () => {
|
|
||||||
it.only('with BALN.SW buy', async () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
|
||||||
|
|
||||||
const activities: Activity[] = [
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2021-11-30'),
|
|
||||||
fee: 1.55,
|
|
||||||
quantity: 2,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'CHF',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Bâloise Holding AG',
|
|
||||||
symbol: 'BALN.SW'
|
|
||||||
},
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: 136.6
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const portfolioCalculator = factory.createCalculator({
|
|
||||||
activities,
|
|
||||||
calculationType: PerformanceCalculationType.TWR,
|
|
||||||
currency: 'CHF',
|
|
||||||
hasFilters: false,
|
|
||||||
userId: userDummyData.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
|
||||||
start: parseDate('2021-11-30')
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
|
||||||
parseDate('2021-11-30')
|
|
||||||
);
|
|
||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
|
||||||
data: chartData,
|
|
||||||
groupBy: 'month'
|
|
||||||
});
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(portfolioSnapshot).toEqual({
|
|
||||||
currentValueInBaseCurrency: new Big('297.8'),
|
|
||||||
errors: [],
|
|
||||||
grossPerformance: new Big('24.6'),
|
|
||||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'0.09004392386530014641'
|
|
||||||
),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('24.6'),
|
|
||||||
hasErrors: false,
|
|
||||||
netPerformance: new Big('23.05'),
|
|
||||||
netPerformancePercentage: new Big('0.08437042459736456808'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'0.08437042459736456808'
|
|
||||||
),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('23.05'),
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
averagePrice: new Big('136.6'),
|
|
||||||
currency: 'CHF',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
dividend: new Big('0'),
|
|
||||||
dividendInBaseCurrency: new Big('0'),
|
|
||||||
fee: new Big('1.55'),
|
|
||||||
feeInBaseCurrency: new Big('1.55'),
|
|
||||||
firstBuyDate: '2021-11-30',
|
|
||||||
grossPerformance: new Big('24.6'),
|
|
||||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'0.09004392386530014641'
|
|
||||||
),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('24.6'),
|
|
||||||
investment: new Big('273.2'),
|
|
||||||
investmentWithCurrencyEffect: new Big('273.2'),
|
|
||||||
netPerformance: new Big('23.05'),
|
|
||||||
netPerformancePercentage: new Big('0.08437042459736456808'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'0.08437042459736456808'
|
|
||||||
),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('23.05'),
|
|
||||||
marketPrice: 148.9,
|
|
||||||
marketPriceInBaseCurrency: 148.9,
|
|
||||||
quantity: new Big('2'),
|
|
||||||
symbol: 'BALN.SW',
|
|
||||||
tags: [],
|
|
||||||
timeWeightedInvestment: new Big('273.2'),
|
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
|
||||||
transactionCount: 1,
|
|
||||||
valueInBaseCurrency: new Big('297.8')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
totalFeesWithCurrencyEffect: new Big('1.55'),
|
|
||||||
totalInterestWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInvestment: new Big('273.2'),
|
|
||||||
totalInvestmentWithCurrencyEffect: new Big('273.2'),
|
|
||||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalValuablesWithCurrencyEffect: new Big('0')
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(investments).toEqual([
|
|
||||||
{ date: '2021-11-30', investment: new Big('273.2') }
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(investmentsByMonth).toEqual([
|
|
||||||
{ date: '2021-11-01', investment: 273.2 },
|
|
||||||
{ date: '2021-12-01', investment: 0 }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,256 +0,0 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import {
|
|
||||||
activityDummyData,
|
|
||||||
symbolProfileDummyData,
|
|
||||||
userDummyData
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
|
||||||
import {
|
|
||||||
PortfolioCalculatorFactory,
|
|
||||||
PerformanceCalculationType
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
|
||||||
return RedisCacheServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock(
|
|
||||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
|
||||||
() => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
|
||||||
return ExchangeRateDataServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let factory: PortfolioCalculatorFactory;
|
|
||||||
let redisCacheService: RedisCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
configurationService = new ConfigurationService();
|
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
redisCacheService = new RedisCacheService(null, null);
|
|
||||||
|
|
||||||
factory = new PortfolioCalculatorFactory(
|
|
||||||
configurationService,
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
redisCacheService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get current positions', () => {
|
|
||||||
it.only('with BTCUSD buy and sell partially', async () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
|
||||||
|
|
||||||
const activities: Activity[] = [
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2015-01-01'),
|
|
||||||
fee: 0,
|
|
||||||
quantity: 2,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Bitcoin USD',
|
|
||||||
symbol: 'BTCUSD'
|
|
||||||
},
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: 320.43
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2017-12-31'),
|
|
||||||
fee: 0,
|
|
||||||
quantity: 1,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Bitcoin USD',
|
|
||||||
symbol: 'BTCUSD'
|
|
||||||
},
|
|
||||||
type: 'SELL',
|
|
||||||
unitPrice: 14156.4
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const portfolioCalculator = factory.createCalculator({
|
|
||||||
activities,
|
|
||||||
calculationType: PerformanceCalculationType.TWR,
|
|
||||||
currency: 'CHF',
|
|
||||||
hasFilters: false,
|
|
||||||
userId: userDummyData.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
|
||||||
start: parseDate('2015-01-01')
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
|
||||||
parseDate('2015-01-01')
|
|
||||||
);
|
|
||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
|
||||||
data: chartData,
|
|
||||||
groupBy: 'month'
|
|
||||||
});
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(portfolioSnapshot).toEqual({
|
|
||||||
currentValueInBaseCurrency: new Big('13298.425356'),
|
|
||||||
errors: [],
|
|
||||||
grossPerformance: new Big('27172.74'),
|
|
||||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'41.6401219622042072686'
|
|
||||||
),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
|
|
||||||
hasErrors: false,
|
|
||||||
netPerformance: new Big('27172.74'),
|
|
||||||
netPerformancePercentage: new Big('42.41978276196153750666'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'41.6401219622042072686'
|
|
||||||
),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
averagePrice: new Big('320.43'),
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
dividend: new Big('0'),
|
|
||||||
dividendInBaseCurrency: new Big('0'),
|
|
||||||
fee: new Big('0'),
|
|
||||||
feeInBaseCurrency: new Big('0'),
|
|
||||||
firstBuyDate: '2015-01-01',
|
|
||||||
grossPerformance: new Big('27172.74'),
|
|
||||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'41.6401219622042072686'
|
|
||||||
),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big(
|
|
||||||
'26516.208701400000064086'
|
|
||||||
),
|
|
||||||
investment: new Big('320.43'),
|
|
||||||
investmentWithCurrencyEffect: new Big('318.542667299999967957'),
|
|
||||||
marketPrice: 13657.2,
|
|
||||||
marketPriceInBaseCurrency: 13298.425356,
|
|
||||||
netPerformance: new Big('27172.74'),
|
|
||||||
netPerformancePercentage: new Big('42.41978276196153750666'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'41.6401219622042072686'
|
|
||||||
),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big(
|
|
||||||
'26516.208701400000064086'
|
|
||||||
),
|
|
||||||
quantity: new Big('1'),
|
|
||||||
symbol: 'BTCUSD',
|
|
||||||
tags: [],
|
|
||||||
timeWeightedInvestment: new Big('640.56763686131386861314'),
|
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
|
||||||
'636.79469348020066587024'
|
|
||||||
),
|
|
||||||
transactionCount: 2,
|
|
||||||
valueInBaseCurrency: new Big('13298.425356')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
totalFeesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInterestWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInvestment: new Big('320.43'),
|
|
||||||
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
|
|
||||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalValuablesWithCurrencyEffect: new Big('0')
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(investments).toEqual([
|
|
||||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
|
||||||
{ date: '2017-12-31', investment: new Big('320.43') }
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(investmentsByMonth).toEqual([
|
|
||||||
{ date: '2015-01-01', investment: 637.0853345999999 },
|
|
||||||
{ date: '2015-02-01', investment: 0 },
|
|
||||||
{ date: '2015-03-01', investment: 0 },
|
|
||||||
{ date: '2015-04-01', investment: 0 },
|
|
||||||
{ date: '2015-05-01', investment: 0 },
|
|
||||||
{ date: '2015-06-01', investment: 0 },
|
|
||||||
{ date: '2015-07-01', investment: 0 },
|
|
||||||
{ date: '2015-08-01', investment: 0 },
|
|
||||||
{ date: '2015-09-01', investment: 0 },
|
|
||||||
{ date: '2015-10-01', investment: 0 },
|
|
||||||
{ date: '2015-11-01', investment: 0 },
|
|
||||||
{ date: '2015-12-01', investment: 0 },
|
|
||||||
{ date: '2016-01-01', investment: 0 },
|
|
||||||
{ date: '2016-02-01', investment: 0 },
|
|
||||||
{ date: '2016-03-01', investment: 0 },
|
|
||||||
{ date: '2016-04-01', investment: 0 },
|
|
||||||
{ date: '2016-05-01', investment: 0 },
|
|
||||||
{ date: '2016-06-01', investment: 0 },
|
|
||||||
{ date: '2016-07-01', investment: 0 },
|
|
||||||
{ date: '2016-08-01', investment: 0 },
|
|
||||||
{ date: '2016-09-01', investment: 0 },
|
|
||||||
{ date: '2016-10-01', investment: 0 },
|
|
||||||
{ date: '2016-11-01', investment: 0 },
|
|
||||||
{ date: '2016-12-01', investment: 0 },
|
|
||||||
{ date: '2017-01-01', investment: 0 },
|
|
||||||
{ date: '2017-02-01', investment: 0 },
|
|
||||||
{ date: '2017-03-01', investment: 0 },
|
|
||||||
{ date: '2017-04-01', investment: 0 },
|
|
||||||
{ date: '2017-05-01', investment: 0 },
|
|
||||||
{ date: '2017-06-01', investment: 0 },
|
|
||||||
{ date: '2017-07-01', investment: 0 },
|
|
||||||
{ date: '2017-08-01', investment: 0 },
|
|
||||||
{ date: '2017-09-01', investment: 0 },
|
|
||||||
{ date: '2017-10-01', investment: 0 },
|
|
||||||
{ date: '2017-11-01', investment: 0 },
|
|
||||||
{ date: '2017-12-01', investment: -318.54266729999995 },
|
|
||||||
{ date: '2018-01-01', investment: 0 }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,158 +0,0 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import {
|
|
||||||
activityDummyData,
|
|
||||||
symbolProfileDummyData,
|
|
||||||
userDummyData
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
|
||||||
import {
|
|
||||||
PortfolioCalculatorFactory,
|
|
||||||
PerformanceCalculationType
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
|
||||||
return RedisCacheServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let factory: PortfolioCalculatorFactory;
|
|
||||||
let redisCacheService: RedisCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
configurationService = new ConfigurationService();
|
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
redisCacheService = new RedisCacheService(null, null);
|
|
||||||
|
|
||||||
factory = new PortfolioCalculatorFactory(
|
|
||||||
configurationService,
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
redisCacheService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('compute portfolio snapshot', () => {
|
|
||||||
it.only('with fee activity', async () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
|
||||||
|
|
||||||
const activities: Activity[] = [
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2021-09-01'),
|
|
||||||
fee: 49,
|
|
||||||
quantity: 0,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'MANUAL',
|
|
||||||
name: 'Account Opening Fee',
|
|
||||||
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
|
|
||||||
},
|
|
||||||
type: 'FEE',
|
|
||||||
unitPrice: 0
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const portfolioCalculator = factory.createCalculator({
|
|
||||||
activities,
|
|
||||||
calculationType: PerformanceCalculationType.TWR,
|
|
||||||
currency: 'USD',
|
|
||||||
hasFilters: false,
|
|
||||||
userId: userDummyData.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
|
||||||
parseDate('2021-11-30')
|
|
||||||
);
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(portfolioSnapshot).toEqual({
|
|
||||||
currentValueInBaseCurrency: new Big('0'),
|
|
||||||
errors: [],
|
|
||||||
grossPerformance: new Big('0'),
|
|
||||||
grossPerformancePercentage: new Big('0'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('0'),
|
|
||||||
hasErrors: true,
|
|
||||||
netPerformance: new Big('0'),
|
|
||||||
netPerformancePercentage: new Big('0'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big('0'),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('0'),
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
averagePrice: new Big('0'),
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'MANUAL',
|
|
||||||
dividend: new Big('0'),
|
|
||||||
dividendInBaseCurrency: new Big('0'),
|
|
||||||
fee: new Big('49'),
|
|
||||||
feeInBaseCurrency: new Big('49'),
|
|
||||||
firstBuyDate: '2021-09-01',
|
|
||||||
grossPerformance: null,
|
|
||||||
grossPerformancePercentage: null,
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: null,
|
|
||||||
grossPerformanceWithCurrencyEffect: null,
|
|
||||||
investment: new Big('0'),
|
|
||||||
investmentWithCurrencyEffect: new Big('0'),
|
|
||||||
marketPrice: null,
|
|
||||||
marketPriceInBaseCurrency: 0,
|
|
||||||
netPerformance: null,
|
|
||||||
netPerformancePercentage: null,
|
|
||||||
netPerformancePercentageWithCurrencyEffect: null,
|
|
||||||
netPerformanceWithCurrencyEffect: null,
|
|
||||||
quantity: new Big('0'),
|
|
||||||
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
|
|
||||||
tags: [],
|
|
||||||
timeWeightedInvestment: new Big('0'),
|
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
|
|
||||||
transactionCount: 1,
|
|
||||||
valueInBaseCurrency: new Big('0')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
totalFeesWithCurrencyEffect: new Big('49'),
|
|
||||||
totalInterestWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInvestment: new Big('0'),
|
|
||||||
totalInvestmentWithCurrencyEffect: new Big('0'),
|
|
||||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalValuablesWithCurrencyEffect: new Big('0')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,222 +0,0 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import {
|
|
||||||
activityDummyData,
|
|
||||||
symbolProfileDummyData,
|
|
||||||
userDummyData
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
|
||||||
import {
|
|
||||||
PortfolioCalculatorFactory,
|
|
||||||
PerformanceCalculationType
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
|
||||||
return RedisCacheServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock(
|
|
||||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
|
||||||
() => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
|
||||||
return ExchangeRateDataServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let factory: PortfolioCalculatorFactory;
|
|
||||||
let redisCacheService: RedisCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
configurationService = new ConfigurationService();
|
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
redisCacheService = new RedisCacheService(null, null);
|
|
||||||
|
|
||||||
factory = new PortfolioCalculatorFactory(
|
|
||||||
configurationService,
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
redisCacheService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get current positions', () => {
|
|
||||||
it.only('with GOOGL buy', async () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
|
||||||
|
|
||||||
const activities: Activity[] = [
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2023-01-03'),
|
|
||||||
fee: 1,
|
|
||||||
quantity: 1,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Alphabet Inc.',
|
|
||||||
symbol: 'GOOGL'
|
|
||||||
},
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: 89.12
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const portfolioCalculator = factory.createCalculator({
|
|
||||||
activities,
|
|
||||||
calculationType: PerformanceCalculationType.TWR,
|
|
||||||
currency: 'CHF',
|
|
||||||
hasFilters: false,
|
|
||||||
userId: userDummyData.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({
|
|
||||||
start: parseDate('2023-01-03')
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
|
||||||
parseDate('2023-01-03')
|
|
||||||
);
|
|
||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
|
||||||
data: chartData,
|
|
||||||
groupBy: 'month'
|
|
||||||
});
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(portfolioSnapshot).toEqual({
|
|
||||||
currentValueInBaseCurrency: new Big('103.10483'),
|
|
||||||
errors: [],
|
|
||||||
grossPerformance: new Big('27.33'),
|
|
||||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'0.25235044599563974109'
|
|
||||||
),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
|
|
||||||
hasErrors: false,
|
|
||||||
netPerformance: new Big('26.33'),
|
|
||||||
netPerformancePercentage: new Big('0.29544434470377019749'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'0.24112962014285697628'
|
|
||||||
),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('19.851974'),
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
averagePrice: new Big('89.12'),
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
dividend: new Big('0'),
|
|
||||||
dividendInBaseCurrency: new Big('0'),
|
|
||||||
fee: new Big('1'),
|
|
||||||
feeInBaseCurrency: new Big('0.9238'),
|
|
||||||
firstBuyDate: '2023-01-03',
|
|
||||||
grossPerformance: new Big('27.33'),
|
|
||||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'0.25235044599563974109'
|
|
||||||
),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
|
|
||||||
investment: new Big('89.12'),
|
|
||||||
investmentWithCurrencyEffect: new Big('82.329056'),
|
|
||||||
netPerformance: new Big('26.33'),
|
|
||||||
netPerformancePercentage: new Big('0.29544434470377019749'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
|
||||||
'0.24112962014285697628'
|
|
||||||
),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('19.851974'),
|
|
||||||
marketPrice: 116.45,
|
|
||||||
marketPriceInBaseCurrency: 103.10483,
|
|
||||||
quantity: new Big('1'),
|
|
||||||
symbol: 'GOOGL',
|
|
||||||
tags: [],
|
|
||||||
timeWeightedInvestment: new Big('89.12'),
|
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
|
||||||
transactionCount: 1,
|
|
||||||
valueInBaseCurrency: new Big('103.10483')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
totalFeesWithCurrencyEffect: new Big('0.9238'),
|
|
||||||
totalInterestWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInvestment: new Big('89.12'),
|
|
||||||
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
|
|
||||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalValuablesWithCurrencyEffect: new Big('0')
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(investments).toEqual([
|
|
||||||
{ date: '2023-01-03', investment: new Big('89.12') }
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(investmentsByMonth).toEqual([
|
|
||||||
{ date: '2023-01-01', investment: 82.329056 },
|
|
||||||
{
|
|
||||||
date: '2023-02-01',
|
|
||||||
investment: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2023-03-01',
|
|
||||||
investment: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2023-04-01',
|
|
||||||
investment: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2023-05-01',
|
|
||||||
investment: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2023-06-01',
|
|
||||||
investment: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2023-07-01',
|
|
||||||
investment: 0
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,158 +0,0 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import {
|
|
||||||
activityDummyData,
|
|
||||||
symbolProfileDummyData,
|
|
||||||
userDummyData
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
|
||||||
import {
|
|
||||||
PortfolioCalculatorFactory,
|
|
||||||
PerformanceCalculationType
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
|
||||||
return RedisCacheServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let factory: PortfolioCalculatorFactory;
|
|
||||||
let redisCacheService: RedisCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
configurationService = new ConfigurationService();
|
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
redisCacheService = new RedisCacheService(null, null);
|
|
||||||
|
|
||||||
factory = new PortfolioCalculatorFactory(
|
|
||||||
configurationService,
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
redisCacheService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('compute portfolio snapshot', () => {
|
|
||||||
it.only('with item activity', async () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2022-01-31').getTime());
|
|
||||||
|
|
||||||
const activities: Activity[] = [
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2022-01-01'),
|
|
||||||
fee: 0,
|
|
||||||
quantity: 1,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'MANUAL',
|
|
||||||
name: 'Penthouse Apartment',
|
|
||||||
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
|
|
||||||
},
|
|
||||||
type: 'ITEM',
|
|
||||||
unitPrice: 500000
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const portfolioCalculator = factory.createCalculator({
|
|
||||||
activities,
|
|
||||||
calculationType: PerformanceCalculationType.TWR,
|
|
||||||
currency: 'USD',
|
|
||||||
hasFilters: false,
|
|
||||||
userId: userDummyData.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
|
||||||
parseDate('2022-01-01')
|
|
||||||
);
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(portfolioSnapshot).toEqual({
|
|
||||||
currentValueInBaseCurrency: new Big('0'),
|
|
||||||
errors: [],
|
|
||||||
grossPerformance: new Big('0'),
|
|
||||||
grossPerformancePercentage: new Big('0'),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big('0'),
|
|
||||||
hasErrors: true,
|
|
||||||
netPerformance: new Big('0'),
|
|
||||||
netPerformancePercentage: new Big('0'),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big('0'),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big('0'),
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
averagePrice: new Big('500000'),
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'MANUAL',
|
|
||||||
dividend: new Big('0'),
|
|
||||||
dividendInBaseCurrency: new Big('0'),
|
|
||||||
fee: new Big('0'),
|
|
||||||
feeInBaseCurrency: new Big('0'),
|
|
||||||
firstBuyDate: '2022-01-01',
|
|
||||||
grossPerformance: null,
|
|
||||||
grossPerformancePercentage: null,
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: null,
|
|
||||||
grossPerformanceWithCurrencyEffect: null,
|
|
||||||
investment: new Big('0'),
|
|
||||||
investmentWithCurrencyEffect: new Big('0'),
|
|
||||||
marketPrice: null,
|
|
||||||
marketPriceInBaseCurrency: 500000,
|
|
||||||
netPerformance: null,
|
|
||||||
netPerformancePercentage: null,
|
|
||||||
netPerformancePercentageWithCurrencyEffect: null,
|
|
||||||
netPerformanceWithCurrencyEffect: null,
|
|
||||||
quantity: new Big('0'),
|
|
||||||
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
|
|
||||||
tags: [],
|
|
||||||
timeWeightedInvestment: new Big('0'),
|
|
||||||
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
|
|
||||||
transactionCount: 1,
|
|
||||||
valueInBaseCurrency: new Big('0')
|
|
||||||
}
|
|
||||||
],
|
|
||||||
totalFeesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInterestWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInvestment: new Big('0'),
|
|
||||||
totalInvestmentWithCurrencyEffect: new Big('0'),
|
|
||||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalValuablesWithCurrencyEffect: new Big('0')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,108 +0,0 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import {
|
|
||||||
activityDummyData,
|
|
||||||
symbolProfileDummyData,
|
|
||||||
userDummyData
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
|
||||||
import {
|
|
||||||
PortfolioCalculatorFactory,
|
|
||||||
PerformanceCalculationType
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
|
||||||
return RedisCacheServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let factory: PortfolioCalculatorFactory;
|
|
||||||
let redisCacheService: RedisCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
configurationService = new ConfigurationService();
|
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
redisCacheService = new RedisCacheService(null, null);
|
|
||||||
|
|
||||||
factory = new PortfolioCalculatorFactory(
|
|
||||||
configurationService,
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
redisCacheService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('compute portfolio snapshot', () => {
|
|
||||||
it.only('with liability activity', async () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2022-01-31').getTime());
|
|
||||||
|
|
||||||
const activities: Activity[] = [
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2023-01-01'), // Date in future
|
|
||||||
fee: 0,
|
|
||||||
quantity: 1,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'MANUAL',
|
|
||||||
name: 'Loan',
|
|
||||||
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
|
|
||||||
},
|
|
||||||
type: 'LIABILITY',
|
|
||||||
unitPrice: 3000
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const portfolioCalculator = factory.createCalculator({
|
|
||||||
activities,
|
|
||||||
calculationType: PerformanceCalculationType.TWR,
|
|
||||||
currency: 'USD',
|
|
||||||
hasFilters: false,
|
|
||||||
userId: userDummyData.id
|
|
||||||
});
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
const liabilitiesInBaseCurrency =
|
|
||||||
await portfolioCalculator.getLiabilitiesInBaseCurrency();
|
|
||||||
|
|
||||||
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,165 +0,0 @@
|
|||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
|
||||||
import {
|
|
||||||
activityDummyData,
|
|
||||||
symbolProfileDummyData,
|
|
||||||
userDummyData
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
|
||||||
import {
|
|
||||||
PerformanceCalculationType,
|
|
||||||
PortfolioCalculatorFactory
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
|
||||||
return RedisCacheServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock(
|
|
||||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
|
||||||
() => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
|
||||||
return ExchangeRateDataServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let factory: PortfolioCalculatorFactory;
|
|
||||||
let redisCacheService: RedisCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
configurationService = new ConfigurationService();
|
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
redisCacheService = new RedisCacheService(null, null);
|
|
||||||
|
|
||||||
factory = new PortfolioCalculatorFactory(
|
|
||||||
configurationService,
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
redisCacheService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get current positions', () => {
|
|
||||||
it.only('with MSFT buy', async () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
|
||||||
|
|
||||||
const activities: Activity[] = [
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2021-09-16'),
|
|
||||||
fee: 19,
|
|
||||||
quantity: 1,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Microsoft Inc.',
|
|
||||||
symbol: 'MSFT'
|
|
||||||
},
|
|
||||||
type: 'BUY',
|
|
||||||
unitPrice: 298.58
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...activityDummyData,
|
|
||||||
date: new Date('2021-11-16'),
|
|
||||||
fee: 0,
|
|
||||||
quantity: 1,
|
|
||||||
SymbolProfile: {
|
|
||||||
...symbolProfileDummyData,
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
name: 'Microsoft Inc.',
|
|
||||||
symbol: 'MSFT'
|
|
||||||
},
|
|
||||||
type: 'DIVIDEND',
|
|
||||||
unitPrice: 0.62
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const portfolioCalculator = factory.createCalculator({
|
|
||||||
activities,
|
|
||||||
calculationType: PerformanceCalculationType.TWR,
|
|
||||||
currency: 'USD',
|
|
||||||
hasFilters: false,
|
|
||||||
userId: userDummyData.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
|
||||||
parseDate('2023-07-10')
|
|
||||||
);
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(portfolioSnapshot).toMatchObject({
|
|
||||||
errors: [],
|
|
||||||
hasErrors: false,
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
averagePrice: new Big('298.58'),
|
|
||||||
currency: 'USD',
|
|
||||||
dataSource: 'YAHOO',
|
|
||||||
dividend: new Big('0.62'),
|
|
||||||
dividendInBaseCurrency: new Big('0.62'),
|
|
||||||
fee: new Big('19'),
|
|
||||||
firstBuyDate: '2021-09-16',
|
|
||||||
investment: new Big('298.58'),
|
|
||||||
investmentWithCurrencyEffect: new Big('298.58'),
|
|
||||||
marketPrice: 331.83,
|
|
||||||
marketPriceInBaseCurrency: 331.83,
|
|
||||||
quantity: new Big('1'),
|
|
||||||
symbol: 'MSFT',
|
|
||||||
tags: [],
|
|
||||||
transactionCount: 2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
totalFeesWithCurrencyEffect: new Big('19'),
|
|
||||||
totalInterestWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInvestment: new Big('298.58'),
|
|
||||||
totalInvestmentWithCurrencyEffect: new Big('298.58'),
|
|
||||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalValuablesWithCurrencyEffect: new Big('0')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,124 +0,0 @@
|
|||||||
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
|
||||||
import {
|
|
||||||
PerformanceCalculationType,
|
|
||||||
PortfolioCalculatorFactory
|
|
||||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
import { subDays } from 'date-fns';
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
|
||||||
return CurrentRateServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
|
||||||
return {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
|
||||||
return RedisCacheServiceMock;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let factory: PortfolioCalculatorFactory;
|
|
||||||
let redisCacheService: RedisCacheService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
configurationService = new ConfigurationService();
|
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(null, null, null, null);
|
|
||||||
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
redisCacheService = new RedisCacheService(null, null);
|
|
||||||
|
|
||||||
factory = new PortfolioCalculatorFactory(
|
|
||||||
configurationService,
|
|
||||||
currentRateService,
|
|
||||||
exchangeRateDataService,
|
|
||||||
redisCacheService
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get current positions', () => {
|
|
||||||
it('with no orders', async () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(Date, 'now')
|
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
|
||||||
|
|
||||||
const portfolioCalculator = factory.createCalculator({
|
|
||||||
activities: [],
|
|
||||||
calculationType: PerformanceCalculationType.TWR,
|
|
||||||
currency: 'CHF',
|
|
||||||
hasFilters: false,
|
|
||||||
userId: userDummyData.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const start = subDays(new Date(Date.now()), 10);
|
|
||||||
|
|
||||||
const chartData = await portfolioCalculator.getChartData({ start });
|
|
||||||
|
|
||||||
const portfolioSnapshot =
|
|
||||||
await portfolioCalculator.computeSnapshot(start);
|
|
||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
|
||||||
data: chartData,
|
|
||||||
groupBy: 'month'
|
|
||||||
});
|
|
||||||
|
|
||||||
spy.mockRestore();
|
|
||||||
|
|
||||||
expect(portfolioSnapshot).toEqual({
|
|
||||||
currentValueInBaseCurrency: new Big(0),
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0),
|
|
||||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
|
||||||
grossPerformanceWithCurrencyEffect: new Big(0),
|
|
||||||
hasErrors: false,
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
|
||||||
netPerformanceWithCurrencyEffect: new Big(0),
|
|
||||||
positions: [],
|
|
||||||
totalFeesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInterestWithCurrencyEffect: new Big('0'),
|
|
||||||
totalInvestment: new Big(0),
|
|
||||||
totalInvestmentWithCurrencyEffect: new Big(0),
|
|
||||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
|
||||||
totalValuablesWithCurrencyEffect: new Big('0')
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(investments).toEqual([]);
|
|
||||||
|
|
||||||
expect(investmentsByMonth).toEqual([
|
|
||||||
{
|
|
||||||
date: '2021-12-01',
|
|
||||||
investment: 0
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user