Compare commits
71 Commits
Author | SHA1 | Date | |
---|---|---|---|
f5df970685 | |||
edfdc0c346 | |||
fcfe7b1787 | |||
170b8acc65 | |||
a47829082e | |||
48ab5fcf08 | |||
dc8b60eeb1 | |||
ee67432ffc | |||
7755a6b655 | |||
d7f72819de | |||
2a4d7bf14f | |||
d49287922f | |||
ac0f6f40cf | |||
d91f947ab0 | |||
af71274ea9 | |||
0feba4b8d9 | |||
62f85293e2 | |||
6a048cee85 | |||
0d93612d16 | |||
9bf68b0d20 | |||
371f1dc451 | |||
5cb2ec6411 | |||
3723a1d8b8 | |||
4c30e9459d | |||
23d323073d | |||
0ad734262a | |||
0649f9fd2c | |||
d089662dab | |||
8c1c336fc6 | |||
43b4f14ace | |||
3717e38845 | |||
265d4d0450 | |||
726e727c7d | |||
cb664774c0 | |||
b89bf1d5e8 | |||
53ce37a83a | |||
e9ac9057ff | |||
7020fc2a93 | |||
efcd9539dd | |||
61ecc48d0e | |||
e465f1b791 | |||
01b6c14bcc | |||
34b02210df | |||
0034776b34 | |||
b183c45027 | |||
7d68905f1b | |||
0953c072fe | |||
d152187ee8 | |||
3c5affce88 | |||
f27e21f9a0 | |||
337ca328c3 | |||
beb9e2c43f | |||
4d79df90a7 | |||
aa72d9b730 | |||
80e899a5d3 | |||
7c33120546 | |||
7f3c86038f | |||
c1446f8559 | |||
88d5dfe435 | |||
7dc8f80fdf | |||
96f90c7259 | |||
a10d9cb6ba | |||
4547c5da1d | |||
28706d7b26 | |||
492bc5e17b | |||
6c37737051 | |||
8677d20c2c | |||
4d905065ad | |||
5599b41b83 | |||
8d5a60d777 | |||
695acf4f3f |
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,13 @@ labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
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).
|
||||
**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).
|
||||
|
||||
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**
|
||||
|
||||
@ -36,8 +42,9 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
||||
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- Cloud or Self-hosted
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Cloud or Self-hosted
|
||||
- Experimental Features enabled or disabled
|
||||
- Browser
|
||||
- OS
|
||||
|
||||
|
@ -1,3 +1,7 @@
|
||||
/.nx/cache
|
||||
|
||||
# Issue: https://github.com/prettier/prettier/issues/15650
|
||||
/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
|
||||
|
||||
/dist
|
||||
/test/import
|
||||
|
145
CHANGELOG.md
145
CHANGELOG.md
@ -5,6 +5,125 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 2.37.0 - 2024-01-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the chart size in the asset profile details dialog of the admin control
|
||||
- Updated the `docker compose` instructions to _Compose V2_ in the documentation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the hidden fifth tab on mobile
|
||||
|
||||
## 2.36.0 - 2024-01-07
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the assistant by a tag selector (experimental)
|
||||
- Added support to set a _CoinGecko_ Demo API key via environment variable (`API_KEY_COINGECKO_DEMO`)
|
||||
- Added support to set a _CoinGecko_ Pro API key via environment variable (`API_KEY_COINGECKO_PRO`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
- Removed the `AccountType` enum
|
||||
- Refreshed the cryptocurrencies list
|
||||
|
||||
## 2.35.0 - 2024-01-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to grant private access
|
||||
- Added a hint for _Time-Weighted Rate of Return_ (TWR) to the portfolio summary tab on the home page
|
||||
- Added support for REST APIs (`JSON`) via the scraper configuration
|
||||
- Enabled the _Redis_ authentication in the `docker-compose` files
|
||||
- Set up a git-hook to format the code before any commit
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the user interface of the access table to share the portfolio
|
||||
- Improved the style of the assistant (experimental)
|
||||
|
||||
## 2.34.0 - 2024-01-02
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the assistant by a date range selector (experimental)
|
||||
- Added a button to test the scraper configuration in the asset profile details dialog of the admin control
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style of the _Top 3_ and _Bottom 3_ performers on the analysis page
|
||||
- Upgraded `Nx` from version `17.2.7` to `17.2.8`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the time-weighted performance calculation for `1D`
|
||||
- Improved the tabs on iOS (_Add to Home Screen_)
|
||||
|
||||
## 2.33.0 - 2023-12-31
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to edit the currency of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
|
||||
- Added a hint for the community languages in the user settings
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the performance calculation to a time-weighted approach
|
||||
- Normalized the benchmark by currency in the benchmark comparator
|
||||
- Increased the timeout to load currencies in the exchange rate data service
|
||||
- Exposed the environment variable `REQUEST_TIMEOUT`
|
||||
- Used the `HasPermission` annotation in endpoints
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.9.0` to `2.9.1`
|
||||
- Upgraded `Nx` from version `17.2.5` to `17.2.7`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the handling of derived currencies (`USX`)
|
||||
|
||||
## 2.32.0 - 2023-12-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to search for an asset profile by `id` as an administrator
|
||||
|
||||
### Changed
|
||||
|
||||
- Set the select column of the lazy-loaded activities table to stick at the end (experimental)
|
||||
- Dropped the activity id in the activities import
|
||||
- Improved the validation of the currency management in the admin control panel
|
||||
- Improved the performance of the value redaction interceptor for the impersonation mode by eliminating `cloneDeep`
|
||||
- Modernized the `Nx` executors
|
||||
- `@nx/eslint:lint`
|
||||
- `@nx/webpack:webpack`
|
||||
- Upgraded `prettier` from version `3.1.0` to `3.1.1`
|
||||
- Upgraded `prisma` from version `5.7.0` to `5.7.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reset the letter spacing in buttons
|
||||
|
||||
## 2.31.0 - 2023-12-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Introduced the lazy-loaded activities table to the account detail dialog (experimental)
|
||||
- Introduced the lazy-loaded activities table to the import activities dialog (experimental)
|
||||
- Introduced the lazy-loaded activities table to the position detail dialog (experimental)
|
||||
- Improved the font weight in the value component
|
||||
- Improved the language localization for Türkçe (`tr`)
|
||||
- Upgraded `angular` from version `17.0.4` to `17.0.7`
|
||||
- Upgraded to _Inter_ 4 font family
|
||||
- Upgraded `Nx` from version `17.0.2` to `17.2.5`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the loading state in the lazy-loaded activities table on the portfolio activities page (experimental)
|
||||
- Fixed the edit of activity in the lazy-loaded activities table on the portfolio activities page (experimental)
|
||||
|
||||
## 2.30.0 - 2023-12-12
|
||||
|
||||
### Added
|
||||
@ -264,7 +383,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Changed the users table in the admin control panel to an `@angular/material` data table
|
||||
- Improved the styling of the membership status
|
||||
- Improved the style of the membership status
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -1439,7 +1558,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling in the admin control panel
|
||||
- Improved the style in the admin control panel
|
||||
- Removed the _Google Play_ badge from the landing page
|
||||
- Upgraded `eslint` dependencies
|
||||
|
||||
@ -2194,7 +2313,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Simplified the initialization of the exchange rate service
|
||||
- Improved the orders query for `assetClass` with symbol profile overrides
|
||||
- Improved the styling of the benchmarks in the markets overview
|
||||
- Improved the style of the benchmarks in the markets overview
|
||||
|
||||
### Todo
|
||||
|
||||
@ -2528,7 +2647,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a styling issue in the benchmark component on mobile
|
||||
- Fixed a style issue in the benchmark component on mobile
|
||||
|
||||
## 1.152.0 - 26.05.2022
|
||||
|
||||
@ -3135,7 +3254,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the styling in the footer row of the activities table
|
||||
- Fixed the style in the footer row of the activities table
|
||||
|
||||
## 1.106.0 - 23.01.2022
|
||||
|
||||
@ -3903,7 +4022,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Improved the wording for the _Restricted View_: _Presenter View_
|
||||
- Improved the styling of the tables
|
||||
- Improved the style of the tables
|
||||
- Ignored cash assets in the allocation chart by sector, continent and country
|
||||
|
||||
### Fixed
|
||||
@ -4106,8 +4225,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling of the current pricing plan
|
||||
- Improved the styling of the transaction type badge
|
||||
- Improved the style of the current pricing plan
|
||||
- Improved the style of the transaction type badge
|
||||
- Set the public _Stripe_ key dynamically
|
||||
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
|
||||
|
||||
@ -4467,7 +4586,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the users table styling of the admin control panel
|
||||
- Improved the users table style of the admin control panel
|
||||
- Improved the background colors in the dark mode
|
||||
|
||||
## 0.92.0 - 25.04.2021
|
||||
@ -4491,7 +4610,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling of the rules in the _X-ray_ section
|
||||
- Improved the style of the rules in the _X-ray_ section
|
||||
|
||||
## 0.90.0 - 22.04.2021
|
||||
|
||||
@ -4686,7 +4805,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Improved the alignment of the _Why Ghostfolio?_ section
|
||||
- Improved the styling of the _Fear & Greed Index_ (market mood)
|
||||
- Improved the style of the _Fear & Greed Index_ (market mood)
|
||||
|
||||
## 0.73.0 - 31.03.2021
|
||||
|
||||
@ -4732,7 +4851,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling in the _X-ray_ section
|
||||
- Improved the style in the _X-ray_ section
|
||||
|
||||
## 0.70.0 - 27.03.2021
|
||||
|
||||
@ -5027,7 +5146,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
|
||||
- Only show relevant data in the position detail dialog
|
||||
- Improved the performance chart styling in Safari
|
||||
- Improved the performance chart style in Safari
|
||||
|
||||
## 0.40.0 - 01.03.2021
|
||||
|
||||
|
46
README.md
46
README.md
@ -49,7 +49,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
@ -87,19 +87,22 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
| Name | Default Value | Description |
|
||||
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | | The _CoinGecko_ Pro API |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -115,7 +118,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):
|
||||
|
||||
```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
|
||||
@ -123,8 +126,8 @@ docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||
Run the following commands to build and start the Docker images:
|
||||
|
||||
```bash
|
||||
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 build
|
||||
docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
```
|
||||
|
||||
#### Setup
|
||||
@ -135,7 +138,7 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
#### Upgrade Version
|
||||
|
||||
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.
|
||||
|
||||
### Home Server Systems (Community)
|
||||
@ -155,8 +158,9 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
||||
### Setup
|
||||
|
||||
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 `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. Open http://localhost:4200/en in your browser
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
@ -165,7 +169,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
||||
|
||||
#### Debug
|
||||
|
||||
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Serve
|
||||
|
||||
@ -278,6 +282,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
|
||||
|
||||
## License
|
||||
|
||||
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
|
||||
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
@ -7,14 +7,15 @@
|
||||
"generators": {},
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/webpack:webpack",
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"],
|
||||
"target": "node",
|
||||
"compiler": "tsc"
|
||||
"compiler": "tsc",
|
||||
"webpackConfig": "apps/api/webpack.config.js"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@ -39,7 +40,7 @@
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"executor": "@nx/eslint:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -24,11 +27,12 @@ import { CreateAccessDto } from './create-access.dto';
|
||||
export class AccessController {
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getAllAccesses(): Promise<Access[]> {
|
||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||
include: {
|
||||
@ -57,13 +61,15 @@ export class AccessController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createAccess)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createAccess(
|
||||
@Body() data: CreateAccessDto
|
||||
): Promise<AccessModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -71,25 +77,29 @@ export class AccessController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
try {
|
||||
return await this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteAccess)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
||||
const access = await this.accessService.access({ id });
|
||||
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
|
||||
!access ||
|
||||
access.userId !== this.request.user.id
|
||||
) {
|
||||
if (!access || access.userId !== this.request.user.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@ -7,7 +8,7 @@ import { AccessService } from './access.service';
|
||||
@Module({
|
||||
controllers: [AccessController],
|
||||
exports: [AccessService],
|
||||
imports: [PrismaModule],
|
||||
imports: [ConfigurationModule, PrismaModule],
|
||||
providers: [AccessService]
|
||||
})
|
||||
export class AccessModule {}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateAccessDto {
|
||||
@IsOptional()
|
||||
@ -6,7 +6,7 @@ export class CreateAccessDto {
|
||||
alias?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsUUID()
|
||||
granteeUserId?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
@ -1,3 +1,6 @@
|
||||
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 {
|
||||
Controller,
|
||||
@ -8,11 +11,11 @@ import {
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AccountBalanceService } from './account-balance.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { AccountBalance } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccountBalanceService } from './account-balance.service';
|
||||
|
||||
@Controller('account-balance')
|
||||
export class AccountBalanceController {
|
||||
@ -21,8 +24,9 @@ export class AccountBalanceController {
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.deleteAccountBalance)
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAccountBalance(
|
||||
@Param('id') id: string
|
||||
): Promise<AccountBalance> {
|
||||
@ -30,14 +34,7 @@ export class AccountBalanceController {
|
||||
id
|
||||
});
|
||||
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.deleteAccountBalance
|
||||
) ||
|
||||
!accountBalance ||
|
||||
accountBalance.userId !== this.request.user.id
|
||||
) {
|
||||
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
@ -7,7 +9,7 @@ import {
|
||||
AccountBalancesResponse,
|
||||
Accounts
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
AccountWithValue,
|
||||
RequestWithUser
|
||||
@ -47,17 +49,9 @@ export class AccountController {
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteAccount)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
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(
|
||||
{
|
||||
id_userId: {
|
||||
@ -87,7 +81,7 @@ export class AccountController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||
@ -102,7 +96,7 @@ export class AccountController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountById(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@ -122,7 +116,7 @@ export class AccountController {
|
||||
}
|
||||
|
||||
@Get(':id/balances')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountBalancesById(
|
||||
@Param('id') id: string
|
||||
@ -133,20 +127,12 @@ export class AccountController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createAccount)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createAccount(
|
||||
@Body() data: CreateAccountDto
|
||||
): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
@ -172,20 +158,12 @@ export class AccountController {
|
||||
}
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateAccount)
|
||||
@Post('transfer-balance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async transferAccountBalance(
|
||||
@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(
|
||||
this.request.user.id
|
||||
);
|
||||
@ -234,18 +212,10 @@ export class AccountController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateAccount)
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
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({
|
||||
id_userId: {
|
||||
id,
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.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 { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
@ -17,7 +20,7 @@ import {
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
MarketDataPreset,
|
||||
RequestWithUser
|
||||
@ -29,6 +32,7 @@ import {
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@ -54,61 +58,29 @@ export class AdminController {
|
||||
private readonly adminService: AdminService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly manualService: ManualService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
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();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gather7Days(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gather7Days();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/max')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherMax(): Promise<void> {
|
||||
if (
|
||||
!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(
|
||||
@ -130,21 +102,10 @@ export class AdminController {
|
||||
this.dataGatheringService.gatherMax();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/profile-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
if (
|
||||
!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(
|
||||
@ -164,24 +125,13 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileDataForSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue({
|
||||
data: {
|
||||
dataSource,
|
||||
@ -196,47 +146,25 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
public async gatherSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherSymbolForDate(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<MarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const date = parseISO(dateString);
|
||||
|
||||
if (!isDate(date)) {
|
||||
@ -254,7 +182,8 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('market-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getMarketData(
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||
@Query('presetId') presetId?: MarketDataPreset,
|
||||
@ -264,18 +193,6 @@ export class AdminController {
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@Query('take') take?: number
|
||||
): Promise<AdminMarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAssetSubClasses,
|
||||
filterBySearchQuery
|
||||
@ -292,45 +209,47 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateMarketData(
|
||||
@Body() data: UpdateBulkMarketDataDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@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(
|
||||
({ date, marketPrice }) => ({
|
||||
dataSource,
|
||||
@ -349,26 +268,15 @@ export class AdminController {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async update(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string,
|
||||
@Body() data: UpdateMarketDataDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const date = parseISO(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
@ -383,24 +291,14 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async addProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<SymbolProfile | never> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
return this.adminService.addAssetProfile({
|
||||
dataSource,
|
||||
symbol,
|
||||
@ -409,45 +307,23 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Delete('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Patch('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async patchAssetProfileData(
|
||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<EnhancedSymbolProfile> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.patchAssetProfileData({
|
||||
...assetProfileData,
|
||||
dataSource,
|
||||
@ -455,24 +331,13 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Put('settings/:key')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateProperty(
|
||||
@Param('key') key: string,
|
||||
@Body() data: PropertyDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return await this.adminService.putSetting(key, data.value);
|
||||
}
|
||||
}
|
||||
|
@ -176,6 +176,7 @@ export class AdminService {
|
||||
|
||||
if (searchQuery) {
|
||||
where.OR = [
|
||||
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||
@ -320,6 +321,7 @@ export class AdminService {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
@ -330,6 +332,7 @@ export class AdminService {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
|
@ -1,87 +1,48 @@
|
||||
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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { JobStatus } from 'bull';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { QueueService } from './queue.service';
|
||||
|
||||
@Controller('admin/queue')
|
||||
export class QueueController {
|
||||
public constructor(
|
||||
private readonly queueService: QueueService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly queueService: QueueService) {}
|
||||
|
||||
@Delete('job')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteJobs(
|
||||
@Query('status') filterByStatus?: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||
return this.queueService.deleteJobs({ status });
|
||||
}
|
||||
|
||||
@Get('job')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getJobs(
|
||||
@Query('status') filterByStatus?: string
|
||||
): Promise<AdminJobs> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||
return this.queueService.getJobs({ status });
|
||||
}
|
||||
|
||||
@Delete('job/:id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,10 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
@ -1,40 +1,18 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
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 { Controller, Delete, Param, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('auth-device')
|
||||
export class AuthDeviceController {
|
||||
public constructor(
|
||||
private readonly authDeviceService: AuthDeviceService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly authDeviceService: AuthDeviceService) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteAuthDevice)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
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 { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||
@ -118,13 +119,13 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Get('webauthn/generate-registration-options')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async generateRegistrationOptions() {
|
||||
return this.webAuthService.generateRegistrationOptions();
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-attestation')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async verifyAttestation(
|
||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||
) {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import type {
|
||||
@ -5,7 +7,7 @@ import type {
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -33,21 +35,10 @@ export class BenchmarkController {
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.addBenchmark({
|
||||
dataSource,
|
||||
@ -71,23 +62,12 @@ export class BenchmarkController {
|
||||
}
|
||||
|
||||
@Delete(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteBenchmark(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||
dataSource,
|
||||
@ -120,7 +100,7 @@ export class BenchmarkController {
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:startDateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getBenchmarkMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@ -128,11 +108,13 @@ export class BenchmarkController {
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<BenchmarkMarketDataDetails> {
|
||||
const startDate = new Date(startDateString);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
return this.benchmarkService.getMarketDataBySymbol({
|
||||
dataSource,
|
||||
startDate,
|
||||
symbol
|
||||
symbol,
|
||||
userCurrency
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
|
||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
|
@ -11,6 +11,7 @@ describe('BenchmarkService', () => {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
@ -11,7 +12,8 @@ import {
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
calculateBenchmarkTrend
|
||||
calculateBenchmarkTrend,
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Benchmark,
|
||||
@ -21,11 +23,11 @@ import {
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { format, isSameDay, subDays } from 'date-fns';
|
||||
import { isNumber, last, uniqBy } from 'lodash';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
@ -34,6 +36,7 @@ export class BenchmarkService {
|
||||
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@ -203,8 +206,14 @@ export class BenchmarkService {
|
||||
public async getMarketDataBySymbol({
|
||||
dataSource,
|
||||
startDate,
|
||||
symbol
|
||||
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||
symbol,
|
||||
userCurrency
|
||||
}: {
|
||||
startDate: Date;
|
||||
userCurrency: string;
|
||||
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||
const marketData: { date: string; value: number }[] = [];
|
||||
|
||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||
this.symbolService.get({
|
||||
dataGatheringItem: {
|
||||
@ -226,44 +235,101 @@ export class BenchmarkService {
|
||||
})
|
||||
]);
|
||||
|
||||
const exchangeRates = await this.exchangeRateDataService.getExchangeRates({
|
||||
currencyFrom: currentSymbolItem.currency,
|
||||
currencyTo: userCurrency,
|
||||
dates: marketDataItems.map(({ date }) => {
|
||||
return date;
|
||||
})
|
||||
});
|
||||
|
||||
const exchangeRateAtStartDate =
|
||||
exchangeRates[format(startDate, DATE_FORMAT)];
|
||||
|
||||
if (!exchangeRateAtStartDate) {
|
||||
Logger.error(
|
||||
`No exchange rate has been found for ${
|
||||
currentSymbolItem.currency
|
||||
}${userCurrency} at ${format(startDate, DATE_FORMAT)}`,
|
||||
'BenchmarkService'
|
||||
);
|
||||
|
||||
return { marketData };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
const step = Math.round(
|
||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||
);
|
||||
|
||||
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
||||
const response = {
|
||||
marketData: [
|
||||
...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
|
||||
};
|
||||
})
|
||||
]
|
||||
};
|
||||
let i = 0;
|
||||
|
||||
if (currentSymbolItem?.marketPrice) {
|
||||
response.marketData.push({
|
||||
for (let marketDataItem of marketDataItems) {
|
||||
if (i % step !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exchangeRate =
|
||||
exchangeRates[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 includesToday = isSameDay(
|
||||
parseDate(last(marketData).date),
|
||||
new Date()
|
||||
);
|
||||
|
||||
if (currentSymbolItem?.marketPrice && !includesToday) {
|
||||
const exchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
|
||||
|
||||
const exchangeRateFactor =
|
||||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||
? exchangeRate / exchangeRateAtStartDate
|
||||
: 1;
|
||||
|
||||
marketData.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
value:
|
||||
this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
currentSymbolItem.marketPrice
|
||||
currentSymbolItem.marketPrice * exchangeRateFactor
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
return {
|
||||
marketData
|
||||
};
|
||||
}
|
||||
|
||||
public async addBenchmark({
|
||||
|
35
apps/api/src/app/cache/cache.controller.ts
vendored
35
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,39 +1,18 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
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 { Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('cache')
|
||||
export class CacheController {
|
||||
public constructor(
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly redisCacheService: RedisCacheService) {}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('flush')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import {
|
||||
Controller,
|
||||
@ -19,7 +20,7 @@ export class ExchangeRateController {
|
||||
) {}
|
||||
|
||||
@Get(':symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getExchangeRate(
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||
@ -14,12 +15,13 @@ export class ExportController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async export(
|
||||
@Query('activityIds') activityIds?: string[]
|
||||
): Promise<Export> {
|
||||
return this.exportService.export({
|
||||
activityIds,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
@ -13,9 +13,11 @@ export class ExportService {
|
||||
|
||||
public async export({
|
||||
activityIds,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activityIds?: string[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
const accounts = (
|
||||
@ -39,10 +41,13 @@ export class ExportService {
|
||||
}
|
||||
);
|
||||
|
||||
let activities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
let { activities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
includeDrafts: true,
|
||||
sortColumn: 'date',
|
||||
sortDirection: 'asc',
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (activityIds) {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
@ -34,7 +36,8 @@ export class ImportController {
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@HasPermission(permissions.createOrder)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async import(
|
||||
@ -42,11 +45,7 @@ export class ImportController {
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
): Promise<ImportResponse> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createAccount
|
||||
) ||
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -92,7 +91,7 @@ export class ImportController {
|
||||
}
|
||||
|
||||
@Get('dividends/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async gatherDividends(
|
||||
|
@ -236,6 +236,7 @@ export class ImportService {
|
||||
|
||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||
activitiesDto,
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
@ -459,15 +460,18 @@ export class ImportService {
|
||||
|
||||
private async extendActivitiesWithErrors({
|
||||
activitiesDto,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<Partial<Activity>[]> {
|
||||
const existingActivities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
let { activities: existingActivities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
includeDrafts: true,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
return activitiesDto.map(
|
||||
|
@ -8,7 +8,6 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||
PROPERTY_DEMO_USER_ID,
|
||||
@ -162,7 +161,7 @@ export class InfoService {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { pull_count } = await got(
|
||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||
@ -187,7 +186,7 @@ export class InfoService {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||
// @ts-ignore
|
||||
@ -214,7 +213,7 @@ export class InfoService {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { stargazers_count } = await got(
|
||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||
@ -342,7 +341,7 @@ export class InfoService {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { data } = await got(
|
||||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { HttpException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -9,6 +9,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
@Injectable()
|
||||
export class LogoService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
@ -46,7 +47,7 @@ export class LogoService {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
return got(
|
||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
@ -44,24 +46,16 @@ export class OrderController {
|
||||
) {}
|
||||
|
||||
@Delete()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteOrder)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteOrders(): Promise<number> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.orderService.deleteOrders({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||
const order = await this.orderService.order({ id });
|
||||
|
||||
@ -82,7 +76,7 @@ export class OrderController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@ -120,19 +114,11 @@ export class OrderController {
|
||||
return { activities, count };
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createOrder)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const order = await this.orderService.createOrder({
|
||||
...data,
|
||||
date: parseISO(data.date),
|
||||
@ -170,19 +156,16 @@ export class OrderController {
|
||||
return order;
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateOrder)
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||
const originalOrder = await this.orderService.order({
|
||||
id
|
||||
});
|
||||
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
|
||||
!originalOrder ||
|
||||
originalOrder.userId !== this.request.user.id
|
||||
) {
|
||||
if (!originalOrder || originalOrder.userId !== this.request.user.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
|
@ -25,7 +25,7 @@ import { endOfToday, isAfter } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { Activities, Activity } from './interfaces/activities.interface';
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
@ -37,34 +37,6 @@ export class OrderService {
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async order(
|
||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||
): Promise<Order | null> {
|
||||
return this.prismaService.order.findUnique({
|
||||
where: orderWhereUniqueInput
|
||||
});
|
||||
}
|
||||
|
||||
public async orders(params: {
|
||||
include?: Prisma.OrderInclude;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.OrderWhereUniqueInput;
|
||||
where?: Prisma.OrderWhereInput;
|
||||
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
|
||||
}): Promise<OrderWithAccount[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prismaService.order.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async createOrder(
|
||||
data: Prisma.OrderCreateInput & {
|
||||
accountId?: string;
|
||||
@ -379,6 +351,14 @@ export class OrderService {
|
||||
return { activities, count };
|
||||
}
|
||||
|
||||
public async order(
|
||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||
): Promise<Order | null> {
|
||||
return this.prismaService.order.findUnique({
|
||||
where: orderWhereUniqueInput
|
||||
});
|
||||
}
|
||||
|
||||
public async updateOrder({
|
||||
data,
|
||||
where
|
||||
@ -455,4 +435,24 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
private async orders(params: {
|
||||
include?: Prisma.OrderInclude;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.OrderWhereUniqueInput;
|
||||
where?: Prisma.OrderWhereInput;
|
||||
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
|
||||
}): Promise<OrderWithAccount[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prismaService.order.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,18 +1,17 @@
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Platform } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
@ -23,49 +22,30 @@ import { UpdatePlatformDto } from './update-platform.dto';
|
||||
|
||||
@Controller('platform')
|
||||
export class PlatformController {
|
||||
public constructor(
|
||||
private readonly platformService: PlatformService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly platformService: PlatformService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPlatforms() {
|
||||
return this.platformService.getPlatformsWithAccountCount();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createPlatform)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createPlatform(
|
||||
@Body() data: CreatePlatformDto
|
||||
): Promise<Platform> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createPlatform)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.platformService.createPlatform(data);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updatePlatform)
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updatePlatform(
|
||||
@Param('id') id: string,
|
||||
@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({
|
||||
id
|
||||
});
|
||||
@ -88,17 +68,9 @@ export class PlatformController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deletePlatform)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
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({
|
||||
id
|
||||
});
|
||||
|
@ -92,6 +92,7 @@ describe('PortfolioCalculator', () => {
|
||||
marketPrice: 148.9,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'BALN.SW',
|
||||
timeWeightedInvestment: new Big('285.8'),
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
|
@ -81,6 +81,7 @@ describe('PortfolioCalculator', () => {
|
||||
marketPrice: 148.9,
|
||||
quantity: new Big('2'),
|
||||
symbol: 'BALN.SW',
|
||||
timeWeightedInvestment: new Big('273.2'),
|
||||
transactionCount: 1
|
||||
}
|
||||
],
|
||||
|
@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
|
||||
currentValue: new Big('13657.2'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
netPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('320.43'),
|
||||
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
investment: new Big('320.43'),
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
netPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
marketPrice: 13657.2,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'BTCUSD',
|
||||
timeWeightedInvestment: new Big('640.56763686131386861314'),
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
|
@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
|
||||
currentValue: new Big('87.8'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('17.68'),
|
||||
netPerformancePercentage: new Big('0.11662269129287598945'),
|
||||
netPerformancePercentage: new Big('0.12184460284330327256'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('75.80'),
|
||||
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
|
||||
fee: new Big('4.25'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||
investment: new Big('75.80'),
|
||||
netPerformance: new Big('17.68'),
|
||||
netPerformancePercentage: new Big('0.11662269129287598945'),
|
||||
netPerformancePercentage: new Big('0.12184460284330327256'),
|
||||
marketPrice: 87.8,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'NOVN.SW',
|
||||
timeWeightedInvestment: new Big('145.10285714285714285714'),
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
|
@ -112,6 +112,7 @@ describe('PortfolioCalculator', () => {
|
||||
marketPrice: 87.8,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'NOVN.SW',
|
||||
timeWeightedInvestment: new Big('151.6'),
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
addMilliseconds,
|
||||
addMonths,
|
||||
addYears,
|
||||
differenceInDays,
|
||||
endOfDay,
|
||||
format,
|
||||
isAfter,
|
||||
@ -43,7 +44,7 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
|
||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||
|
||||
export class PortfolioCalculator {
|
||||
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
|
||||
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT =
|
||||
true;
|
||||
|
||||
private static readonly ENABLE_LOGGING = false;
|
||||
@ -238,12 +239,13 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
const valuesByDate: {
|
||||
const accumulatedValuesByDate: {
|
||||
[date: string]: {
|
||||
maxTotalInvestmentValue: Big;
|
||||
totalCurrentValue: Big;
|
||||
totalInvestmentValue: Big;
|
||||
totalNetPerformanceValue: Big;
|
||||
totalTimeWeightedInvestmentValue: Big;
|
||||
};
|
||||
} = {};
|
||||
|
||||
@ -253,6 +255,7 @@ export class PortfolioCalculator {
|
||||
investmentValues: { [date: string]: Big };
|
||||
maxInvestmentValues: { [date: string]: Big };
|
||||
netPerformanceValues: { [date: string]: Big };
|
||||
timeWeightedInvestmentValues: { [date: string]: Big };
|
||||
};
|
||||
} = {};
|
||||
|
||||
@ -261,7 +264,8 @@ export class PortfolioCalculator {
|
||||
currentValues,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformanceValues
|
||||
netPerformanceValues,
|
||||
timeWeightedInvestmentValues
|
||||
} = this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
@ -275,7 +279,8 @@ export class PortfolioCalculator {
|
||||
currentValues,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformanceValues
|
||||
netPerformanceValues,
|
||||
timeWeightedInvestmentValues
|
||||
};
|
||||
}
|
||||
|
||||
@ -293,38 +298,50 @@ export class PortfolioCalculator {
|
||||
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
|
||||
const netPerformanceValue =
|
||||
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
||||
const timeWeightedInvestmentValue =
|
||||
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
|
||||
|
||||
valuesByDate[dateString] = {
|
||||
accumulatedValuesByDate[dateString] = {
|
||||
totalCurrentValue: (
|
||||
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||
).add(currentValue),
|
||||
totalInvestmentValue: (
|
||||
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
|
||||
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
|
||||
new Big(0)
|
||||
).add(investmentValue),
|
||||
totalTimeWeightedInvestmentValue: (
|
||||
accumulatedValuesByDate[dateString]
|
||||
?.totalTimeWeightedInvestmentValue ?? new Big(0)
|
||||
).add(timeWeightedInvestmentValue),
|
||||
maxTotalInvestmentValue: (
|
||||
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
|
||||
accumulatedValuesByDate[dateString]?.maxTotalInvestmentValue ??
|
||||
new Big(0)
|
||||
).add(maxInvestmentValue),
|
||||
totalNetPerformanceValue: (
|
||||
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
|
||||
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
|
||||
new Big(0)
|
||||
).add(netPerformanceValue)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(valuesByDate).map(([date, values]) => {
|
||||
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
|
||||
const {
|
||||
maxTotalInvestmentValue,
|
||||
totalCurrentValue,
|
||||
totalInvestmentValue,
|
||||
totalNetPerformanceValue
|
||||
totalNetPerformanceValue,
|
||||
totalTimeWeightedInvestmentValue
|
||||
} = values;
|
||||
|
||||
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
|
||||
let investmentValue =
|
||||
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
|
||||
? totalTimeWeightedInvestmentValue
|
||||
: maxTotalInvestmentValue;
|
||||
|
||||
const netPerformanceInPercentage = investmentValue.eq(0)
|
||||
? 0
|
||||
: totalNetPerformanceValue
|
||||
.div(maxTotalInvestmentValue)
|
||||
.mul(100)
|
||||
.toNumber();
|
||||
: totalNetPerformanceValue.div(investmentValue).mul(100).toNumber();
|
||||
|
||||
return {
|
||||
date,
|
||||
@ -447,7 +464,6 @@ export class PortfolioCalculator {
|
||||
if (firstIndex > 0) {
|
||||
firstIndex--;
|
||||
}
|
||||
const initialValues: { [symbol: string]: Big } = {};
|
||||
|
||||
const positions: TimelinePosition[] = [];
|
||||
let hasAnySymbolMetricsErrors = false;
|
||||
@ -461,9 +477,9 @@ export class PortfolioCalculator {
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
initialValue,
|
||||
netPerformance,
|
||||
netPerformancePercentage
|
||||
netPerformancePercentage,
|
||||
timeWeightedInvestment
|
||||
} = this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
@ -472,9 +488,9 @@ export class PortfolioCalculator {
|
||||
});
|
||||
|
||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||
initialValues[item.symbol] = initialValue;
|
||||
|
||||
positions.push({
|
||||
timeWeightedInvestment,
|
||||
averagePrice: item.quantity.eq(0)
|
||||
? new Big(0)
|
||||
: item.investment.div(item.quantity),
|
||||
@ -509,7 +525,7 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
||||
const overall = this.calculateOverallPerformance(positions);
|
||||
|
||||
return {
|
||||
...overall,
|
||||
@ -732,18 +748,13 @@ export class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
private calculateOverallPerformance(
|
||||
positions: TimelinePosition[],
|
||||
initialValues: { [symbol: string]: Big }
|
||||
) {
|
||||
private calculateOverallPerformance(positions: TimelinePosition[]) {
|
||||
let currentValue = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformancePercentage = new Big(0);
|
||||
let hasErrors = false;
|
||||
let netPerformance = new Big(0);
|
||||
let netPerformancePercentage = new Big(0);
|
||||
let sumOfWeights = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalTimeWeightedInvestment = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
@ -766,21 +777,9 @@ export class PortfolioCalculator {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (currentPosition.grossPerformancePercentage) {
|
||||
// Use the average from the initial value and the current investment as
|
||||
// a weight
|
||||
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
|
||||
.plus(currentPosition.investment)
|
||||
.div(2);
|
||||
|
||||
sumOfWeights = sumOfWeights.plus(weight);
|
||||
|
||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||
currentPosition.grossPerformancePercentage.mul(weight)
|
||||
);
|
||||
|
||||
netPerformancePercentage = netPerformancePercentage.plus(
|
||||
currentPosition.netPerformancePercentage.mul(weight)
|
||||
if (currentPosition.timeWeightedInvestment) {
|
||||
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
|
||||
currentPosition.timeWeightedInvestment
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.warn(
|
||||
@ -791,22 +790,18 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
if (sumOfWeights.gt(0)) {
|
||||
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
|
||||
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
|
||||
} else {
|
||||
grossPerformancePercentage = new Big(0);
|
||||
netPerformancePercentage = new Big(0);
|
||||
}
|
||||
|
||||
return {
|
||||
currentValue,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
totalInvestment
|
||||
totalInvestment,
|
||||
netPerformancePercentage: totalTimeWeightedInvestment.eq(0)
|
||||
? new Big(0)
|
||||
: netPerformance.div(totalTimeWeightedInvestment),
|
||||
grossPerformancePercentage: totalTimeWeightedInvestment.eq(0)
|
||||
? new Big(0)
|
||||
: grossPerformance.div(totalTimeWeightedInvestment)
|
||||
};
|
||||
}
|
||||
|
||||
@ -1018,6 +1013,7 @@ export class PortfolioCalculator {
|
||||
|
||||
let averagePriceAtEndDate = new Big(0);
|
||||
let averagePriceAtStartDate = new Big(0);
|
||||
const currentValues: { [date: string]: Big } = {};
|
||||
let feesAtStartDate = new Big(0);
|
||||
let fees = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
@ -1025,12 +1021,12 @@ export class PortfolioCalculator {
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
const currentValues: { [date: string]: Big } = {};
|
||||
const investmentValues: { [date: string]: Big } = {};
|
||||
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
let maxTotalInvestment = new Big(0);
|
||||
const netPerformanceValues: { [date: string]: Big } = {};
|
||||
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
@ -1122,6 +1118,9 @@ export class PortfolioCalculator {
|
||||
return order.itemType === 'end';
|
||||
});
|
||||
|
||||
let totalInvestmentDays = 0;
|
||||
let sumOfTimeWeightedInvestments = new Big(0);
|
||||
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
@ -1162,11 +1161,11 @@ export class PortfolioCalculator {
|
||||
order.type === 'BUY'
|
||||
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
: totalUnits.gt(0)
|
||||
? totalInvestment
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(this.getFactor(order.type))
|
||||
: new Big(0);
|
||||
? totalInvestment
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(this.getFactor(order.type))
|
||||
: new Big(0);
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
@ -1174,6 +1173,7 @@ export class PortfolioCalculator {
|
||||
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||
}
|
||||
|
||||
const totalInvestmentBeforeTransaction = totalInvestment;
|
||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||
|
||||
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
|
||||
@ -1243,14 +1243,51 @@ export class PortfolioCalculator {
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
}
|
||||
|
||||
if (isChartMode && i > indexOfStartOrder) {
|
||||
currentValues[order.date] = valueOfInvestment;
|
||||
netPerformanceValues[order.date] = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
if (i > indexOfStartOrder) {
|
||||
// Only consider periods with an investment for the calculation of
|
||||
// the time weighted investment
|
||||
if (valueOfInvestmentBeforeTransaction.gt(0)) {
|
||||
// Calculate the number of days since the previous order
|
||||
const orderDate = new Date(order.date);
|
||||
const previousOrderDate = new Date(orders[i - 1].date);
|
||||
|
||||
investmentValues[order.date] = totalInvestment;
|
||||
maxInvestmentValues[order.date] = maxTotalInvestment;
|
||||
let daysSinceLastOrder = differenceInDays(
|
||||
orderDate,
|
||||
previousOrderDate
|
||||
);
|
||||
|
||||
// Set to at least 1 day, otherwise the transactions on the same day
|
||||
// would not be considered in the time weighted calculation
|
||||
if (daysSinceLastOrder <= 0) {
|
||||
daysSinceLastOrder = 1;
|
||||
}
|
||||
|
||||
// Sum up the total investment days since the start date to calculate
|
||||
// the time weighted investment
|
||||
totalInvestmentDays += daysSinceLastOrder;
|
||||
|
||||
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
|
||||
valueAtStartDate
|
||||
.minus(investmentAtStartDate)
|
||||
.plus(totalInvestmentBeforeTransaction)
|
||||
.mul(daysSinceLastOrder)
|
||||
);
|
||||
}
|
||||
|
||||
if (isChartMode) {
|
||||
currentValues[order.date] = valueOfInvestment;
|
||||
netPerformanceValues[order.date] = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
investmentValues[order.date] = totalInvestment;
|
||||
maxInvestmentValues[order.date] = maxTotalInvestment;
|
||||
|
||||
timeWeightedInvestmentValues[order.date] =
|
||||
totalInvestmentDays > 0
|
||||
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||
: new Big(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
@ -1274,50 +1311,79 @@ export class PortfolioCalculator {
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
|
||||
totalInvestmentDays > 0
|
||||
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||
: new Big(0);
|
||||
|
||||
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
|
||||
maxTotalInvestment.minus(investmentAtStartDate)
|
||||
);
|
||||
|
||||
const grossPerformancePercentage =
|
||||
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
||||
averagePriceAtStartDate.eq(0) ||
|
||||
averagePriceAtEndDate.eq(0) ||
|
||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||
: new Big(0)
|
||||
: // This formula has the issue that buying more units with a price
|
||||
// lower than the average buying price results in a positive
|
||||
// performance even if the market price stays constant
|
||||
unitPriceAtEndDate
|
||||
.div(averagePriceAtEndDate)
|
||||
.div(
|
||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||
let grossPerformancePercentage: Big;
|
||||
|
||||
if (
|
||||
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
|
||||
) {
|
||||
grossPerformancePercentage =
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalGrossPerformance.div(
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||
)
|
||||
.minus(1);
|
||||
: new Big(0);
|
||||
} else {
|
||||
grossPerformancePercentage =
|
||||
averagePriceAtStartDate.eq(0) ||
|
||||
averagePriceAtEndDate.eq(0) ||
|
||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||
: new Big(0)
|
||||
: // This formula has the issue that buying more units with a price
|
||||
// lower than the average buying price results in a positive
|
||||
// performance even if the market price stays constant
|
||||
unitPriceAtEndDate
|
||||
.div(averagePriceAtEndDate)
|
||||
.div(
|
||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||
)
|
||||
.minus(1);
|
||||
}
|
||||
|
||||
const feesPerUnit = totalUnits.gt(0)
|
||||
? fees.minus(feesAtStartDate).div(totalUnits)
|
||||
: new Big(0);
|
||||
|
||||
const netPerformancePercentage =
|
||||
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
||||
averagePriceAtStartDate.eq(0) ||
|
||||
averagePriceAtEndDate.eq(0) ||
|
||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||
: new Big(0)
|
||||
: // This formula has the issue that buying more units with a price
|
||||
// lower than the average buying price results in a positive
|
||||
// performance even if the market price stays constant
|
||||
unitPriceAtEndDate
|
||||
.minus(feesPerUnit)
|
||||
.div(averagePriceAtEndDate)
|
||||
.div(
|
||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||
let netPerformancePercentage: Big;
|
||||
|
||||
if (
|
||||
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
|
||||
) {
|
||||
netPerformancePercentage =
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalNetPerformance.div(
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||
)
|
||||
.minus(1);
|
||||
: new Big(0);
|
||||
} else {
|
||||
netPerformancePercentage =
|
||||
averagePriceAtStartDate.eq(0) ||
|
||||
averagePriceAtEndDate.eq(0) ||
|
||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||
: new Big(0)
|
||||
: // This formula has the issue that buying more units with a price
|
||||
// lower than the average buying price results in a positive
|
||||
// performance even if the market price stays constant
|
||||
unitPriceAtEndDate
|
||||
.minus(feesPerUnit)
|
||||
.div(averagePriceAtEndDate)
|
||||
.div(
|
||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||
)
|
||||
.minus(1);
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
@ -1330,6 +1396,9 @@ export class PortfolioCalculator {
|
||||
2
|
||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||
Total investment: ${totalInvestment.toFixed(2)}
|
||||
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
|
||||
2
|
||||
)}
|
||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||
Gross performance: ${totalGrossPerformance.toFixed(
|
||||
2
|
||||
@ -1349,9 +1418,12 @@ export class PortfolioCalculator {
|
||||
maxInvestmentValues,
|
||||
netPerformancePercentage,
|
||||
netPerformanceValues,
|
||||
timeWeightedInvestmentValues,
|
||||
grossPerformance: totalGrossPerformance,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance
|
||||
netPerformance: totalNetPerformance,
|
||||
timeWeightedInvestment:
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDate
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
@ -61,7 +62,7 @@ export class PortfolioController {
|
||||
) {}
|
||||
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getDetails(
|
||||
@ -204,7 +205,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
@Get('dividends')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getDividends(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@ -254,7 +255,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getInvestments(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@ -315,7 +316,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@ -405,7 +406,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
@Get('positions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@ -500,7 +501,7 @@ export class PortfolioController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPosition(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@ -523,7 +524,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getReport(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||
): Promise<PortfolioReport> {
|
||||
|
@ -1035,25 +1035,44 @@ export class PortfolioService {
|
||||
|
||||
return {
|
||||
hasErrors: currentPositions.hasErrors,
|
||||
positions: positions.map((position) => {
|
||||
return {
|
||||
...position,
|
||||
assetClass: symbolProfileMap[position.symbol].assetClass,
|
||||
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
|
||||
averagePrice: new Big(position.averagePrice).toNumber(),
|
||||
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
||||
grossPerformancePercentage:
|
||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||
investment: new Big(position.investment).toNumber(),
|
||||
marketState:
|
||||
dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
|
||||
name: symbolProfileMap[position.symbol].name,
|
||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||
netPerformancePercentage:
|
||||
position.netPerformancePercentage?.toNumber() ?? null,
|
||||
quantity: new Big(position.quantity).toNumber()
|
||||
};
|
||||
})
|
||||
positions: positions.map(
|
||||
({
|
||||
averagePrice,
|
||||
currency,
|
||||
dataSource,
|
||||
firstBuyDate,
|
||||
investment,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
quantity,
|
||||
symbol,
|
||||
transactionCount
|
||||
}) => {
|
||||
return {
|
||||
currency,
|
||||
dataSource,
|
||||
firstBuyDate,
|
||||
symbol,
|
||||
transactionCount,
|
||||
assetClass: symbolProfileMap[symbol].assetClass,
|
||||
assetSubClass: symbolProfileMap[symbol].assetSubClass,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
grossPerformance: grossPerformance?.toNumber() ?? null,
|
||||
grossPerformancePercentage:
|
||||
grossPerformancePercentage?.toNumber() ?? null,
|
||||
investment: investment.toNumber(),
|
||||
marketState:
|
||||
dataProviderResponses[symbol]?.marketState ?? 'delayed',
|
||||
name: symbolProfileMap[symbol].name,
|
||||
netPerformance: netPerformance?.toNumber() ?? null,
|
||||
netPerformancePercentage:
|
||||
netPerformancePercentage?.toNumber() ?? null,
|
||||
quantity: quantity.toNumber()
|
||||
};
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
@ -37,7 +38,7 @@ export class SubscriptionController {
|
||||
|
||||
@Post('redeem-coupon')
|
||||
@HttpCode(StatusCodes.OK)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
|
||||
if (!this.request.user) {
|
||||
throw new HttpException(
|
||||
@ -109,7 +110,7 @@ export class SubscriptionController {
|
||||
}
|
||||
|
||||
@Post('stripe/checkout-session')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createCheckoutSession(
|
||||
@Body() { couponId, priceId }: { couponId: string; priceId: string }
|
||||
) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
@ -34,7 +35,7 @@ export class SymbolController {
|
||||
* Must be before /:symbol
|
||||
*/
|
||||
@Get('lookup')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async lookupSymbol(
|
||||
@Query('includeIndices') includeIndices: boolean = false,
|
||||
@ -88,7 +89,7 @@ export class SymbolController {
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherSymbolForDate(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
|
@ -1,18 +1,17 @@
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Tag } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
@ -23,40 +22,25 @@ import { UpdateTagDto } from './update-tag.dto';
|
||||
|
||||
@Controller('tag')
|
||||
export class TagController {
|
||||
public constructor(
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly tagService: TagService
|
||||
) {}
|
||||
public constructor(private readonly tagService: TagService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getTags() {
|
||||
return this.tagService.getTagsWithActivityCount();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.createTag)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
|
||||
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.tagService.createTag(data);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateTag)
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
|
||||
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalTag = await this.tagService.getTag({
|
||||
id
|
||||
});
|
||||
@ -79,15 +63,9 @@ export class TagController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteTag)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteTag(@Param('id') id: string) {
|
||||
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalTag = await this.tagService.getTag({
|
||||
id
|
||||
});
|
||||
|
@ -4,6 +4,7 @@ import type {
|
||||
ViewMode
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsISO8601,
|
||||
IsIn,
|
||||
@ -37,6 +38,10 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
emergencyFund?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
'filters.tags'?: string[];
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExperimentalFeatures?: boolean;
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
@ -36,12 +38,10 @@ export class UserController {
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteUser)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteUser) ||
|
||||
id === this.request.user.id
|
||||
) {
|
||||
if (id === this.request.user.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -54,7 +54,7 @@ export class UserController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getUser(
|
||||
@Headers('accept-language') acceptLanguage: string
|
||||
): Promise<User> {
|
||||
@ -92,7 +92,7 @@ export class UserController {
|
||||
}
|
||||
|
||||
@Put('setting')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||
if (
|
||||
size(data) === 1 &&
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { HasPermissionGuard } from './has-permission.guard';
|
||||
|
||||
@ -10,12 +9,8 @@ describe('HasPermissionGuard', () => {
|
||||
let reflector: Reflector;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [HasPermissionGuard, Reflector]
|
||||
}).compile();
|
||||
|
||||
guard = module.get<HasPermissionGuard>(HasPermissionGuard);
|
||||
reflector = module.get<Reflector>(Reflector);
|
||||
reflector = new Reflector();
|
||||
guard = new HasPermissionGuard(reflector);
|
||||
});
|
||||
|
||||
function setupReflectorSpy(returnValue: string) {
|
||||
|
@ -14,17 +14,17 @@ export class HasPermissionGuard implements CanActivate {
|
||||
public constructor(private reflector: Reflector) {}
|
||||
|
||||
public canActivate(context: ExecutionContext): boolean {
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
const requiredPermission = this.reflector.get<string>(
|
||||
HAS_PERMISSION_KEY,
|
||||
context.getHandler()
|
||||
);
|
||||
|
||||
if (!requiredPermission) {
|
||||
return true; // No specific permissions required
|
||||
// No specific permissions required
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
|
||||
if (!user || !hasPermission(user.permissions, requiredPermission)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
|
@ -32,9 +32,11 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
|
||||
}
|
||||
|
||||
export function redactAttributes({
|
||||
isFirstRun = true,
|
||||
object,
|
||||
options
|
||||
}: {
|
||||
isFirstRun?: boolean;
|
||||
object: any;
|
||||
options: { attribute: string; valueMap: { [key: string]: any } }[];
|
||||
}): any {
|
||||
@ -42,7 +44,10 @@ export function redactAttributes({
|
||||
return object;
|
||||
}
|
||||
|
||||
const redactedObject = cloneDeep(object);
|
||||
// Create deep clone
|
||||
const redactedObject = isFirstRun
|
||||
? JSON.parse(JSON.stringify(object))
|
||||
: object;
|
||||
|
||||
for (const option of options) {
|
||||
if (redactedObject.hasOwnProperty(option.attribute)) {
|
||||
@ -59,7 +64,11 @@ export function redactAttributes({
|
||||
if (isArray(redactedObject[property])) {
|
||||
redactedObject[property] = redactedObject[property].map(
|
||||
(currentObject) => {
|
||||
return redactAttributes({ options, object: currentObject });
|
||||
return redactAttributes({
|
||||
options,
|
||||
isFirstRun: false,
|
||||
object: currentObject
|
||||
});
|
||||
}
|
||||
);
|
||||
} else if (
|
||||
@ -69,6 +78,7 @@ export function redactAttributes({
|
||||
// Recursively call the function on the nested object
|
||||
redactedObject[property] = redactAttributes({
|
||||
options,
|
||||
isFirstRun: false,
|
||||
object: redactedObject[property]
|
||||
});
|
||||
}
|
||||
|
@ -12,6 +12,8 @@ export class ConfigurationService {
|
||||
this.environmentConfiguration = cleanEnv(process.env, {
|
||||
ACCESS_TOKEN_SALT: str(),
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
API_KEY_COINGECKO_DEMO: str({ default: '' }),
|
||||
API_KEY_COINGECKO_PRO: str({ default: '' }),
|
||||
BETTER_UPTIME_API_KEY: str({ default: '' }),
|
||||
CACHE_QUOTES_TTL: num({ default: 1 }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
@ -44,6 +46,7 @@ export class ConfigurationService {
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
REDIS_PASSWORD: str({ default: '' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
REQUEST_TIMEOUT: num({ default: 2000 }),
|
||||
ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
|
||||
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||
STRIPE_SECRET_KEY: str({ default: '' }),
|
||||
|
@ -5,7 +5,6 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@ -107,7 +106,7 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getQuotes({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbols
|
||||
}: {
|
||||
requestTimeout?: number;
|
||||
|
@ -1,13 +1,11 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_REQUEST_TIMEOUT
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
@ -19,13 +17,30 @@ import {
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { format, fromUnixTime, getUnixTime } from 'date-fns';
|
||||
import got from 'got';
|
||||
import got, { Headers } from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class CoinGeckoService implements DataProviderInterface {
|
||||
private readonly URL = 'https://api.coingecko.com/api/v3';
|
||||
private readonly apiUrl: string;
|
||||
private readonly headers: Headers = {};
|
||||
|
||||
public constructor() {}
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO');
|
||||
const apiKeyPro = this.configurationService.get('API_KEY_COINGECKO_PRO');
|
||||
|
||||
this.apiUrl = 'https://api.coingecko.com/api/v3';
|
||||
|
||||
if (apiKeyDemo) {
|
||||
this.headers['x-cg-demo-api-key'] = apiKeyDemo;
|
||||
}
|
||||
|
||||
if (apiKeyPro) {
|
||||
this.apiUrl = 'https://pro-api.coingecko.com/api/v3';
|
||||
this.headers['x-cg-pro-api-key'] = apiKeyPro;
|
||||
}
|
||||
}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
@ -47,9 +62,10 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { name } = await got(`${this.URL}/coins/${aSymbol}`, {
|
||||
const { name } = await got(`${this.apiUrl}/coins/${aSymbol}`, {
|
||||
headers: this.headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
}).json<any>();
|
||||
@ -59,7 +75,9 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'CoinGeckoService');
|
||||
@ -95,15 +113,16 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { prices } = await got(
|
||||
`${
|
||||
this.URL
|
||||
this.apiUrl
|
||||
}/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime(
|
||||
from
|
||||
)}&to=${getUnixTime(to)}`,
|
||||
{
|
||||
headers: this.headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
}
|
||||
@ -141,7 +160,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getQuotes({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbols
|
||||
}: {
|
||||
requestTimeout?: number;
|
||||
@ -161,10 +180,11 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
}, requestTimeout);
|
||||
|
||||
const quotes = await got(
|
||||
`${this.URL}/simple/price?ids=${symbols.join(
|
||||
`${this.apiUrl}/simple/price?ids=${symbols.join(
|
||||
','
|
||||
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
|
||||
{
|
||||
headers: this.headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
}
|
||||
@ -183,7 +203,9 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'CoinGeckoService');
|
||||
@ -210,9 +232,10 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { coins } = await got(`${this.URL}/search?query=${query}`, {
|
||||
const { coins } = await got(`${this.apiUrl}/search?query=${query}`, {
|
||||
headers: this.headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
}).json<any>();
|
||||
@ -231,7 +254,9 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'CoinGeckoService');
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||
import { parseSymbol } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
@ -15,7 +14,7 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
||||
) {}
|
||||
|
||||
public async enhance({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
response,
|
||||
symbol
|
||||
}: {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@ -21,8 +21,12 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
'Information Technology': 'Technology'
|
||||
};
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public async enhance({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
response,
|
||||
symbol
|
||||
}: {
|
||||
@ -55,7 +59,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
return got(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
|
||||
@ -82,7 +86,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const holdings = await got(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
|
||||
@ -97,7 +101,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
return got(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
|
||||
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
|
||||
@ -25,13 +26,16 @@ jest.mock(
|
||||
);
|
||||
|
||||
describe('YahooFinanceDataEnhancerService', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let cryptocurrencyService: CryptocurrencyService;
|
||||
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
|
||||
|
||||
beforeAll(async () => {
|
||||
configurationService = new ConfigurationService();
|
||||
cryptocurrencyService = new CryptocurrencyService();
|
||||
|
||||
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
|
||||
configurationService,
|
||||
cryptocurrencyService
|
||||
);
|
||||
});
|
||||
|
@ -1,10 +1,7 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
UNKNOWN_KEY
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
@ -22,6 +19,7 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
|
||||
@Injectable()
|
||||
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly cryptocurrencyService: CryptocurrencyService
|
||||
) {}
|
||||
|
||||
@ -76,7 +74,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||
}
|
||||
|
||||
public async enhance({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
response,
|
||||
symbol
|
||||
}: {
|
||||
|
@ -74,6 +74,6 @@ import { DataProviderService } from './data-provider.service';
|
||||
},
|
||||
YahooFinanceDataEnhancerService
|
||||
],
|
||||
exports: [DataProviderService, YahooFinanceService]
|
||||
exports: [DataProviderService, ManualService, YahooFinanceService]
|
||||
})
|
||||
export class DataProviderModule {}
|
||||
|
@ -5,10 +5,7 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_REQUEST_TIMEOUT
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -82,7 +79,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const response = await got(
|
||||
`${this.URL}/eod/${symbol}?api_token=${
|
||||
@ -132,7 +129,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getQuotes({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbols
|
||||
}: {
|
||||
requestTimeout?: number;
|
||||
@ -194,7 +191,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
})?.currency;
|
||||
|
||||
result[this.convertFromEodSymbol(code)] = {
|
||||
currency: currency ?? DEFAULT_CURRENCY,
|
||||
currency:
|
||||
currency ??
|
||||
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
|
||||
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
||||
marketPrice: close,
|
||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||
@ -208,7 +207,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
if (response[`${DEFAULT_CURRENCY}GBP`]) {
|
||||
response[`${DEFAULT_CURRENCY}GBp`] = {
|
||||
...response[`${DEFAULT_CURRENCY}GBP`],
|
||||
currency: `${DEFAULT_CURRENCY}GBp`,
|
||||
currency: 'GBp',
|
||||
marketPrice: this.getConvertedValue({
|
||||
symbol: `${DEFAULT_CURRENCY}GBp`,
|
||||
value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice
|
||||
@ -219,7 +218,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
if (response[`${DEFAULT_CURRENCY}ILS`]) {
|
||||
response[`${DEFAULT_CURRENCY}ILA`] = {
|
||||
...response[`${DEFAULT_CURRENCY}ILS`],
|
||||
currency: `${DEFAULT_CURRENCY}ILA`,
|
||||
currency: 'ILA',
|
||||
marketPrice: this.getConvertedValue({
|
||||
symbol: `${DEFAULT_CURRENCY}ILA`,
|
||||
value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice
|
||||
@ -227,12 +226,23 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
if (response[`${DEFAULT_CURRENCY}USX`]) {
|
||||
response[`${DEFAULT_CURRENCY}USX`] = {
|
||||
currency: 'USX',
|
||||
dataSource: this.getName(),
|
||||
marketPrice: new Big(1).mul(100).toNumber(),
|
||||
marketState: 'open'
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'EodHistoricalDataService');
|
||||
@ -359,7 +369,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const response = await got(
|
||||
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
|
||||
@ -391,7 +401,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'EodHistoricalDataService');
|
||||
|
@ -5,10 +5,7 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_REQUEST_TIMEOUT
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
@ -70,7 +67,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { historical } = await got(
|
||||
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
|
||||
@ -114,7 +111,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getQuotes({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbols
|
||||
}: {
|
||||
requestTimeout?: number;
|
||||
@ -154,7 +151,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'FinancialModelingPrepService');
|
||||
@ -181,7 +180,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const result = await got(
|
||||
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
|
||||
@ -205,7 +204,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'FinancialModelingPrepService');
|
||||
|
@ -7,7 +7,6 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -101,7 +100,7 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getQuotes({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbols
|
||||
}: {
|
||||
requestTimeout?: number;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
@ -6,23 +7,25 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
extractNumberFromString,
|
||||
getYesterday
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { ScraperConfiguration } from '@ghostfolio/common/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { addDays, format, isBefore } from 'date-fns';
|
||||
import got from 'got';
|
||||
import got, { Headers } from 'got';
|
||||
import jsonpath from 'jsonpath';
|
||||
|
||||
@Injectable()
|
||||
export class ManualService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
@ -96,21 +99,7 @@ export class ManualService implements DataProviderInterface {
|
||||
return {};
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
const { body } = await got(url, {
|
||||
headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
const $ = cheerio.load(body);
|
||||
|
||||
const value = extractNumberFromString($(selector).text());
|
||||
const value = await this.scrape(symbolProfile.scraperConfiguration);
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
@ -134,7 +123,7 @@ export class ManualService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getQuotes({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbols
|
||||
}: {
|
||||
requestTimeout?: number;
|
||||
@ -232,4 +221,43 @@ export class ManualService implements DataProviderInterface {
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
public async test(scraperConfiguration: ScraperConfiguration) {
|
||||
return this.scrape(scraperConfiguration);
|
||||
}
|
||||
|
||||
private async scrape(
|
||||
scraperConfiguration: ScraperConfiguration
|
||||
): Promise<number> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { body, headers } = await got(scraperConfiguration.url, {
|
||||
headers: scraperConfiguration.headers as Headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
if (headers['content-type'] === 'application/json') {
|
||||
const data = JSON.parse(body);
|
||||
const value = String(
|
||||
jsonpath.query(data, scraperConfiguration.selector)[0]
|
||||
);
|
||||
|
||||
return extractNumberFromString(value);
|
||||
} else {
|
||||
const $ = cheerio.load(body);
|
||||
|
||||
return extractNumberFromString(
|
||||
$(scraperConfiguration.selector).first().text()
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,10 +5,7 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import {
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
ghostfolioFearAndGreedIndexSymbol
|
||||
} from '@ghostfolio/common/config';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -88,7 +85,7 @@ export class RapidApiService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getQuotes({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbols
|
||||
}: {
|
||||
requestTimeout?: number;
|
||||
@ -146,7 +143,7 @@ export class RapidApiService implements DataProviderInterface {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { fgi } = await got(
|
||||
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
|
||||
@ -166,7 +163,9 @@ export class RapidApiService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'RapidApiService');
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
@ -6,10 +7,7 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_REQUEST_TIMEOUT
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -22,6 +20,7 @@ import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
|
||||
@Injectable()
|
||||
export class YahooFinanceService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly cryptocurrencyService: CryptocurrencyService,
|
||||
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
|
||||
) {}
|
||||
@ -160,7 +159,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getQuotes({
|
||||
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbols
|
||||
}: {
|
||||
requestTimeout?: number;
|
||||
|
@ -11,6 +11,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { format, isToday } from 'date-fns';
|
||||
import { isNumber, uniq } from 'lodash';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
export class ExchangeRateDataService {
|
||||
@ -33,6 +34,125 @@ export class ExchangeRateDataService {
|
||||
return this.currencyPairs;
|
||||
}
|
||||
|
||||
public async getExchangeRates({
|
||||
currencyFrom,
|
||||
currencyTo,
|
||||
dates
|
||||
}: {
|
||||
currencyFrom: string;
|
||||
currencyTo: string;
|
||||
dates: Date[];
|
||||
}) {
|
||||
let factors: { [dateString: string]: number } = {};
|
||||
|
||||
if (currencyFrom === currencyTo) {
|
||||
for (const date of dates) {
|
||||
factors[format(date, DATE_FORMAT)] = 1;
|
||||
}
|
||||
} else {
|
||||
const dataSource =
|
||||
this.dataProviderService.getDataSourceForExchangeRates();
|
||||
const symbol = `${currencyFrom}${currencyTo}`;
|
||||
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: { in: dates },
|
||||
uniqueAssets: [
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (marketData?.length > 0) {
|
||||
for (const { date, marketPrice } of marketData) {
|
||||
factors[format(date, DATE_FORMAT)] = marketPrice;
|
||||
}
|
||||
} else {
|
||||
// Calculate indirectly via base currency
|
||||
|
||||
let marketPriceBaseCurrencyFromCurrency: {
|
||||
[dateString: string]: number;
|
||||
} = {};
|
||||
let marketPriceBaseCurrencyToCurrency: {
|
||||
[dateString: string]: number;
|
||||
} = {};
|
||||
|
||||
try {
|
||||
if (currencyFrom === DEFAULT_CURRENCY) {
|
||||
for (const date of dates) {
|
||||
marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] =
|
||||
1;
|
||||
}
|
||||
} else {
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: { in: dates },
|
||||
uniqueAssets: [
|
||||
{
|
||||
dataSource,
|
||||
symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
for (const { date, marketPrice } of marketData) {
|
||||
marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] =
|
||||
marketPrice;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
if (currencyTo === DEFAULT_CURRENCY) {
|
||||
for (const date of dates) {
|
||||
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] = 1;
|
||||
}
|
||||
} else {
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
uniqueAssets: [
|
||||
{
|
||||
dataSource,
|
||||
symbol: `${DEFAULT_CURRENCY}${currencyTo}`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
for (const { date, marketPrice } of marketData) {
|
||||
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] =
|
||||
marketPrice;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
for (const date of dates) {
|
||||
try {
|
||||
const factor =
|
||||
(1 /
|
||||
marketPriceBaseCurrencyFromCurrency[
|
||||
format(date, DATE_FORMAT)
|
||||
]) *
|
||||
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)];
|
||||
|
||||
factors[format(date, DATE_FORMAT)] = factor;
|
||||
} catch {
|
||||
Logger.error(
|
||||
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)}`,
|
||||
'ExchangeRateDataService'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return factors;
|
||||
}
|
||||
|
||||
public hasCurrencyPair(currency1: string, currency2: string) {
|
||||
return this.currencyPairs.some(({ symbol }) => {
|
||||
return (
|
||||
@ -75,7 +195,8 @@ export class ExchangeRateDataService {
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: this.currencyPairs.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
}),
|
||||
requestTimeout: ms('30 seconds')
|
||||
});
|
||||
|
||||
for (const symbol of Object.keys(quotes)) {
|
||||
|
@ -3,6 +3,8 @@ import { CleanedEnvAccessors } from 'envalid';
|
||||
export interface Environment extends CleanedEnvAccessors {
|
||||
ACCESS_TOKEN_SALT: string;
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
API_KEY_COINGECKO_DEMO: string;
|
||||
API_KEY_COINGECKO_PRO: string;
|
||||
BETTER_UPTIME_API_KEY: string;
|
||||
CACHE_QUOTES_TTL: number;
|
||||
CACHE_TTL: number;
|
||||
@ -32,6 +34,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
REDIS_HOST: string;
|
||||
REDIS_PASSWORD: string;
|
||||
REDIS_PORT: number;
|
||||
REQUEST_TIMEOUT: number;
|
||||
ROOT_URL: string;
|
||||
STRIPE_PUBLIC_KEY: string;
|
||||
STRIPE_SECRET_KEY: string;
|
||||
|
@ -89,6 +89,7 @@ export class SymbolProfileService {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
@ -100,6 +101,7 @@ export class SymbolProfileService {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
currency,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbolMapping
|
||||
|
6
apps/api/webpack.config.js
Normal file
6
apps/api/webpack.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
const { composePlugins, withNx } = require('@nx/webpack');
|
||||
|
||||
module.exports = composePlugins(withNx(), (config, { options, context }) => {
|
||||
// Customize webpack config here
|
||||
return config;
|
||||
});
|
@ -150,41 +150,41 @@
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/angular:webpack-dev-server",
|
||||
"executor": "@nx/angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "apps/client/proxy.conf.json",
|
||||
"browserTarget": "client:build"
|
||||
"buildTarget": "client:build"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"browserTarget": "client:build:development-de"
|
||||
"buildTarget": "client:build:development-de"
|
||||
},
|
||||
"development-en": {
|
||||
"browserTarget": "client:build:development-en"
|
||||
"buildTarget": "client:build:development-en"
|
||||
},
|
||||
"development-es": {
|
||||
"browserTarget": "client:build:development-es"
|
||||
"buildTarget": "client:build:development-es"
|
||||
},
|
||||
"development-fr": {
|
||||
"browserTarget": "client:build:development-fr"
|
||||
"buildTarget": "client:build:development-fr"
|
||||
},
|
||||
"development-it": {
|
||||
"browserTarget": "client:build:development-it"
|
||||
"buildTarget": "client:build:development-it"
|
||||
},
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
"buildTarget": "client:build:development-nl"
|
||||
},
|
||||
"development-pl": {
|
||||
"browserTarget": "client:build:development-pl"
|
||||
"buildTarget": "client:build:development-pl"
|
||||
},
|
||||
"development-pt": {
|
||||
"browserTarget": "client:build:development-pt"
|
||||
"buildTarget": "client:build:development-pt"
|
||||
},
|
||||
"development-tr": {
|
||||
"browserTarget": "client:build:development-tr"
|
||||
"buildTarget": "client:build:development-tr"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
"buildTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -207,7 +207,7 @@
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"executor": "@nx/eslint:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
||||
}
|
||||
|
@ -96,8 +96,8 @@
|
||||
href="https://status.ghostfol.io"
|
||||
target="_blank"
|
||||
title="Ghostfolio Status"
|
||||
>Status<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></a>
|
||||
>Status<ion-icon class="ml-1" name="open-outline"
|
||||
/></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -110,8 +110,8 @@
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
target="_blank"
|
||||
title="Find Ghostfolio on GitHub"
|
||||
>GitHub<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></a>
|
||||
>GitHub<ion-icon class="ml-1" name="open-outline"
|
||||
/></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@ -119,8 +119,8 @@
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
target="_blank"
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></a>
|
||||
>Slack<ion-icon class="ml-1" name="open-outline"
|
||||
/></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@ -128,11 +128,8 @@
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
target="_blank"
|
||||
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||
>X (formerly Twitter)<ion-icon
|
||||
class="ml-1"
|
||||
name="open-outline"
|
||||
></ion-icon
|
||||
></a>
|
||||
>X (formerly Twitter)<ion-icon class="ml-1" name="open-outline"
|
||||
/></a>
|
||||
</li>
|
||||
<li> </li>
|
||||
<li>
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
|
||||
&.has-info-message {
|
||||
header {
|
||||
@ -30,7 +30,7 @@
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: calc(100vh - 2 * var(--mat-toolbar-standard-height));
|
||||
min-height: calc(100svh - 2 * var(--mat-toolbar-standard-height));
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: calc(100vh - var(--mat-toolbar-standard-height));
|
||||
min-height: calc(100svh - var(--mat-toolbar-standard-height));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -14,26 +14,26 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
|
||||
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||
<ng-container>
|
||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||
Restricted View
|
||||
</ng-container>
|
||||
<div class="align-items-center d-flex">
|
||||
<ion-icon class="mr-1" name="lock-closed-outline" />
|
||||
<ng-container i18n>Restricted View</ng-container>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="details">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
|
||||
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||
<ng-container *ngIf="element.type === 'PUBLIC'">
|
||||
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
||||
<div *ngIf="element.type === 'PUBLIC'" class="align-items-center d-flex">
|
||||
<ion-icon class="mr-1" name="link-outline" />
|
||||
<a
|
||||
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
|
||||
target="_blank"
|
||||
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
|
||||
>
|
||||
</ng-container>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
[matMenuTriggerFor]="transactionMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
||||
|
@ -7,6 +7,8 @@ import {
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Sort, SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -16,6 +18,7 @@ import {
|
||||
HistoricalDataItem,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import Big from 'big.js';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
@ -24,7 +27,6 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { AccountDetailDialogParams } from './interfaces/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
@Component({
|
||||
host: { class: 'd-flex flex-column h-100' },
|
||||
@ -38,6 +40,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
public activities: OrderWithAccount[];
|
||||
public balance: number;
|
||||
public currency: string;
|
||||
public dataSource: MatTableDataSource<OrderWithAccount>;
|
||||
public equity: number;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToDeleteAccountBalance: boolean;
|
||||
@ -46,6 +49,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
public isLoadingChart: boolean;
|
||||
public name: string;
|
||||
public platformName: string;
|
||||
public sortColumn = 'date';
|
||||
public sortDirection: SortDirection = 'desc';
|
||||
public totalItems: number;
|
||||
public transactionCount: number;
|
||||
public user: User;
|
||||
public valueInBaseCurrency: number;
|
||||
@ -77,8 +83,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.isLoadingActivities = true;
|
||||
|
||||
this.dataService
|
||||
.fetchAccount(this.data.accountId)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -110,19 +114,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
);
|
||||
|
||||
this.dataService
|
||||
.fetchActivities({
|
||||
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities }) => {
|
||||
this.activities = activities;
|
||||
|
||||
this.isLoadingActivities = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -131,6 +122,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
});
|
||||
|
||||
this.fetchAccountBalances();
|
||||
this.fetchActivities();
|
||||
this.fetchPortfolioPerformance();
|
||||
}
|
||||
|
||||
@ -151,12 +143,20 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
let activityIds = [];
|
||||
|
||||
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||
activityIds = this.dataSource.data.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
} else {
|
||||
activityIds = this.activities.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
this.dataService
|
||||
.fetchExport(
|
||||
this.activities.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
)
|
||||
.fetchExport(activityIds)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data) => {
|
||||
downloadAsFile({
|
||||
@ -172,6 +172,13 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onSortChanged({ active, direction }: Sort) {
|
||||
this.sortColumn = active;
|
||||
this.sortDirection = direction;
|
||||
|
||||
this.fetchActivities();
|
||||
}
|
||||
|
||||
private fetchAccountBalances() {
|
||||
this.dataService
|
||||
.fetchAccountBalances(this.data.accountId)
|
||||
@ -183,6 +190,41 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private fetchActivities() {
|
||||
this.isLoadingActivities = true;
|
||||
|
||||
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||
this.dataService
|
||||
.fetchActivities({
|
||||
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
|
||||
sortColumn: this.sortColumn,
|
||||
sortDirection: this.sortDirection
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities, count }) => {
|
||||
this.dataSource = new MatTableDataSource(activities);
|
||||
this.totalItems = count;
|
||||
|
||||
this.isLoadingActivities = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
} else {
|
||||
this.dataService
|
||||
.fetchActivities({
|
||||
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities }) => {
|
||||
this.activities = activities;
|
||||
|
||||
this.isLoadingActivities = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private fetchPortfolioPerformance() {
|
||||
this.isLoadingChart = true;
|
||||
|
||||
|
@ -71,7 +71,25 @@
|
||||
>
|
||||
<mat-tab>
|
||||
<ng-template i18n mat-tab-label>Activities</ng-template>
|
||||
<gf-activities-table-lazy
|
||||
*ngIf="user?.settings?.isExperimentalFeatures === true"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[dataSource]="dataSource"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId && !user.settings.isRestrictedView"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="false"
|
||||
[sortColumn]="sortColumn"
|
||||
[sortDirection]="sortDirection"
|
||||
[totalItems]="totalItems"
|
||||
(export)="onExport()"
|
||||
(sortChanged)="onSortChanged($event)"
|
||||
></gf-activities-table-lazy>
|
||||
<gf-activities-table
|
||||
*ngIf="user?.settings?.isExperimentalFeatures !== true"
|
||||
[activities]="activities"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="data.deviceType"
|
||||
|
@ -7,6 +7,7 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-foote
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
||||
import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module';
|
||||
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
@ -19,6 +20,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
|
||||
CommonModule,
|
||||
GfAccountBalancesModule,
|
||||
GfActivitiesTableModule,
|
||||
GfActivitiesTableLazyModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
GfInvestmentChartModule,
|
||||
|
@ -5,7 +5,7 @@
|
||||
[disabled]="dataSource?.data.length < 2"
|
||||
(click)="onTransferBalance()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="arrow-redo-outline" />
|
||||
<ng-container i18n>Transfer Cash Balance</ng-container>...
|
||||
</button>
|
||||
</div>
|
||||
@ -19,7 +19,7 @@
|
||||
></th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
<div class="d-flex justify-content-center">
|
||||
<ion-icon *ngIf="element.isExcluded" name="eye-off-outline"></ion-icon>
|
||||
<ion-icon *ngIf="element.isExcluded" name="eye-off-outline" />
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
@ -231,7 +231,7 @@
|
||||
title="Note"
|
||||
(click)="onOpenComment(element.comment); $event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="document-text-outline"></ion-icon>
|
||||
<ion-icon name="document-text-outline" />
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
@ -250,12 +250,12 @@
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdateAccount(element)">
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="create-outline" />
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
@ -265,7 +265,7 @@
|
||||
(click)="onDeleteAccount(element.id)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
|
@ -90,33 +90,24 @@
|
||||
<ng-container i18n>Status</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||
<ion-icon
|
||||
*ngIf="element.state === 'active'"
|
||||
name="play-outline"
|
||||
></ion-icon>
|
||||
<ion-icon *ngIf="element.state === 'active'" name="play-outline" />
|
||||
<ion-icon
|
||||
*ngIf="element.state === 'completed'"
|
||||
class="text-success"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
/>
|
||||
<ion-icon
|
||||
*ngIf="element.state === 'delayed'"
|
||||
name="time-outline"
|
||||
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
|
||||
></ion-icon>
|
||||
/>
|
||||
<ion-icon
|
||||
*ngIf="element.state === 'failed'"
|
||||
class="text-danger"
|
||||
name="alert-circle-outline"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
*ngIf="element.state === 'paused'"
|
||||
name="pause-outline"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
*ngIf="element.state === 'waiting'"
|
||||
name="cafe-outline"
|
||||
></ion-icon>
|
||||
/>
|
||||
<ion-icon *ngIf="element.state === 'paused'" name="pause-outline" />
|
||||
<ion-icon *ngIf="element.state === 'waiting'" name="cafe-outline" />
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -128,7 +119,7 @@
|
||||
[matMenuTriggerFor]="jobsActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-vertical" />
|
||||
</button>
|
||||
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onDeleteJobs()">
|
||||
@ -143,7 +134,7 @@
|
||||
[matMenuTriggerFor]="jobActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onViewData(element.data)">
|
||||
|
@ -4,6 +4,10 @@
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
|
||||
gf-line-chart {
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-feature-settings: 'tnum';
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
@ -16,7 +16,7 @@
|
||||
class="text-muted"
|
||||
matDatepickerToggleIcon
|
||||
name="calendar-clear-outline"
|
||||
></ion-icon>
|
||||
/>
|
||||
</mat-datepicker-toggle>
|
||||
<mat-datepicker #date disabled="true"></mat-datepicker>
|
||||
</mat-form-field>
|
||||
@ -38,7 +38,7 @@
|
||||
title="Fetch market price"
|
||||
(click)="onFetchSymbolForDate()"
|
||||
>
|
||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||
<ion-icon class="text-muted" name="refresh-outline" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -125,7 +125,7 @@
|
||||
*ngIf="element.comment"
|
||||
class="d-block"
|
||||
name="document-text-outline"
|
||||
></ion-icon>
|
||||
/>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -137,7 +137,7 @@
|
||||
[matMenuTriggerFor]="assetProfilesActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-vertical" />
|
||||
</button>
|
||||
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onGather7Days()">
|
||||
@ -158,7 +158,7 @@
|
||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
@ -166,7 +166,7 @@
|
||||
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="create-outline" />
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
@ -176,7 +176,7 @@
|
||||
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
@ -225,7 +225,7 @@
|
||||
[queryParams]="{ createAssetProfileDialog: true }"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||
<ion-icon name="add-outline" size="large" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -15,6 +15,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminMarketDataDetails,
|
||||
Currency,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
@ -51,6 +52,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
assetClass: new FormControl<AssetClass>(undefined),
|
||||
assetSubClass: new FormControl<AssetSubClass>(undefined),
|
||||
comment: '',
|
||||
currency: '',
|
||||
historicalData: this.formBuilder.group({
|
||||
csvString: ''
|
||||
}),
|
||||
@ -63,6 +65,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public currencies: Currency[] = [];
|
||||
public isBenchmark = false;
|
||||
public marketDataDetails: MarketData[] = [];
|
||||
public sectors: {
|
||||
@ -86,7 +89,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
) {}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.benchmarks = this.dataService.fetchInfo().benchmarks;
|
||||
const { benchmarks, currencies } = this.dataService.fetchInfo();
|
||||
|
||||
this.benchmarks = benchmarks;
|
||||
this.currencies = currencies.map((currency) => ({
|
||||
label: currency,
|
||||
value: currency
|
||||
}));
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
@ -132,6 +141,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
assetClass: this.assetProfile.assetClass ?? null,
|
||||
assetSubClass: this.assetProfile.assetSubClass ?? null,
|
||||
comment: this.assetProfile?.comment ?? '',
|
||||
currency: this.assetProfile?.currency,
|
||||
historicalData: {
|
||||
csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE
|
||||
},
|
||||
@ -245,12 +255,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
} catch {}
|
||||
|
||||
const assetProfileData: UpdateAssetProfileDto = {
|
||||
scraperConfiguration,
|
||||
symbolMapping,
|
||||
assetClass: this.assetProfileForm.controls['assetClass'].value,
|
||||
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
|
||||
comment: this.assetProfileForm.controls['comment'].value ?? null,
|
||||
name: this.assetProfileForm.controls['name'].value,
|
||||
scraperConfiguration,
|
||||
symbolMapping
|
||||
currency: (<Currency>(
|
||||
(<unknown>this.assetProfileForm.controls['currency'].value)
|
||||
))?.value,
|
||||
name: this.assetProfileForm.controls['name'].value
|
||||
};
|
||||
|
||||
this.adminService
|
||||
@ -264,6 +277,34 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onTestMarketData() {
|
||||
this.adminService
|
||||
.testMarketData({
|
||||
dataSource: this.data.dataSource,
|
||||
scraperConfiguration:
|
||||
this.assetProfileForm.controls['scraperConfiguration'].value,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(
|
||||
catchError(({ error }) => {
|
||||
alert(`Error: ${error?.message}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(({ price }) => {
|
||||
alert(
|
||||
$localize`The current market price is` +
|
||||
' ' +
|
||||
price +
|
||||
' ' +
|
||||
(<Currency>(
|
||||
(<unknown>this.assetProfileForm.controls['currency'].value)
|
||||
))?.value
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
|
||||
this.dataService
|
||||
.deleteBenchmark({ dataSource, symbol })
|
||||
|
@ -15,7 +15,7 @@
|
||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-vertical" />
|
||||
</button>
|
||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item type="button" (click)="initialize()">
|
||||
@ -183,6 +183,15 @@
|
||||
<input formControlName="name" matInput type="text" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Currency</mat-label>
|
||||
<gf-currency-selector
|
||||
formControlName="currency"
|
||||
[currencies]="currencies"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Asset Class</mat-label>
|
||||
@ -234,12 +243,24 @@
|
||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Scraper Configuration</mat-label>
|
||||
<textarea
|
||||
cdkTextareaAutosize
|
||||
formControlName="scraperConfiguration"
|
||||
matInput
|
||||
type="text"
|
||||
></textarea>
|
||||
<div class="align-items-end d-flex">
|
||||
<textarea
|
||||
cdkTextareaAutosize
|
||||
formControlName="scraperConfiguration"
|
||||
matInput
|
||||
type="text"
|
||||
(keyup.enter)="$event.stopPropagation()"
|
||||
></textarea>
|
||||
<button
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="assetProfileForm.controls['scraperConfiguration'].value === '{}'"
|
||||
(click)="onTestMarketData()"
|
||||
>
|
||||
<ng-container i18n>Test</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -10,6 +10,7 @@ import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||
import { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
@ -21,6 +22,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfAdminMarketDataDetailModule,
|
||||
GfCurrencySelectorModule,
|
||||
GfPortfolioProportionChartModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
|
@ -119,8 +119,12 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
const currency = prompt($localize`Please add a currency:`);
|
||||
|
||||
if (currency) {
|
||||
const currencies = uniq([...this.customCurrencies, currency]);
|
||||
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
|
||||
if (currency.length === 3) {
|
||||
const currencies = uniq([...this.customCurrencies, currency]);
|
||||
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
|
||||
} else {
|
||||
alert($localize`${currency} is an invalid currency!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,7 +62,7 @@
|
||||
[matMenuTriggerFor]="exchangeRateActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu
|
||||
#exchangeRateActionsMenu="matMenu"
|
||||
@ -79,10 +79,7 @@
|
||||
[routerLink]="['/admin', 'market-data']"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="mr-2"
|
||||
name="create-outline"
|
||||
></ion-icon>
|
||||
<ion-icon class="mr-2" name="create-outline" />
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</a>
|
||||
@ -92,10 +89,7 @@
|
||||
(click)="onDeleteCurrency(exchangeRate.label2)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="mr-2"
|
||||
name="trash-outline"
|
||||
></ion-icon>
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
@ -109,7 +103,7 @@
|
||||
mat-flat-button
|
||||
(click)="onAddCurrency()"
|
||||
>
|
||||
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
||||
<ion-icon class="mr-1" name="add-outline" />
|
||||
<span i18n>Add Currency</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -147,7 +141,7 @@
|
||||
mat-button
|
||||
(click)="onDeleteSystemMessage()"
|
||||
>
|
||||
<ion-icon name="trash-outline"></ion-icon>
|
||||
<ion-icon name="trash-outline" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@ -157,10 +151,7 @@
|
||||
mat-flat-button
|
||||
(click)="onSetSystemMessage()"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="information-circle-outline"
|
||||
></ion-icon>
|
||||
<ion-icon class="mr-1" name="information-circle-outline" />
|
||||
<span i18n>Set Message</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -182,7 +173,7 @@
|
||||
[matMenuTriggerFor]="couponActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu
|
||||
#couponActionsMenu="matMenu"
|
||||
@ -194,10 +185,7 @@
|
||||
(click)="onDeleteCoupon(coupon.code)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="mr-2"
|
||||
name="trash-outline"
|
||||
></ion-icon>
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
@ -240,7 +228,7 @@
|
||||
<div class="w-50" i18n>Housekeeping</div>
|
||||
<div class="w-50">
|
||||
<button color="warn" mat-flat-button (click)="onFlushCache()">
|
||||
<ion-icon class="mr-1" name="close-circle-outline"></ion-icon>
|
||||
<ion-icon class="mr-1" name="close-circle-outline" />
|
||||
<span i18n>Flush Cache</span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -82,18 +82,18 @@
|
||||
[matMenuTriggerFor]="platformMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu #platformMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdatePlatform(element)">
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="create-outline" />
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onDeletePlatform(element.id)">
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
|
@ -62,18 +62,18 @@
|
||||
[matMenuTriggerFor]="tagMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu #tagMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdateTag(element)">
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="create-outline" />
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onDeleteTag(element.id)">
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
|
@ -195,7 +195,7 @@
|
||||
[matMenuTriggerFor]="userMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu #userMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
@ -204,7 +204,7 @@
|
||||
(click)="onImpersonateUser(element.id)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="contract-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="contract-outline" />
|
||||
<span i18n>Impersonate User</span>
|
||||
</span>
|
||||
</button>
|
||||
@ -214,7 +214,7 @@
|
||||
(click)="onDeleteUser(element.id)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete User</span>
|
||||
</span>
|
||||
</button>
|
||||
|
@ -3,5 +3,5 @@
|
||||
mat-button
|
||||
(click)="onClickCloseButton()"
|
||||
>
|
||||
<ion-icon name="close" size="large"></ion-icon>
|
||||
<ion-icon name="close" size="large" />
|
||||
</button>
|
||||
|
@ -9,5 +9,5 @@
|
||||
mat-button
|
||||
(click)="onClickCloseButton()"
|
||||
>
|
||||
<ion-icon name="close" size="large"></ion-icon>
|
||||
<ion-icon name="close" size="large" />
|
||||
</button>
|
||||
|
@ -119,7 +119,11 @@
|
||||
[matMenuTriggerRestoreFocus]="false"
|
||||
(menuOpened)="onOpenAssistant()"
|
||||
>
|
||||
<ion-icon name="search-outline"></ion-icon>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<ion-icon class="rotate-90" name="options-outline" />
|
||||
} @else {
|
||||
<ion-icon name="search-outline" />
|
||||
}
|
||||
</button>
|
||||
<mat-menu
|
||||
#assistantMenu="matMenu"
|
||||
@ -134,7 +138,10 @@
|
||||
[hasPermissionToAccessAdminControl]="
|
||||
hasPermissionToAccessAdminControl
|
||||
"
|
||||
[user]="user"
|
||||
(closed)="closeAssistant()"
|
||||
(dateRangeChanged)="onDateRangeChange($event)"
|
||||
(selectedTagChanged)="onSelectedTagChanged($event)"
|
||||
/>
|
||||
</mat-menu>
|
||||
</li>
|
||||
@ -150,12 +157,12 @@
|
||||
class="d-none d-sm-block"
|
||||
name="person-circle-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
/>
|
||||
<ion-icon
|
||||
class="d-block d-sm-none"
|
||||
size="large"
|
||||
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
|
||||
></ion-icon>
|
||||
/>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<ng-container *ngIf="user?.access?.length > 0">
|
||||
@ -169,7 +176,7 @@
|
||||
? 'radio-button-off-outline'
|
||||
: 'radio-button-on-outline'
|
||||
"
|
||||
></ion-icon>
|
||||
/>
|
||||
<span i18n>Me</span>
|
||||
</span>
|
||||
</button>
|
||||
@ -187,7 +194,7 @@
|
||||
? 'radio-button-on-outline'
|
||||
: 'radio-button-off-outline'
|
||||
"
|
||||
></ion-icon>
|
||||
/>
|
||||
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
|
||||
<span *ngIf="!accessItem.alias" i18n>User</span>
|
||||
</span>
|
||||
|
@ -32,6 +32,10 @@
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.5rem;
|
||||
|
||||
&.rotate-90 {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -19,9 +19,12 @@ import {
|
||||
SettingsStorageService
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
|
||||
import { Tag } from '@prisma/client';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -88,7 +91,8 @@ export class HeaderComponent implements OnChanges {
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
private tokenStorageService: TokenStorageService
|
||||
private tokenStorageService: TokenStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
@ -144,6 +148,20 @@ export class HeaderComponent implements OnChanges {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
public onDateRangeChange(dateRange: DateRange) {
|
||||
this.dataService
|
||||
.putUserSetting({ dateRange })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe();
|
||||
});
|
||||
}
|
||||
|
||||
public onMenuClosed() {
|
||||
this.isMenuOpen = false;
|
||||
}
|
||||
@ -156,6 +174,20 @@ export class HeaderComponent implements OnChanges {
|
||||
this.assistantElement.initialize();
|
||||
}
|
||||
|
||||
public onSelectedTagChanged(tag: Tag) {
|
||||
this.dataService
|
||||
.putUserSetting({ 'filters.tags': tag ? [tag.id] : null })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe();
|
||||
});
|
||||
}
|
||||
|
||||
public onSignOut() {
|
||||
this.signOut.next();
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<div class="container justify-content-center p-3">
|
||||
<div class="mb-3 text-center">
|
||||
<div *ngIf="!user?.settings?.isExperimentalFeatures" class="mb-3 text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="positions === undefined"
|
||||
|
@ -96,7 +96,10 @@
|
||||
[showDetails]="showDetails"
|
||||
[unit]="unit"
|
||||
></gf-portfolio-performance>
|
||||
<div *ngIf="showDetails" class="text-center">
|
||||
<div
|
||||
*ngIf="showDetails && !user?.settings?.isExperimentalFeatures"
|
||||
class="text-center"
|
||||
>
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="isLoadingPerformance"
|
||||
|
@ -23,7 +23,7 @@
|
||||
>
|
||||
<ion-icon
|
||||
[name]="isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'"
|
||||
></ion-icon>
|
||||
/>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
@ -12,7 +12,7 @@
|
||||
<ion-icon
|
||||
*ngIf="errors?.length > 0 && !isLoading"
|
||||
name="alert-circle-outline"
|
||||
></ion-icon>
|
||||
/>
|
||||
</div>
|
||||
<div *ngIf="isLoading" class="align-items-center d-flex">
|
||||
<ngx-skeleton-loader
|
||||
|
@ -69,7 +69,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-nowrap px-3 py-1 row">
|
||||
<div class="flex-grow-1 ml-3 text-truncate" i18n>Gross Performance</div>
|
||||
<div class="align-items-center d-flex flex-grow-1 ml-3 text-truncate">
|
||||
<ng-container i18n>Gross Performance</ng-container>
|
||||
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
|
||||
</div>
|
||||
<div class="flex-column flex-wrap justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
@ -112,7 +115,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-nowrap px-3 py-1 row">
|
||||
<div class="flex-grow-1 text-truncate ml-3" i18n>Net Performance</div>
|
||||
<div class="flex-grow-1 text-truncate ml-3">
|
||||
<ng-container i18n>Net Performance</ng-container>
|
||||
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
|
||||
</div>
|
||||
<div class="flex-column flex-wrap justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
@ -163,7 +169,7 @@
|
||||
*ngIf="hasPermissionToUpdateUserSettings && !isLoading"
|
||||
class="mr-1 text-muted"
|
||||
name="ellipsis-horizontal-circle-outline"
|
||||
></ion-icon>
|
||||
/>
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[isCurrency]="true"
|
||||
|
@ -7,12 +7,16 @@ import {
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Sort, SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
EnhancedSymbolProfile,
|
||||
LineChartItem
|
||||
LineChartItem,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
@ -31,6 +35,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
|
||||
styleUrls: ['./position-detail-dialog.component.scss']
|
||||
})
|
||||
export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
public activities: OrderWithAccount[];
|
||||
public assetClass: string;
|
||||
public assetSubClass: string;
|
||||
public averagePrice: number;
|
||||
@ -39,6 +44,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public dataProviderInfo: DataProviderInfo;
|
||||
public dataSource: MatTableDataSource<OrderWithAccount>;
|
||||
public dividendInBaseCurrency: number;
|
||||
public feeInBaseCurrency: number;
|
||||
public firstBuyDate: string;
|
||||
@ -51,16 +57,19 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
public minPrice: number;
|
||||
public netPerformance: number;
|
||||
public netPerformancePercent: number;
|
||||
public orders: OrderWithAccount[];
|
||||
public quantity: number;
|
||||
public quantityPrecision = 2;
|
||||
public reportDataGlitchMail: string;
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public sortColumn = 'date';
|
||||
public sortDirection: SortDirection = 'desc';
|
||||
public SymbolProfile: EnhancedSymbolProfile;
|
||||
public tags: Tag[];
|
||||
public totalItems: number;
|
||||
public transactionCount: number;
|
||||
public user: User;
|
||||
public value: number;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -69,7 +78,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<PositionDetailDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
public ngOnInit(): void {
|
||||
@ -102,10 +112,12 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
transactionCount,
|
||||
value
|
||||
}) => {
|
||||
this.activities = orders;
|
||||
this.averagePrice = averagePrice;
|
||||
this.benchmarkDataItems = [];
|
||||
this.countries = {};
|
||||
this.dataProviderInfo = dataProviderInfo;
|
||||
this.dataSource = new MatTableDataSource(orders.reverse());
|
||||
this.dividendInBaseCurrency = dividendInBaseCurrency;
|
||||
this.feeInBaseCurrency = feeInBaseCurrency;
|
||||
this.firstBuyDate = firstBuyDate;
|
||||
@ -130,7 +142,6 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
this.minPrice = minPrice;
|
||||
this.netPerformance = netPerformance;
|
||||
this.netPerformancePercent = netPerformancePercent;
|
||||
this.orders = orders;
|
||||
this.quantity = quantity;
|
||||
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
|
||||
this.sectors = {};
|
||||
@ -142,6 +153,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
};
|
||||
});
|
||||
this.transactionCount = transactionCount;
|
||||
this.totalItems = transactionCount;
|
||||
this.value = value;
|
||||
|
||||
if (SymbolProfile?.assetClass) {
|
||||
@ -239,6 +251,16 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
);
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onClose(): void {
|
||||
@ -246,12 +268,20 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
let activityIds = [];
|
||||
|
||||
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||
activityIds = this.dataSource.data.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
} else {
|
||||
activityIds = this.activities.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
this.dataService
|
||||
.fetchExport(
|
||||
this.orders.map((order) => {
|
||||
return order.id;
|
||||
})
|
||||
)
|
||||
.fetchExport(activityIds)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data) => {
|
||||
downloadAsFile({
|
||||
|
@ -246,11 +246,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
|
||||
<div class="row" [ngClass]="{ 'd-none': !activities?.length }">
|
||||
<div class="col mb-3">
|
||||
<div class="h5 mb-0" i18n>Activities</div>
|
||||
<gf-activities-table-lazy
|
||||
*ngIf="user?.settings?.isExperimentalFeatures === true"
|
||||
[baseCurrency]="data.baseCurrency"
|
||||
[dataSource]="dataSource"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId && !user.settings.isRestrictedView"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data.locale"
|
||||
[showActions]="false"
|
||||
[showNameColumn]="false"
|
||||
[sortColumn]="sortColumn"
|
||||
[sortDirection]="sortDirection"
|
||||
[sortDisabled]="true"
|
||||
[totalItems]="totalItems"
|
||||
(export)="onExport()"
|
||||
></gf-activities-table-lazy>
|
||||
<gf-activities-table
|
||||
[activities]="orders"
|
||||
*ngIf="user?.settings?.isExperimentalFeatures !== true"
|
||||
[activities]="activities"
|
||||
[baseCurrency]="data.baseCurrency"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
@ -277,7 +296,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
|
||||
*ngIf="activities?.length > 0 && data.hasPermissionToReportDataGlitch === true"
|
||||
class="row"
|
||||
>
|
||||
<div class="col">
|
||||
|
@ -5,6 +5,7 @@ import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
@ -19,6 +20,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfActivitiesTableModule,
|
||||
GfActivitiesTableLazyModule,
|
||||
GfDataProviderCreditsModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
|
@ -65,7 +65,7 @@
|
||||
class="chevron text-muted"
|
||||
name="chevron-forward-outline"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user