Compare commits
116 Commits
Author | SHA1 | Date | |
---|---|---|---|
761376d72d | |||
9c086edffe | |||
585f99e4df | |||
9d907b5eb5 | |||
ba05f5ba30 | |||
3261e3ee59 | |||
5607c6bb52 | |||
1c6050d3e3 | |||
38f2930ec6 | |||
556be61fff | |||
651b4bcff7 | |||
0a8d159f78 | |||
1a4109ebaa | |||
92e502e1c2 | |||
e344c43a5a | |||
d6b78f3457 | |||
9bbb856f66 | |||
d3707bbb87 | |||
7df53896f3 | |||
b2b3fde80e | |||
a83441b3ba | |||
075431d868 | |||
0168c1c4e8 | |||
07de8f87fc | |||
3e16041c16 | |||
5882b7914d | |||
69c9e259b1 | |||
aca37a27f9 | |||
313d2a2f79 | |||
9ac67b0af2 | |||
1e526852a7 | |||
e54638a684 | |||
0179823ad9 | |||
029b7bed9a | |||
635f10e2d0 | |||
cebf879d67 | |||
124bdc028d | |||
d69a69ce18 | |||
15344513ce | |||
b291d9e031 | |||
bee702302f | |||
bb56e09a13 | |||
0873f539c5 | |||
6dcd801d05 | |||
77065dac50 | |||
438484879d | |||
e37a650c70 | |||
6e8c90b3fc | |||
9e1a7fc981 | |||
ff638adf03 | |||
fa44cee781 | |||
db1d474ddf | |||
994275e093 | |||
ee397c8047 | |||
7203939c42 | |||
9725f16c81 | |||
bb8b1e4f43 | |||
9d3610331a | |||
0043b44670 | |||
bbc4e64cb4 | |||
c7f4825499 | |||
8f583709ef | |||
4c30212a72 | |||
cade2f6a5e | |||
3b9a8fabb5 | |||
3435b3a348 | |||
5d39b267ab | |||
ffaaa14dba | |||
c65746d119 | |||
1a6840f1f6 | |||
fb7fb886f6 | |||
127abb8f4e | |||
ed1136999a | |||
9f545e3e2b | |||
1602f976f0 | |||
4bf4c1a8a3 | |||
e78755c280 | |||
7772684413 | |||
955302666e | |||
ddce8cc7f9 | |||
aca0d77e91 | |||
8b9379f5ce | |||
0806d0dc92 | |||
e518bc3779 | |||
eff807dd9a | |||
155bf67f60 | |||
9aefe3747e | |||
0878febded | |||
f953c6ea64 | |||
76dbf78279 | |||
f12866b9ec | |||
83ba5f3d9f | |||
7439c1bf54 | |||
e255b76053 | |||
ed7209fb53 | |||
008a2ab123 | |||
2aedd74480 | |||
11076592d1 | |||
ebee851b23 | |||
7d3f1832b4 | |||
39e6abfc8c | |||
78e0fdb0ca | |||
606350b2ff | |||
d09cad4e05 | |||
069660afe4 | |||
4d9a223491 | |||
aed8f5cf04 | |||
1beb4de62f | |||
9bc3505ded | |||
3e82de6b21 | |||
563f354e7e | |||
e96e6c717c | |||
961774ce9f | |||
49f46e1a1e | |||
050c0a4da7 | |||
4908e6d35d |
31
.travis.yml
31
.travis.yml
@ -3,9 +3,28 @@ git:
|
||||
depth: false
|
||||
node_js:
|
||||
- 14
|
||||
before_script:
|
||||
- yarn
|
||||
script:
|
||||
- yarn format:check
|
||||
- yarn test
|
||||
- yarn build:all
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
cache: yarn
|
||||
|
||||
if: (type = pull_request) OR (tag IS present)
|
||||
|
||||
jobs:
|
||||
include:
|
||||
- stage: Install dependencies
|
||||
if: type = pull_request
|
||||
script: yarn --frozen-lockfile
|
||||
- stage: Check formatting
|
||||
if: type = pull_request
|
||||
script: yarn format:check
|
||||
- stage: Execute tests
|
||||
if: type = pull_request
|
||||
script: yarn test
|
||||
- stage: Build application
|
||||
if: type = pull_request
|
||||
script: yarn build:all
|
||||
- stage: Build and publish docker image
|
||||
if: tag IS present
|
||||
script: ./publish-docker-image.sh
|
||||
|
282
CHANGELOG.md
282
CHANGELOG.md
@ -5,6 +5,288 @@ 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).
|
||||
|
||||
## 1.106.0 - 23.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the footer row with total fees and total value to the activities table
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the historical data view in the admin control panel
|
||||
- Upgraded _Stripe_ dependencies
|
||||
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the redirection on logout
|
||||
|
||||
## 1.105.0 - 20.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the data provider with grouping by data source and thereby reducing the number of requests
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the unresolved account names in the _X-ray_ section
|
||||
- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
|
||||
|
||||
## 1.104.0 - 16.01.2022
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the fallback to load currencies directly from the data provider
|
||||
- Fixed the missing symbol profile data connection in the import functionality for activities
|
||||
|
||||
## 1.103.0 - 13.01.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Added links to the statistics section on the about page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the currency of the value in the position detail dialog
|
||||
|
||||
## 1.102.0 - 11.01.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Start eliminating `dataSource` from activity
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the support for multiple accounts with the same name
|
||||
- Fixed the preselected default account of the create activity dialog
|
||||
|
||||
## 1.101.0 - 08.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added `GOOGLE_SHEETS` as a new data source type
|
||||
|
||||
### Changed
|
||||
|
||||
- Excluded the url pattern of shared portfolios in the `robots.txt` file
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.100.0 - 05.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
|
||||
- Added a blog post
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the routing of the create activity dialog
|
||||
- Fixed the link color in the blog posts
|
||||
|
||||
## 1.99.0 - 01.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Exposed the profile data gathering by symbol as an endpoint
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
|
||||
- Restructured the about page
|
||||
- Start refactoring _transactions_ to _activities_
|
||||
- Refactored the demo user id
|
||||
- Upgraded `angular` from version `13.0.2` to `13.1.1`
|
||||
- Upgraded `chart.js` from version `3.5.0` to `3.7.0`
|
||||
- Upgraded `Nx` from version `13.3.0` to `13.4.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hid the data provider warning while loading
|
||||
- Fixed an exception with the market state caused by a failed data provider request
|
||||
- Fixed an exception in the portfolio position endpoint
|
||||
- Fixed the reload of the position detail dialog (with query parameters)
|
||||
- Fixed the missing mapping for Russia in the data enhancer for symbol profile data via _Trackinsight_
|
||||
|
||||
## 1.98.0 - 29.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the date range component to the holdings tab
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the statistics section on the about page (users in Slack community)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
|
||||
- Fixed the scrolling issue in the position detail dialog on mobile
|
||||
|
||||
## 1.97.0 - 28.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the transactions to the position detail dialog
|
||||
- Added support for dividend
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.96.0 - 27.12.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Made the data provider warning more discreet
|
||||
- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
|
||||
- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
|
||||
- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
|
||||
- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
|
||||
- Upgraded `prisma` from version `3.6.0` to `3.7.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the file type detection in the import functionality for transactions
|
||||
|
||||
## 1.95.0 - 26.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a warning to the log if the data gathering fails
|
||||
|
||||
### Fixed
|
||||
|
||||
- Filtered potential `null` currencies
|
||||
- Improved the 7d data gathering optimization for currencies
|
||||
|
||||
## 1.94.0 - 25.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for cryptocurrencies _Cosmos_ (`ATOM-USD`) and _Polkadot_ (`DOT-USD`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 30 days
|
||||
- Made the import functionality for transactions by `csv` files more flexible
|
||||
- Optimized the 7d data gathering (only consider symbols with incomplete market data)
|
||||
- Upgraded `prettier` from version `2.3.2` to `2.5.1`
|
||||
|
||||
## 1.93.0 - 21.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for cryptocurrency _Solana_ (`SOL-USD`)
|
||||
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Converted errors to warnings in portfolio calculator
|
||||
|
||||
## 1.92.0 - 19.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a line chart to the historical data view in the admin control panel
|
||||
- Supported the update of historical data in the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the redirection on logout
|
||||
- Fixed the permission for the system status page
|
||||
|
||||
## 1.91.0 - 18.12.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the redundant all time high and all time low from the performance endpoint
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the symbol conversion from _Yahoo Finance_ including a hyphen
|
||||
- Fixed hidden values (`0`) in the statistics section on the about page
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.90.0 - 14.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the validation in the import functionality for transactions by checking the currency of the data provider service
|
||||
- Added support for cryptocurrency _Uniswap_
|
||||
- Set up pipeline for docker build
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the default transactions import limit
|
||||
- Improved the landing page in dark mode
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `/bin/sh: prisma: not found` in docker build
|
||||
- Added `apk` in `Dockerfile` (`python3 g++ make openssl`)
|
||||
|
||||
## 1.89.0 - 11.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the data gathering by symbol endpoint with an optional date
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `Nx` from version `13.2.2` to `13.3.0`
|
||||
- Upgraded `storybook` from version `6.4.0-rc.3` to `6.4.9`
|
||||
|
||||
## 1.88.0 - 09.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a coupon system
|
||||
|
||||
## 1.87.0 - 07.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Supported the management of additional currencies in the admin control panel
|
||||
- Introduced the system message
|
||||
- Introduced the read only mode
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 10 days
|
||||
- Upgraded `prisma` from version `2.30.2` to `3.6.0`
|
||||
|
||||
## 1.86.0 - 04.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the historical data chart of the _Fear & Greed Index_ (market mood)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the historical data view in the admin control panel (hide invalid and future dates)
|
||||
- Enabled the import functionality for transactions by default
|
||||
- Converted the symbols to uppercase to avoid case-sensitive duplicates in the symbol profile model
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the allocations by currency in combination with cash balances
|
||||
|
||||
## 1.85.0 - 01.12.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the data gathering of the _Fear & Greed Index_ (market mood)
|
||||
|
||||
## 1.84.0 - 30.11.2021
|
||||
|
||||
### Added
|
||||
|
@ -12,7 +12,8 @@ COPY ./package.json package.json
|
||||
COPY ./yarn.lock yarn.lock
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN yarn
|
||||
RUN apk add --no-cache python3 g++ make openssl
|
||||
RUN yarn install
|
||||
|
||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
|
44
README.md
44
README.md
@ -12,7 +12,7 @@
|
||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#contributing">
|
||||
@ -34,7 +34,7 @@
|
||||
|
||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
|
||||
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
|
||||
|
||||
## Why Ghostfolio?
|
||||
|
||||
@ -81,27 +81,43 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
||||
|
||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
|
||||
## Run with Docker
|
||||
## Run with Docker (self-hosting)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
|
||||
### Setup Docker Image
|
||||
### a. Run environment
|
||||
|
||||
Run the following commands to build and start the Docker image:
|
||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose-build-local.yml build
|
||||
docker-compose -f docker/docker-compose-build-local.yml up
|
||||
docker-compose -f docker/docker-compose.yml up
|
||||
```
|
||||
|
||||
### Setup Database
|
||||
#### Setup Database
|
||||
|
||||
Run the following command to setup the database once Ghostfolio is running:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
|
||||
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
||||
```
|
||||
|
||||
### b. Build and run environment
|
||||
|
||||
Run the following commands to build and start the Docker images:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.build.yml build
|
||||
docker-compose -f docker/docker-compose.build.yml up
|
||||
```
|
||||
|
||||
#### Setup Database
|
||||
|
||||
Run the following command to setup the database once Ghostfolio is running:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
||||
```
|
||||
|
||||
### Fetch Historical Data
|
||||
@ -112,6 +128,12 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
### Finalization
|
||||
|
||||
1. Create a new user via _Get Started_
|
||||
1. Assign the role `ADMIN` to this user (directly in the database)
|
||||
1. Delete the original _Admin_ (directly in the database)
|
||||
|
||||
### Migrate Database
|
||||
|
||||
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
||||
@ -131,9 +153,7 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `cd docker`
|
||||
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `cd -` to go back to the project root directory
|
||||
1. Run `docker-compose -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 and populate your database with (example) data
|
||||
1. Start server and client (see [_Development_](#Development))
|
||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
|
@ -1,9 +1,5 @@
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -66,10 +62,7 @@ export class AccessController {
|
||||
@Body() data: CreateAccessDto
|
||||
): Promise<AccessModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.createAccess
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -86,10 +79,7 @@ export class AccessController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteAccess
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
|
@ -24,7 +24,7 @@ export class AccessService {
|
||||
take?: number;
|
||||
cursor?: Prisma.AccessWhereUniqueInput;
|
||||
where?: Prisma.AccessWhereInput;
|
||||
orderBy?: Prisma.AccessOrderByInput;
|
||||
orderBy?: Prisma.AccessOrderByWithRelationInput;
|
||||
}): Promise<AccessWithGranteeUser[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
|
@ -6,11 +6,7 @@ import {
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -48,10 +44,7 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteAccount
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -143,10 +136,7 @@ export class AccountController {
|
||||
@Body() data: CreateAccountDto
|
||||
): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.createAccount
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -183,10 +173,7 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateAccount
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
|
@ -40,7 +40,7 @@ export class AccountService {
|
||||
take?: number;
|
||||
cursor?: Prisma.AccountWhereUniqueInput;
|
||||
where?: Prisma.AccountWhereInput;
|
||||
orderBy?: Prisma.AccountOrderByInput;
|
||||
orderBy?: Prisma.AccountOrderByWithRelationInput;
|
||||
}): Promise<
|
||||
(Account & {
|
||||
Order?: Order[];
|
||||
|
@ -1,36 +1,39 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { isDate } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
public constructor(
|
||||
private readonly adminService: AdminService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@ -39,7 +42,7 @@ export class AdminController {
|
||||
public async getAdminData(): Promise<AdminData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
@ -57,7 +60,7 @@ export class AdminController {
|
||||
public async gatherMax(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
@ -73,35 +76,12 @@ export class AdminController {
|
||||
return;
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@Post('gather/profile-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
@ -116,12 +96,93 @@ export class AdminController {
|
||||
return;
|
||||
}
|
||||
|
||||
@Post('gather/profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
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;
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
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 = new Date(dateString);
|
||||
|
||||
if (!isDate(date)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return this.dataGatheringService.gatherSymbolForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
|
||||
@Get('market-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketData(): Promise<AdminMarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
@ -137,11 +198,11 @@ export class AdminController {
|
||||
@Get('market-data/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('symbol') symbol
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
@ -153,4 +214,58 @@ export class AdminController {
|
||||
|
||||
return this.adminService.getMarketDataBySymbol(symbol);
|
||||
}
|
||||
|
||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
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 = new Date(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
data: { ...data, dataSource },
|
||||
where: {
|
||||
date_symbol: {
|
||||
date,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Put('settings/:key')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminController } from './admin.controller';
|
||||
@ -18,6 +19,7 @@ import { AdminService } from './admin.service';
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
SubscriptionModule
|
||||
],
|
||||
controllers: [AdminController],
|
||||
|
@ -4,13 +4,16 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Property } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
@ -21,6 +24,7 @@ export class AdminService {
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
@ -45,6 +49,7 @@ export class AdminService {
|
||||
};
|
||||
}),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
settings: await this.propertyService.get(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
users: await this.getUsersWithAnalytics()
|
||||
@ -52,12 +57,67 @@ export class AdminService {
|
||||
}
|
||||
|
||||
public async getMarketData(): Promise<AdminMarketData> {
|
||||
const marketData = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['dataSource', 'symbol']
|
||||
});
|
||||
|
||||
const currencyPairsToGather: AdminMarketDataItem[] =
|
||||
this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
marketData: await (
|
||||
await this.dataGatheringService.getSymbolsMax()
|
||||
).map((symbol) => {
|
||||
return symbol;
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol
|
||||
};
|
||||
});
|
||||
|
||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
dataSource: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
||||
marketDataItem.symbol === symbolProfile.symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
marketDataItemCount,
|
||||
activityCount: symbolProfile._count.Order,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
||||
};
|
||||
}
|
||||
|
||||
@ -76,6 +136,23 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
public async putSetting(key: string, value: string) {
|
||||
let response: Property;
|
||||
|
||||
if (value === '') {
|
||||
response = await this.propertyService.delete({ key });
|
||||
} else {
|
||||
response = await this.propertyService.put({ key, value });
|
||||
}
|
||||
|
||||
if (key === PROPERTY_CURRENCIES) {
|
||||
await this.exchangeRateDataService.initialize();
|
||||
await this.dataGatheringService.reset();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering =
|
||||
await this.dataGatheringService.getLastDataGathering();
|
||||
|
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsNumber } from 'class-validator';
|
||||
|
||||
export class UpdateMarketDataDto {
|
||||
@IsNumber()
|
||||
marketPrice: number;
|
||||
}
|
@ -1,9 +1,5 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
@ -29,7 +25,7 @@ export class AuthDeviceController {
|
||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.deleteAuthDevice
|
||||
)
|
||||
) {
|
||||
|
@ -23,7 +23,7 @@ export class AuthDeviceService {
|
||||
take?: number;
|
||||
cursor?: Prisma.AuthDeviceWhereUniqueInput;
|
||||
where?: Prisma.AuthDeviceWhereInput;
|
||||
orderBy?: Prisma.AuthDeviceOrderByInput;
|
||||
orderBy?: Prisma.AuthDeviceOrderByWithRelationInput;
|
||||
}): Promise<AuthDevice[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prismaService.authDevice.findMany({
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -19,7 +19,8 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
}),
|
||||
SubscriptionModule
|
||||
SubscriptionModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
AuthDeviceService,
|
||||
@ -28,7 +29,6 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
GoogleStrategy,
|
||||
JwtStrategy,
|
||||
PrismaService,
|
||||
UserService,
|
||||
WebAuthService
|
||||
]
|
||||
})
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
@ -15,10 +15,11 @@ import { ImportService } from './import.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ImportController],
|
||||
providers: [CacheService, ImportService, OrderService]
|
||||
providers: [CacheService, ImportService]
|
||||
})
|
||||
export class ImportModule {}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Order } from '@prisma/client';
|
||||
@ -6,9 +7,8 @@ import { isSameDay, parseISO } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
private static MAX_ORDERS_TO_IMPORT = 20;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly orderService: OrderService
|
||||
) {}
|
||||
@ -34,11 +34,6 @@ export class ImportService {
|
||||
unitPrice
|
||||
} of orders) {
|
||||
await this.orderService.createOrder({
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { userId, id: accountId }
|
||||
}
|
||||
},
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
@ -46,7 +41,26 @@ export class ImportService {
|
||||
symbol,
|
||||
type,
|
||||
unitPrice,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { userId, id: accountId }
|
||||
}
|
||||
},
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
@ -59,8 +73,14 @@ export class ImportService {
|
||||
orders: Partial<Order>[];
|
||||
userId: string;
|
||||
}) {
|
||||
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
|
||||
throw new Error('Too many transactions');
|
||||
if (
|
||||
orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
|
||||
) {
|
||||
throw new Error(
|
||||
`Too many transactions (${this.configurationService.get(
|
||||
'MAX_ORDERS_TO_IMPORT'
|
||||
)} at most)`
|
||||
);
|
||||
}
|
||||
|
||||
const existingOrders = await this.orderService.orders({
|
||||
@ -98,6 +118,12 @@ export class ImportService {
|
||||
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (result[symbol].currency !== currency) {
|
||||
throw new Error(
|
||||
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
@ -21,6 +22,7 @@ import { InfoService } from './info.service';
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
}),
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
|
@ -4,6 +4,14 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
@ -15,7 +23,6 @@ import { subDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||
|
||||
public constructor(
|
||||
@ -25,15 +32,18 @@ export class InfoService {
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {}
|
||||
|
||||
public async get(): Promise<InfoItem> {
|
||||
const info: Partial<InfoItem> = {};
|
||||
let isReadOnlyMode: boolean;
|
||||
const platforms = await this.prismaService.platform.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
let systemMessage: string;
|
||||
|
||||
const globalPermissions: string[] = [];
|
||||
|
||||
@ -45,6 +55,12 @@ export class InfoService {
|
||||
globalPermissions.push(permissions.enableImport);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
||||
globalPermissions.push(permissions.enableSocialLogin);
|
||||
}
|
||||
@ -59,10 +75,20 @@ export class InfoService {
|
||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
|
||||
globalPermissions.push(permissions.enableSystemMessage);
|
||||
|
||||
systemMessage = (await this.propertyService.getByKey(
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
)) as string;
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
globalPermissions,
|
||||
isReadOnlyMode,
|
||||
platforms,
|
||||
systemMessage,
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
@ -162,9 +188,15 @@ export class InfoService {
|
||||
});
|
||||
}
|
||||
|
||||
private async countSlackCommunityUsers() {
|
||||
return (await this.propertyService.getByKey(
|
||||
PROPERTY_SLACK_COMMUNITY_USERS
|
||||
)) as string;
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: InfoService.DEMO_USER_ID
|
||||
id: DEMO_USER_ID
|
||||
});
|
||||
}
|
||||
|
||||
@ -193,19 +225,19 @@ export class InfoService {
|
||||
} catch {}
|
||||
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers7d = await this.countActiveUsers(7);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const newUsers30d = await this.countNewUsers(30);
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||
|
||||
statistics = {
|
||||
activeUsers1d,
|
||||
activeUsers7d,
|
||||
activeUsers30d,
|
||||
gitHubContributors,
|
||||
gitHubStargazers,
|
||||
newUsers30d
|
||||
newUsers30d,
|
||||
slackCommunityUsers
|
||||
};
|
||||
|
||||
await this.redisCacheService.set(
|
||||
@ -222,7 +254,7 @@ export class InfoService {
|
||||
}
|
||||
|
||||
const stripeConfig = await this.prismaService.property.findUnique({
|
||||
where: { key: 'STRIPE_CONFIG' }
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
});
|
||||
|
||||
if (stripeConfig) {
|
||||
|
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
export interface Activities {
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
feeInBaseCurrency: number;
|
||||
valueInBaseCurrency: number;
|
||||
}
|
@ -1,11 +1,7 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -27,6 +23,7 @@ import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
import { OrderService } from './order.service';
|
||||
import { UpdateOrderDto } from './update-order.dto';
|
||||
|
||||
@ -43,10 +40,7 @@ export class OrderController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteOrder
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -66,38 +60,35 @@ export class OrderController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<OrderModel[]> {
|
||||
): Promise<Activities> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
|
||||
let orders = await this.orderService.orders({
|
||||
include: {
|
||||
Account: {
|
||||
include: {
|
||||
Platform: true
|
||||
}
|
||||
},
|
||||
SymbolProfile: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId: impersonationUserId || this.request.user.id }
|
||||
let activities = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userId: impersonationUserId || this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
||||
activities = nullifyValuesInObjects(activities, [
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
return orders;
|
||||
return { activities };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ -115,10 +106,7 @@ export class OrderController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.createOrder
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -133,23 +121,23 @@ export class OrderController {
|
||||
|
||||
return this.orderService.createOrder({
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
date,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
},
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -161,10 +149,7 @@ export class OrderController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateOrder
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
|
@ -4,6 +4,7 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -16,6 +17,7 @@ import { OrderService } from './order.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
|
@ -1,15 +1,20 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
||||
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
|
||||
import { Activity } from './interfaces/activities.interface';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
public constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
@ -28,7 +33,7 @@ export class OrderService {
|
||||
take?: number;
|
||||
cursor?: Prisma.OrderWhereUniqueInput;
|
||||
where?: Prisma.OrderWhereInput;
|
||||
orderBy?: Prisma.OrderOrderByInput;
|
||||
orderBy?: Prisma.OrderOrderByWithRelationInput;
|
||||
}): Promise<OrderWithAccount[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
@ -45,19 +50,22 @@ export class OrderService {
|
||||
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
// Convert the symbol to uppercase to avoid case-sensitive duplicates
|
||||
const symbol = data.symbol.toUpperCase();
|
||||
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
symbol,
|
||||
dataSource: data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.symbol
|
||||
date: <Date>data.date
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData([
|
||||
{ dataSource: data.dataSource, symbol: data.symbol }
|
||||
{ symbol, dataSource: data.dataSource }
|
||||
]);
|
||||
|
||||
await this.cacheService.flush();
|
||||
@ -65,7 +73,8 @@ export class OrderService {
|
||||
return this.prismaService.order.create({
|
||||
data: {
|
||||
...data,
|
||||
isDraft
|
||||
isDraft,
|
||||
symbol
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -78,28 +87,65 @@ export class OrderService {
|
||||
});
|
||||
}
|
||||
|
||||
public getOrders({
|
||||
public async getOrders({
|
||||
includeDrafts = false,
|
||||
types,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
includeDrafts?: boolean;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}) {
|
||||
}): Promise<Activity[]> {
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
if (includeDrafts === false) {
|
||||
where.isDraft = false;
|
||||
}
|
||||
|
||||
return this.orders({
|
||||
if (types) {
|
||||
where.OR = types.map((type) => {
|
||||
return {
|
||||
type: {
|
||||
equals: type
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
await this.orders({
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: true,
|
||||
Account: {
|
||||
include: {
|
||||
Platform: true
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
).map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
order.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -73,7 +73,7 @@ describe('CurrentRateService', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
dataProviderService = new DataProviderService(null, [], null);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null, null);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
@ -85,19 +85,6 @@ describe('CurrentRateService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('getValue', async () => {
|
||||
expect(
|
||||
await currentRateService.getValue({
|
||||
currency: 'USD',
|
||||
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
||||
symbol: 'AMZN',
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject({
|
||||
marketPrice: 1847.839966
|
||||
});
|
||||
});
|
||||
|
||||
it('getValues', async () => {
|
||||
expect(
|
||||
await currentRateService.getValues({
|
||||
|
@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValueParams } from './interfaces/get-value-params.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -18,46 +17,6 @@ export class CurrentRateService {
|
||||
private readonly marketDataService: MarketDataService
|
||||
) {}
|
||||
|
||||
public async getValue({
|
||||
currency,
|
||||
date,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: GetValueParams): Promise<GetValueObject> {
|
||||
if (isToday(date)) {
|
||||
const dataProviderResult = await this.dataProviderService.get([
|
||||
{
|
||||
symbol,
|
||||
dataSource: this.dataProviderService.getPrimaryDataSource()
|
||||
}
|
||||
]);
|
||||
return {
|
||||
symbol,
|
||||
date: resetHours(date),
|
||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const marketData = await this.marketDataService.get({
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (marketData) {
|
||||
return {
|
||||
date: marketData.date,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
marketData.marketPrice,
|
||||
currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: marketData.symbol
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
|
||||
}
|
||||
|
||||
public async getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
|
@ -1,6 +0,0 @@
|
||||
export interface GetValueParams {
|
||||
currency: string;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
userCurrency: string;
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
@ -10,6 +9,6 @@ export interface PortfolioOrder {
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
type: OrderType;
|
||||
type: TypeOfOrder;
|
||||
unitPrice: Big;
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
@ -16,6 +17,7 @@ export interface PortfolioPositionDetail {
|
||||
name: string;
|
||||
netPerformance: number;
|
||||
netPerformancePercent: number;
|
||||
orders: OrderWithAccount[];
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
transactionCount: number;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,9 @@
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
@ -238,9 +238,7 @@ export class PortfolioCalculator {
|
||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
Logger.error(
|
||||
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
||||
);
|
||||
Logger.warn(`Missing value for symbol ${item.symbol} at ${nextDate}`);
|
||||
continue;
|
||||
}
|
||||
let lastInvestment: Big = new Big(0);
|
||||
@ -271,7 +269,7 @@ export class PortfolioCalculator {
|
||||
if (!initialValue) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
Logger.error(
|
||||
Logger.warn(
|
||||
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||
);
|
||||
continue;
|
||||
@ -515,7 +513,7 @@ export class PortfolioCalculator {
|
||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.error(
|
||||
Logger.warn(
|
||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
||||
);
|
||||
hasErrors = true;
|
||||
@ -660,14 +658,14 @@ export class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
private getFactor(type: OrderType) {
|
||||
private getFactor(type: TypeOfOrder) {
|
||||
let factor: number;
|
||||
|
||||
switch (type) {
|
||||
case OrderType.Buy:
|
||||
case 'BUY':
|
||||
factor = 1;
|
||||
break;
|
||||
case OrderType.Sell:
|
||||
case 'SELL':
|
||||
factor = -1;
|
||||
break;
|
||||
default:
|
||||
|
@ -10,12 +10,12 @@ import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformance,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
@ -48,46 +48,10 @@ export class PortfolioController {
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async findAll(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Res() res: Response
|
||||
): Promise<InvestmentItem[]> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
res.status(StatusCodes.FORBIDDEN);
|
||||
return <any>res.json([]);
|
||||
}
|
||||
|
||||
let investments = await this.portfolioService.getInvestments(
|
||||
impersonationId
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxInvestment = investments.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
1
|
||||
);
|
||||
|
||||
investments = investments.map((item) => ({
|
||||
date: item.date,
|
||||
investment: item.investment / maxInvestment
|
||||
}));
|
||||
}
|
||||
|
||||
return <any>res.json(investments);
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getChart(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioChart> {
|
||||
@ -98,18 +62,14 @@ export class PortfolioController {
|
||||
|
||||
let chartData = historicalDataContainer.items;
|
||||
|
||||
let hasNullValue = false;
|
||||
let hasError = false;
|
||||
|
||||
chartData.forEach((chartDataItem) => {
|
||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||
hasNullValue = true;
|
||||
hasError = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasNullValue) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
@ -131,6 +91,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
return <any>res.json({
|
||||
hasError,
|
||||
chart: chartData,
|
||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||
@ -140,7 +101,7 @@ export class PortfolioController {
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getDetails(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioDetails> {
|
||||
@ -152,6 +113,8 @@ export class PortfolioController {
|
||||
return <any>res.json({ accounts: {}, holdings: {} });
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
@ -160,7 +123,7 @@ export class PortfolioController {
|
||||
);
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (
|
||||
@ -198,43 +161,74 @@ export class PortfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
return <any>res.json({ accounts, holdings });
|
||||
return <any>res.json({ accounts, hasError, holdings });
|
||||
}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioInvestments> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
res.status(StatusCodes.FORBIDDEN);
|
||||
return <any>res.json({});
|
||||
}
|
||||
|
||||
let investments = await this.portfolioService.getInvestments(
|
||||
impersonationId
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxInvestment = investments.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
1
|
||||
);
|
||||
|
||||
investments = investments.map((item) => ({
|
||||
date: item.date,
|
||||
investment: item.investment / maxInvestment
|
||||
}));
|
||||
}
|
||||
|
||||
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPerformance(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPerformance> {
|
||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
if (performanceInformation?.hasErrors) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
let performance = performanceInformation.performance;
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
performance = nullifyValuesInObject(performance, [
|
||||
'currentGrossPerformance',
|
||||
'currentValue'
|
||||
]);
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentGrossPerformance', 'currentValue']
|
||||
);
|
||||
}
|
||||
|
||||
return <any>res.json(performance);
|
||||
return <any>res.json(performanceInformation);
|
||||
}
|
||||
|
||||
@Get('positions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPositions> {
|
||||
@ -243,10 +237,6 @@ export class PortfolioController {
|
||||
range
|
||||
);
|
||||
|
||||
if (result?.hasErrors) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
@ -340,6 +330,7 @@ export class PortfolioController {
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'fees',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
@ -353,7 +344,7 @@ export class PortfolioController {
|
||||
@Get('position/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
let position = await this.portfolioService.getPosition(
|
||||
@ -370,6 +361,7 @@ export class PortfolioController {
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'orders',
|
||||
'quantity',
|
||||
'value'
|
||||
]);
|
||||
@ -387,7 +379,7 @@ export class PortfolioController {
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioReport> {
|
||||
if (
|
||||
|
@ -6,7 +6,6 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
|
||||
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||
@ -21,11 +20,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
|
||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
UNKNOWN_KEY,
|
||||
baseCurrency,
|
||||
ghostfolioCashSymbol
|
||||
} from '@ghostfolio/common/config';
|
||||
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Accounts,
|
||||
@ -60,7 +55,7 @@ import {
|
||||
subDays,
|
||||
subYears
|
||||
} from 'date-fns';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { isEmpty, sortBy } from 'lodash';
|
||||
|
||||
import {
|
||||
HistoricalDataContainer,
|
||||
@ -112,7 +107,7 @@ export class PortfolioService {
|
||||
account.currency,
|
||||
userCurrency
|
||||
),
|
||||
value: details.accounts[account.name]?.current ?? 0
|
||||
value: details.accounts[account.id]?.current ?? 0
|
||||
};
|
||||
|
||||
delete result.Order;
|
||||
@ -155,12 +150,33 @@ export class PortfolioService {
|
||||
return [];
|
||||
}
|
||||
|
||||
return portfolioCalculator.getInvestments().map((item) => {
|
||||
const investments = portfolioCalculator.getInvestments().map((item) => {
|
||||
return {
|
||||
date: item.date,
|
||||
investment: item.investment.toNumber()
|
||||
};
|
||||
});
|
||||
|
||||
// Add investment of today
|
||||
const investmentOfToday = investments.filter((investment) => {
|
||||
return investment.date === format(new Date(), DATE_FORMAT);
|
||||
});
|
||||
|
||||
if (investmentOfToday.length <= 0) {
|
||||
const pastInvestments = investments.filter((investment) => {
|
||||
return isBefore(parseDate(investment.date), new Date());
|
||||
});
|
||||
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
||||
|
||||
investments.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
investment: lastInvestment?.investment ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
return sortBy(investments, (investment) => {
|
||||
return investment.date;
|
||||
});
|
||||
}
|
||||
|
||||
public async getChart(
|
||||
@ -347,13 +363,17 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Add a cash position for each currency
|
||||
holdings[ghostfolioCashSymbol] = await this.getCashPosition({
|
||||
const cashPositions = await this.getCashPositions({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
investment: totalInvestment,
|
||||
value: totalValue
|
||||
});
|
||||
|
||||
for (const symbol of Object.keys(cashPositions)) {
|
||||
holdings[symbol] = cashPositions[symbol];
|
||||
}
|
||||
|
||||
const accounts = await this.getValueOfAccounts(
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
@ -368,11 +388,12 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
||||
(order) => order.symbol === aSymbol
|
||||
);
|
||||
const orders = (
|
||||
await this.orderService.getOrders({ userCurrency, userId })
|
||||
).filter((order) => order.symbol === aSymbol);
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
@ -389,6 +410,7 @@ export class PortfolioService {
|
||||
name: undefined,
|
||||
netPerformance: undefined,
|
||||
netPerformancePercent: undefined,
|
||||
orders: [],
|
||||
quantity: undefined,
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined,
|
||||
@ -401,15 +423,19 @@ export class PortfolioService {
|
||||
const positionCurrency = orders[0].currency;
|
||||
const name = orders[0].SymbolProfile?.name ?? '';
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
const portfolioOrders: PortfolioOrder[] = orders
|
||||
.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
})
|
||||
.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(order.fee),
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.symbol,
|
||||
type: <OrderType>order.type,
|
||||
type: order.type,
|
||||
unitPrice: new Big(order.unitPrice)
|
||||
}));
|
||||
|
||||
@ -443,17 +469,17 @@ export class PortfolioService {
|
||||
// Convert investment, gross and net performance to currency of user
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const investment = this.exchangeRateDataService.toCurrency(
|
||||
position.investment.toNumber(),
|
||||
position.investment?.toNumber(),
|
||||
currency,
|
||||
userCurrency
|
||||
);
|
||||
const grossPerformance = this.exchangeRateDataService.toCurrency(
|
||||
position.grossPerformance.toNumber(),
|
||||
position.grossPerformance?.toNumber(),
|
||||
currency,
|
||||
userCurrency
|
||||
);
|
||||
const netPerformance = this.exchangeRateDataService.toCurrency(
|
||||
position.netPerformance.toNumber(),
|
||||
position.netPerformance?.toNumber(),
|
||||
currency,
|
||||
userCurrency
|
||||
);
|
||||
@ -522,6 +548,7 @@ export class PortfolioService {
|
||||
minPrice,
|
||||
name,
|
||||
netPerformance,
|
||||
orders,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||
@ -579,6 +606,7 @@ export class PortfolioService {
|
||||
maxPrice,
|
||||
minPrice,
|
||||
name,
|
||||
orders,
|
||||
averagePrice: 0,
|
||||
currency: currentData[aSymbol]?.currency,
|
||||
firstBuyDate: undefined,
|
||||
@ -656,7 +684,9 @@ export class PortfolioService {
|
||||
grossPerformancePercentage:
|
||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||
investment: new Big(position.investment).toNumber(),
|
||||
marketState: dataProviderResponses[position.symbol].marketState,
|
||||
marketState:
|
||||
dataProviderResponses[position.symbol]?.marketState ??
|
||||
MarketState.delayed,
|
||||
name: symbolProfileMap[position.symbol].name,
|
||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||
netPerformancePercentage:
|
||||
@ -689,9 +719,7 @@ export class PortfolioService {
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentValue: 0,
|
||||
isAllTimeHigh: false,
|
||||
isAllTimeLow: false
|
||||
currentValue: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -724,29 +752,11 @@ export class PortfolioService {
|
||||
currentGrossPerformancePercent,
|
||||
currentNetPerformance,
|
||||
currentNetPerformancePercent,
|
||||
currentValue,
|
||||
isAllTimeHigh: true, // TODO
|
||||
isAllTimeLow: false // TODO
|
||||
currentValue
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date
|
||||
return isBefore(date, new Date(order.date));
|
||||
})
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.currency,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
|
||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
@ -827,7 +837,7 @@ export class PortfolioService {
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions.totalInvestment.toNumber(),
|
||||
this.getFees(orders)
|
||||
this.getFees(orders).toNumber()
|
||||
)
|
||||
],
|
||||
{ baseCurrency: currency }
|
||||
@ -837,21 +847,25 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||
|
||||
const { balance } = await this.accountService.getCashDetails(
|
||||
userId,
|
||||
currency
|
||||
userCurrency
|
||||
);
|
||||
const orders = await this.orderService.getOrders({ userId });
|
||||
const fees = this.getFees(orders);
|
||||
const orders = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
const dividend = this.getDividend(orders).toNumber();
|
||||
const fees = this.getFees(orders).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
|
||||
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
|
||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||
|
||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
||||
|
||||
@ -861,50 +875,129 @@ export class PortfolioService {
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
dividend,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
cash: balance,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
ordersCount: orders.length,
|
||||
totalBuy: totalBuy,
|
||||
totalSell: totalSell
|
||||
ordersCount: orders.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
}).length
|
||||
};
|
||||
}
|
||||
|
||||
private async getCashPosition({
|
||||
private async getCashPositions({
|
||||
cashDetails,
|
||||
investment,
|
||||
userCurrency,
|
||||
value
|
||||
}: {
|
||||
cashDetails: CashDetails;
|
||||
investment: Big;
|
||||
userCurrency: string;
|
||||
value: Big;
|
||||
}) {
|
||||
const cashValue = new Big(cashDetails.balance);
|
||||
const cashPositions = {};
|
||||
|
||||
return {
|
||||
allocationCurrent: cashValue.div(value).toNumber(),
|
||||
allocationInvestment: cashValue.div(investment).toNumber(),
|
||||
for (const account of cashDetails.accounts) {
|
||||
const convertedBalance = this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
userCurrency
|
||||
);
|
||||
|
||||
if (convertedBalance === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cashPositions[account.currency]) {
|
||||
cashPositions[account.currency].investment += convertedBalance;
|
||||
cashPositions[account.currency].value += convertedBalance;
|
||||
} else {
|
||||
cashPositions[account.currency] = {
|
||||
allocationCurrent: 0,
|
||||
allocationInvestment: 0,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetClass.CASH,
|
||||
countries: [],
|
||||
currency: 'CHF',
|
||||
currency: account.currency,
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: cashValue.toNumber(),
|
||||
investment: convertedBalance,
|
||||
marketPrice: 0,
|
||||
marketState: MarketState.open,
|
||||
name: 'Cash',
|
||||
name: account.currency,
|
||||
netPerformance: 0,
|
||||
netPerformancePercent: 0,
|
||||
quantity: 0,
|
||||
sectors: [],
|
||||
symbol: ghostfolioCashSymbol,
|
||||
symbol: account.currency,
|
||||
transactionCount: 0,
|
||||
value: cashValue.toNumber()
|
||||
value: convertedBalance
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const symbol of Object.keys(cashPositions)) {
|
||||
// Calculate allocations for each currency
|
||||
cashPositions[symbol].allocationCurrent = new Big(
|
||||
cashPositions[symbol].value
|
||||
)
|
||||
.div(value)
|
||||
.toNumber();
|
||||
cashPositions[symbol].allocationInvestment = new Big(
|
||||
cashPositions[symbol].investment
|
||||
)
|
||||
.div(investment)
|
||||
.toNumber();
|
||||
}
|
||||
|
||||
return cashPositions;
|
||||
}
|
||||
|
||||
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date and type dividend
|
||||
return (
|
||||
isBefore(date, new Date(order.date)) &&
|
||||
order.type === TypeOfOrder.DIVIDEND
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.currency,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date
|
||||
return isBefore(date, new Date(order.date));
|
||||
})
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.currency,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||
switch (aDateRange) {
|
||||
@ -934,16 +1027,22 @@ export class PortfolioService {
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: OrderWithAccount[];
|
||||
}> {
|
||||
const orders = await this.orderService.getOrders({ includeDrafts, userId });
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
|
||||
const orders = await this.orderService.getOrders({
|
||||
includeDrafts,
|
||||
userCurrency,
|
||||
userId,
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return { transactionPoints: [], orders: [] };
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
@ -955,7 +1054,7 @@ export class PortfolioService {
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.symbol,
|
||||
type: <OrderType>order.type,
|
||||
type: order.type,
|
||||
unitPrice: new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
order.unitPrice,
|
||||
@ -996,8 +1095,11 @@ export class PortfolioService {
|
||||
account.currency,
|
||||
userCurrency
|
||||
);
|
||||
accounts[account.name] = {
|
||||
accounts[account.id] = {
|
||||
balance: convertedBalance,
|
||||
currency: account.currency,
|
||||
current: convertedBalance,
|
||||
name: account.name,
|
||||
original: convertedBalance
|
||||
};
|
||||
|
||||
@ -1011,14 +1113,17 @@ export class PortfolioService {
|
||||
originalValueOfSymbol *= -1;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
||||
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
||||
currentValueOfSymbol;
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
||||
originalValueOfSymbol;
|
||||
} else {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||
balance: 0,
|
||||
currency: order.Account?.currency,
|
||||
current: currentValueOfSymbol,
|
||||
name: account.name,
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
|
||||
import { Coupon } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -14,6 +17,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
@ -22,16 +26,70 @@ import { SubscriptionService } from './subscription.service';
|
||||
export class SubscriptionController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
@Post('redeem-coupon')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async redeemCoupon(
|
||||
@Body() { couponCode }: { couponCode: string },
|
||||
@Res() res: Response
|
||||
) {
|
||||
if (!this.request.user) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let coupons =
|
||||
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
||||
[];
|
||||
|
||||
const isValid = coupons.some((coupon) => {
|
||||
return coupon.code === couponCode;
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
await this.subscriptionService.createSubscription(this.request.user.id);
|
||||
|
||||
// Destroy coupon
|
||||
coupons = coupons.filter((coupon) => {
|
||||
return coupon.code !== couponCode;
|
||||
});
|
||||
await this.propertyService.put({
|
||||
key: PROPERTY_COUPONS,
|
||||
value: JSON.stringify(coupons)
|
||||
});
|
||||
|
||||
Logger.log(
|
||||
`Subscription for user '${this.request.user.id}' has been created with coupon`
|
||||
);
|
||||
|
||||
res.status(StatusCodes.OK);
|
||||
|
||||
return <any>res.json({
|
||||
message: getReasonPhrase(StatusCodes.OK),
|
||||
statusCode: StatusCodes.OK
|
||||
});
|
||||
}
|
||||
|
||||
@Get('stripe/callback')
|
||||
public async stripeCallback(@Req() req, @Res() res) {
|
||||
await this.subscriptionService.createSubscription(
|
||||
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||
req.query.checkoutSessionId
|
||||
);
|
||||
|
||||
Logger.log(`Subscription for user '${userId}' has been created via Stripe`);
|
||||
|
||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SubscriptionController } from './subscription.controller';
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [PropertyModule],
|
||||
controllers: [SubscriptionController],
|
||||
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
||||
exports: [SubscriptionService]
|
||||
|
@ -2,7 +2,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Subscription } from '@prisma/client';
|
||||
import { Subscription, User } from '@prisma/client';
|
||||
import { addDays, isBefore } from 'date-fns';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
@ -64,26 +64,32 @@ export class SubscriptionService {
|
||||
};
|
||||
}
|
||||
|
||||
public async createSubscription(aCheckoutSessionId: string) {
|
||||
try {
|
||||
const session = await this.stripe.checkout.sessions.retrieve(
|
||||
aCheckoutSessionId
|
||||
);
|
||||
|
||||
public async createSubscription(aUserId: string) {
|
||||
await this.prismaService.subscription.create({
|
||||
data: {
|
||||
expiresAt: addDays(new Date(), 365),
|
||||
User: {
|
||||
connect: {
|
||||
id: session.client_reference_id
|
||||
id: aUserId
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
||||
try {
|
||||
const session = await this.stripe.checkout.sessions.retrieve(
|
||||
aCheckoutSessionId
|
||||
);
|
||||
|
||||
await this.createSubscription(session.client_reference_id);
|
||||
|
||||
await this.stripe.customers.update(session.customer as string, {
|
||||
description: session.client_reference_id
|
||||
});
|
||||
|
||||
return session.client_reference_id;
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface SymbolItem {
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
historicalData: HistoricalDataItem[];
|
||||
marketPrice: number;
|
||||
}
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
ParseBoolPipe,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
@ -12,7 +15,7 @@ import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { isDate, isEmpty } from 'lodash';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -34,8 +37,7 @@ export class SymbolController {
|
||||
@Query() { query = '' }
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
try {
|
||||
const encodedQuery = encodeURIComponent(query.toLowerCase());
|
||||
return this.symbolService.lookup(encodedQuery);
|
||||
return this.symbolService.lookup(query.toLowerCase());
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
@ -51,7 +53,9 @@ export class SymbolController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getSymbolData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
|
||||
includeHistoricalData: boolean
|
||||
): Promise<SymbolItem> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
@ -60,7 +64,10 @@ export class SymbolController {
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.symbolService.get({ dataSource, symbol });
|
||||
const result = await this.symbolService.get({
|
||||
includeHistoricalData,
|
||||
dataGatheringItem: { dataSource, symbol }
|
||||
});
|
||||
|
||||
if (!result || isEmpty(result)) {
|
||||
throw new HttpException(
|
||||
@ -71,4 +78,27 @@ export class SymbolController {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherSymbolForDate(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<IDataProviderHistoricalResponse> {
|
||||
const date = new Date(dateString);
|
||||
|
||||
if (!isDate(date)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return this.symbolService.getForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@ -7,7 +8,12 @@ import { SymbolController } from './symbol.controller';
|
||||
import { SymbolService } from './symbol.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigurationModule, DataProviderModule, PrismaModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PrismaModule
|
||||
],
|
||||
controllers: [SymbolController],
|
||||
providers: [SymbolService]
|
||||
})
|
||||
|
@ -1,8 +1,15 @@
|
||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -11,16 +18,42 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
export class SymbolService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> {
|
||||
public async get({
|
||||
dataGatheringItem,
|
||||
includeHistoricalData = false
|
||||
}: {
|
||||
dataGatheringItem: IDataGatheringItem;
|
||||
includeHistoricalData?: boolean;
|
||||
}): Promise<SymbolItem> {
|
||||
const response = await this.dataProviderService.get([dataGatheringItem]);
|
||||
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
||||
|
||||
if (dataGatheringItem.dataSource && marketPrice) {
|
||||
let historicalData: HistoricalDataItem[];
|
||||
|
||||
if (includeHistoricalData) {
|
||||
const days = 30;
|
||||
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: { gte: subDays(new Date(), days) },
|
||||
symbols: [dataGatheringItem.symbol]
|
||||
});
|
||||
|
||||
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||
return {
|
||||
value,
|
||||
date: date.toISOString()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currency,
|
||||
historicalData,
|
||||
marketPrice,
|
||||
dataSource: dataGatheringItem.dataSource
|
||||
};
|
||||
@ -29,6 +62,27 @@ export class SymbolService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async getForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<IDataProviderHistoricalResponse> {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[{ dataSource, symbol }],
|
||||
date,
|
||||
date
|
||||
);
|
||||
|
||||
return {
|
||||
marketPrice:
|
||||
historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
|
||||
};
|
||||
}
|
||||
|
||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const results: { items: LookupItem[] } = { items: [] };
|
||||
|
||||
@ -39,32 +93,6 @@ export class SymbolService {
|
||||
try {
|
||||
const { items } = await this.dataProviderService.search(aQuery);
|
||||
results.items = items;
|
||||
|
||||
// Add custom symbols
|
||||
const ghostfolioSymbolProfiles =
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
name: {
|
||||
startsWith: aQuery
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
|
||||
results.items.push(ghostfolioSymbolProfile);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
hasRole,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
@ -20,7 +23,7 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { Provider, Role } from '@prisma/client';
|
||||
import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -34,7 +37,9 @@ import { UserService } from './user.service';
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private jwtService: JwtService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
@ -43,10 +48,7 @@ export class UserController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteUser
|
||||
) ||
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteUser) ||
|
||||
id === this.request.user.id
|
||||
) {
|
||||
throw new HttpException(
|
||||
@ -68,6 +70,19 @@ export class UserController {
|
||||
|
||||
@Post()
|
||||
public async signupUser(): Promise<UserItem> {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
|
||||
if (isReadOnlyMode) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { accessToken, id } = await this.userService.createUser({
|
||||
provider: Provider.ANONYMOUS
|
||||
});
|
||||
@ -85,7 +100,7 @@ export class UserController {
|
||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.updateUserSettings
|
||||
)
|
||||
) {
|
||||
@ -111,7 +126,7 @@ export class UserController {
|
||||
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.updateUserSettings
|
||||
)
|
||||
) {
|
||||
@ -127,10 +142,7 @@ export class UserController {
|
||||
};
|
||||
|
||||
if (
|
||||
hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateViewMode
|
||||
)
|
||||
hasPermission(this.request.user.permissions, permissions.updateViewMode)
|
||||
) {
|
||||
userSettings.viewMode = data.viewMode;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -13,6 +14,7 @@ import { UserService } from './user.service';
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
}),
|
||||
PropertyModule,
|
||||
SubscriptionModule
|
||||
],
|
||||
controllers: [UserController],
|
||||
|
@ -1,12 +1,21 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { baseCurrency, locale } from '@ghostfolio/common/config';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
baseCurrency,
|
||||
locale
|
||||
} from '@ghostfolio/common/config';
|
||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
||||
import {
|
||||
getPermissions,
|
||||
hasRole,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client';
|
||||
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UserSettings } from './interfaces/user-settings.interface';
|
||||
@ -20,6 +29,7 @@ export class UserService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
@ -74,12 +84,32 @@ export class UserService {
|
||||
|
||||
const user: UserWithSettings = userFromDatabase;
|
||||
|
||||
const currentPermissions = getPermissions(userFromDatabase.role);
|
||||
let currentPermissions = getPermissions(userFromDatabase.role);
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
if (hasRole(user, Role.ADMIN)) {
|
||||
currentPermissions.push(permissions.toggleReadOnlyMode);
|
||||
}
|
||||
|
||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
|
||||
if (isReadOnlyMode) {
|
||||
currentPermissions = currentPermissions.filter((permission) => {
|
||||
return !(
|
||||
permission.startsWith('create') ||
|
||||
permission.startsWith('delete') ||
|
||||
permission.startsWith('update')
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
user.permissions = currentPermissions;
|
||||
|
||||
if (userFromDatabase?.Settings) {
|
||||
@ -119,7 +149,7 @@ export class UserService {
|
||||
take?: number;
|
||||
cursor?: Prisma.UserWhereUniqueInput;
|
||||
where?: Prisma.UserWhereInput;
|
||||
orderBy?: Prisma.UserOrderByInput;
|
||||
orderBy?: Prisma.UserOrderByWithRelationInput;
|
||||
}): Promise<User[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prismaService.user.findMany({
|
||||
|
@ -1,8 +0,0 @@
|
||||
export enum OrderType {
|
||||
CorporateAction = 'CORPORATE_ACTION',
|
||||
Bonus = 'BONUS',
|
||||
Buy = 'BUY',
|
||||
Dividend = 'DIVIDEND',
|
||||
Sell = 'SELL',
|
||||
Split = 'SPLIT'
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
import { Account, SymbolProfile } from '@prisma/client';
|
||||
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
import { OrderType } from './order-type';
|
||||
|
||||
export class Order {
|
||||
private account: Account;
|
||||
@ -15,7 +14,7 @@ export class Order {
|
||||
private symbol: string;
|
||||
private symbolProfile: SymbolProfile;
|
||||
private total: number;
|
||||
private type: OrderType;
|
||||
private type: TypeOfOrder;
|
||||
private unitPrice: number;
|
||||
|
||||
public constructor(data: IOrder) {
|
||||
|
@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const account of Object.keys(this.accounts)) {
|
||||
accounts[account] = {
|
||||
name: account,
|
||||
investment: this.accounts[account].current
|
||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||
accounts[accountId] = {
|
||||
name: account.name,
|
||||
investment: account.current
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
|
||||
Object.values(accounts).forEach((account) => {
|
||||
for (const account of Object.values(accounts)) {
|
||||
if (!maxItem) {
|
||||
maxItem = account;
|
||||
}
|
||||
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
if (account.investment > maxItem?.investment) {
|
||||
maxItem = account;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||
|
||||
|
@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings?: Settings) {
|
||||
const platforms: {
|
||||
const accounts: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||
investment: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const account of Object.keys(this.accounts)) {
|
||||
platforms[account] = {
|
||||
name: account,
|
||||
investment: this.accounts[account].original
|
||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||
accounts[accountId] = {
|
||||
name: account.name,
|
||||
investment: account.original
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
|
||||
Object.values(platforms).forEach((platform) => {
|
||||
for (const account of Object.values(accounts)) {
|
||||
if (!maxItem) {
|
||||
maxItem = platform;
|
||||
maxItem = account;
|
||||
}
|
||||
|
||||
// Calculate total investment
|
||||
totalInvestment += platform.investment;
|
||||
totalInvestment += account.investment;
|
||||
|
||||
// Find maximum
|
||||
if (platform.investment > maxItem?.investment) {
|
||||
maxItem = platform;
|
||||
if (account.investment > maxItem?.investment) {
|
||||
maxItem = account;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||
|
||||
|
@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { Environment } from './interfaces/environment.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -14,18 +13,25 @@ export class ConfigurationService {
|
||||
ACCESS_TOKEN_SALT: str(),
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }),
|
||||
ENABLE_FEATURE_IMPORT: bool({ default: true }),
|
||||
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
||||
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||
GOOGLE_SHEETS_ID: str({ default: '' }),
|
||||
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||
JWT_SECRET_KEY: str({}),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
PORT: port({ default: 3333 }),
|
||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
|
@ -10,7 +10,7 @@ export class CryptocurrencyService {
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public isCrypto(aSymbol = '') {
|
||||
public isCryptocurrency(aSymbol = '') {
|
||||
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
|
||||
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
|
||||
}
|
||||
|
@ -1,7 +1,11 @@
|
||||
{
|
||||
"1INCH": "1inch",
|
||||
"ALGO": "Algorand",
|
||||
"ATOM": "Cosmos",
|
||||
"AVAX": "Avalanche",
|
||||
"DOT": "Polkadot",
|
||||
"MATIC": "Polygon",
|
||||
"SHIB": "Shiba Inu"
|
||||
"SHIB": "Shiba Inu",
|
||||
"SOL": "Solana",
|
||||
"UNI3": "Uniswap"
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PROPERTY_LAST_DATA_GATHERING,
|
||||
PROPERTY_LOCKED_DATA_GATHERING
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -13,7 +16,6 @@ import {
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||
@ -25,7 +27,6 @@ export class DataGatheringService {
|
||||
private dataGatheringProgress: number;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@Inject('DataEnhancers')
|
||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
@ -43,7 +44,7 @@ export class DataGatheringService {
|
||||
|
||||
await this.prismaService.property.create({
|
||||
data: {
|
||||
key: 'LOCKED_DATA_GATHERING',
|
||||
key: PROPERTY_LOCKED_DATA_GATHERING,
|
||||
value: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
@ -55,11 +56,11 @@ export class DataGatheringService {
|
||||
|
||||
await this.prismaService.property.upsert({
|
||||
create: {
|
||||
key: 'LAST_DATA_GATHERING',
|
||||
key: PROPERTY_LAST_DATA_GATHERING,
|
||||
value: new Date().toISOString()
|
||||
},
|
||||
update: { value: new Date().toISOString() },
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
@ -67,7 +68,7 @@ export class DataGatheringService {
|
||||
|
||||
await this.prismaService.property.delete({
|
||||
where: {
|
||||
key: 'LOCKED_DATA_GATHERING'
|
||||
key: PROPERTY_LOCKED_DATA_GATHERING
|
||||
}
|
||||
});
|
||||
|
||||
@ -78,7 +79,7 @@ export class DataGatheringService {
|
||||
|
||||
public async gatherMax() {
|
||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
||||
});
|
||||
|
||||
if (!isDataGatheringLocked) {
|
||||
@ -87,7 +88,7 @@ export class DataGatheringService {
|
||||
|
||||
await this.prismaService.property.create({
|
||||
data: {
|
||||
key: 'LOCKED_DATA_GATHERING',
|
||||
key: PROPERTY_LOCKED_DATA_GATHERING,
|
||||
value: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
@ -99,11 +100,11 @@ export class DataGatheringService {
|
||||
|
||||
await this.prismaService.property.upsert({
|
||||
create: {
|
||||
key: 'LAST_DATA_GATHERING',
|
||||
key: PROPERTY_LAST_DATA_GATHERING,
|
||||
value: new Date().toISOString()
|
||||
},
|
||||
update: { value: new Date().toISOString() },
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
@ -111,7 +112,7 @@ export class DataGatheringService {
|
||||
|
||||
await this.prismaService.property.delete({
|
||||
where: {
|
||||
key: 'LOCKED_DATA_GATHERING'
|
||||
key: PROPERTY_LOCKED_DATA_GATHERING
|
||||
}
|
||||
});
|
||||
|
||||
@ -128,7 +129,7 @@ export class DataGatheringService {
|
||||
symbol: string;
|
||||
}) {
|
||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
||||
});
|
||||
|
||||
if (!isDataGatheringLocked) {
|
||||
@ -137,7 +138,7 @@ export class DataGatheringService {
|
||||
|
||||
await this.prismaService.property.create({
|
||||
data: {
|
||||
key: 'LOCKED_DATA_GATHERING',
|
||||
key: PROPERTY_LOCKED_DATA_GATHERING,
|
||||
value: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
@ -156,11 +157,11 @@ export class DataGatheringService {
|
||||
|
||||
await this.prismaService.property.upsert({
|
||||
create: {
|
||||
key: 'LAST_DATA_GATHERING',
|
||||
key: PROPERTY_LAST_DATA_GATHERING,
|
||||
value: new Date().toISOString()
|
||||
},
|
||||
update: { value: new Date().toISOString() },
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
@ -168,7 +169,7 @@ export class DataGatheringService {
|
||||
|
||||
await this.prismaService.property.delete({
|
||||
where: {
|
||||
key: 'LOCKED_DATA_GATHERING'
|
||||
key: PROPERTY_LOCKED_DATA_GATHERING
|
||||
}
|
||||
});
|
||||
|
||||
@ -177,6 +178,44 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherSymbolForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}) {
|
||||
try {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[{ dataSource, symbol }],
|
||||
date,
|
||||
date
|
||||
);
|
||||
|
||||
const marketPrice =
|
||||
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
|
||||
|
||||
if (marketPrice) {
|
||||
return await this.prismaService.marketData.upsert({
|
||||
create: {
|
||||
dataSource,
|
||||
date,
|
||||
marketPrice,
|
||||
symbol
|
||||
},
|
||||
update: { marketPrice },
|
||||
where: { date_symbol: { date, symbol } }
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
} finally {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
||||
Logger.log('Profile data gathering has been started.');
|
||||
console.time('data-gathering-profile');
|
||||
@ -203,7 +242,7 @@ export class DataGatheringService {
|
||||
try {
|
||||
currentData[symbol] = await dataEnhancer.enhance({
|
||||
response,
|
||||
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
|
||||
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
|
||||
@ -295,6 +334,7 @@ export class DataGatheringService {
|
||||
?.marketPrice;
|
||||
}
|
||||
|
||||
if (lastMarketPrice) {
|
||||
try {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
@ -305,6 +345,14 @@ export class DataGatheringService {
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
} else {
|
||||
Logger.warn(
|
||||
`Failed to gather data for symbol ${symbol} at ${format(
|
||||
currentDate,
|
||||
DATE_FORMAT
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Count month one up for iteration
|
||||
currentDate = new Date(
|
||||
@ -351,13 +399,13 @@ export class DataGatheringService {
|
||||
|
||||
public async getIsInProgress() {
|
||||
return await this.prismaService.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
||||
});
|
||||
}
|
||||
|
||||
public async getLastDataGathering() {
|
||||
const lastDataGathering = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
||||
});
|
||||
|
||||
if (lastDataGathering?.value) {
|
||||
@ -406,11 +454,7 @@ export class DataGatheringService {
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...currencyPairsToGather,
|
||||
...symbolProfilesToGather
|
||||
];
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
public async reset() {
|
||||
@ -418,28 +462,35 @@ export class DataGatheringService {
|
||||
|
||||
await this.prismaService.property.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
||||
OR: [
|
||||
{ key: PROPERTY_LAST_DATA_GATHERING },
|
||||
{ key: PROPERTY_LOCKED_DATA_GATHERING }
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
||||
const benchmarksToGather: IDataGatheringItem[] = [];
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
benchmarksToGather.push({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
date: startDate,
|
||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||
});
|
||||
}
|
||||
|
||||
return benchmarksToGather;
|
||||
}
|
||||
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
// Only consider symbols with incomplete market data for the last
|
||||
// 7 days
|
||||
const symbolsToGather = (
|
||||
await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['symbol'],
|
||||
where: {
|
||||
date: { gt: startDate }
|
||||
}
|
||||
})
|
||||
)
|
||||
.filter((group) => {
|
||||
return group._count < 6;
|
||||
})
|
||||
.map((group) => {
|
||||
return group.symbol;
|
||||
});
|
||||
|
||||
const symbolProfilesToGather = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
@ -449,7 +500,11 @@ export class DataGatheringService {
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
)
|
||||
.filter(({ symbol }) => {
|
||||
return symbolsToGather.includes(symbol);
|
||||
})
|
||||
.map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: startDate
|
||||
@ -458,6 +513,9 @@ export class DataGatheringService {
|
||||
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ symbol }) => {
|
||||
return symbolsToGather.includes(symbol);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
@ -466,37 +524,29 @@ export class DataGatheringService {
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...currencyPairsToGather,
|
||||
...symbolProfilesToGather
|
||||
];
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prismaService.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true }
|
||||
});
|
||||
|
||||
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
|
||||
(distinctOrder) => {
|
||||
return distinctOrders.filter((distinctOrder) => {
|
||||
return (
|
||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async isDataGatheringNeeded() {
|
||||
const lastDataGathering = await this.getLastDataGathering();
|
||||
|
||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
||||
});
|
||||
|
||||
const diffInHours = differenceInHours(new Date(), lastDataGathering);
|
||||
|
@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
return DataSource.ALPHA_VANTAGE;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(aSymbol);
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(aQuery);
|
||||
|
||||
return {
|
||||
items: result?.bestMatches?.map((bestMatch) => {
|
||||
return {
|
||||
dataSource: DataSource.ALPHA_VANTAGE,
|
||||
dataSource: this.getName(),
|
||||
name: bestMatch['2. name'],
|
||||
symbol: bestMatch['1. symbol']
|
||||
};
|
||||
|
@ -7,6 +7,9 @@ const getJSON = bent('json');
|
||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
||||
private static countries = require('countries-list/dist/countries.json');
|
||||
private static countriesMapping = {
|
||||
'Russian Federation': 'Russia'
|
||||
};
|
||||
private static sectorsMapping = {
|
||||
'Consumer Discretionary': 'Consumer Cyclical',
|
||||
'Consumer Defensive': 'Consumer Staples',
|
||||
@ -45,7 +48,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
for (const [key, country] of Object.entries<any>(
|
||||
TrackinsightDataEnhancerService.countries
|
||||
)) {
|
||||
if (country.name === name) {
|
||||
if (
|
||||
country.name === name ||
|
||||
country.name ===
|
||||
TrackinsightDataEnhancerService.countriesMapping[name]
|
||||
) {
|
||||
countryCode = key;
|
||||
break;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
@ -21,12 +22,14 @@ import { DataProviderService } from './data-provider.service';
|
||||
AlphaVantageService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService,
|
||||
{
|
||||
inject: [
|
||||
AlphaVantageService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
],
|
||||
@ -34,11 +37,13 @@ import { DataProviderService } from './data-provider.service';
|
||||
useFactory: (
|
||||
alphaVantageService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
rakutenRapidApiService,
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
rakutenRapidApiService,
|
||||
yahooFinanceService
|
||||
]
|
||||
|
@ -11,8 +11,8 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { format, isValid } from 'date-fns';
|
||||
import { groupBy, isEmpty } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
export class DataProviderService {
|
||||
@ -30,18 +30,27 @@ export class DataProviderService {
|
||||
[symbol: string]: IDataProviderResponse;
|
||||
} = {};
|
||||
|
||||
for (const item of items) {
|
||||
const dataProvider = this.getDataProvider(item.dataSource);
|
||||
response[item.symbol] = (await dataProvider.get([item.symbol]))[
|
||||
item.symbol
|
||||
];
|
||||
}
|
||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||
|
||||
const promises = [];
|
||||
for (const symbol of Object.keys(response)) {
|
||||
const promise = Promise.resolve(response[symbol]);
|
||||
|
||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||
itemsGroupedByDataSource
|
||||
)) {
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
|
||||
const promise = Promise.resolve(
|
||||
this.getDataProvider(DataSource[dataSource]).get(symbols)
|
||||
);
|
||||
|
||||
promises.push(
|
||||
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
||||
promise.then((result) => {
|
||||
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
|
||||
response[symbol] = dataProviderResponse;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,7 +71,7 @@ export class DataProviderService {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
if (isEmpty(aItems)) {
|
||||
if (isEmpty(aItems) || !isValid(from) || !isValid(to)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
@ -96,7 +105,7 @@ export class DataProviderService {
|
||||
ORDER BY date;`;
|
||||
|
||||
const marketDataByGranularity: MarketData[] =
|
||||
await this.prismaService.$queryRaw(queryRaw);
|
||||
await this.prismaService.$queryRawUnsafe(queryRaw);
|
||||
|
||||
response = marketDataByGranularity.reduce((r, marketData) => {
|
||||
const { date, marketPrice, symbol } = marketData;
|
||||
@ -149,13 +158,13 @@ export class DataProviderService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||
let lookupItems: LookupItem[] = [];
|
||||
|
||||
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
||||
promises.push(
|
||||
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
|
||||
this.getDataProvider(DataSource[dataSource]).search(aQuery)
|
||||
);
|
||||
}
|
||||
|
||||
@ -176,7 +185,7 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
public getPrimaryDataSource(): DataSource {
|
||||
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
|
||||
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
|
||||
}
|
||||
|
||||
private getDataProvider(providerName: DataSource) {
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
@ -13,13 +19,6 @@ import * as bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
||||
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
[symbol]: {
|
||||
marketPrice,
|
||||
currency: symbolProfile?.currency,
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
dataSource: this.getName(),
|
||||
marketState: MarketState.delayed
|
||||
}
|
||||
};
|
||||
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
return DataSource.GHOSTFOLIO;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
}
|
||||
},
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
private extractNumberFromString(aString: string): number {
|
||||
|
@ -0,0 +1,181 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { GoogleSpreadsheet } from 'google-spreadsheet';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleSheetsService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
aSymbols
|
||||
);
|
||||
|
||||
const sheet = await this.getSheet({
|
||||
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
|
||||
symbol: 'Overview'
|
||||
});
|
||||
|
||||
const rows = await sheet.getRows();
|
||||
|
||||
for (const row of rows) {
|
||||
const marketPrice = parseFloat(row['marketPrice']);
|
||||
const symbol = row['symbol'];
|
||||
|
||||
if (aSymbols.includes(symbol)) {
|
||||
response[symbol] = {
|
||||
marketPrice,
|
||||
currency: symbolProfiles.find((symbolProfile) => {
|
||||
return symbolProfile.symbol === symbol;
|
||||
})?.currency,
|
||||
dataSource: this.getName(),
|
||||
marketState: MarketState.delayed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbols: string[],
|
||||
aGranularity: Granularity = 'day',
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const [symbol] = aSymbols;
|
||||
|
||||
const sheet = await this.getSheet({
|
||||
symbol,
|
||||
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
|
||||
});
|
||||
|
||||
const rows = await sheet.getRows();
|
||||
|
||||
const historicalData: {
|
||||
[date: string]: IDataProviderHistoricalResponse;
|
||||
} = {};
|
||||
|
||||
rows
|
||||
.filter((row, index) => {
|
||||
return index >= 1;
|
||||
})
|
||||
.forEach((row) => {
|
||||
const date = parseDate(row._rawData[0]);
|
||||
const close = parseFloat(row._rawData[1]);
|
||||
|
||||
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
|
||||
});
|
||||
|
||||
return {
|
||||
[symbol]: historicalData
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
return DataSource.GOOGLE_SHEETS;
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
}
|
||||
},
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
private async getSheet({
|
||||
sheetId,
|
||||
symbol
|
||||
}: {
|
||||
sheetId: string;
|
||||
symbol: string;
|
||||
}) {
|
||||
const doc = new GoogleSpreadsheet(sheetId);
|
||||
|
||||
await doc.useServiceAccountAuth({
|
||||
client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
|
||||
private_key: this.configurationService
|
||||
.get('GOOGLE_SHEETS_PRIVATE_KEY')
|
||||
.replace(/\\n/g, '\n')
|
||||
});
|
||||
|
||||
await doc.loadInfo();
|
||||
|
||||
const sheet = doc.sheetsByTitle[symbol];
|
||||
|
||||
await sheet.loadCells();
|
||||
|
||||
return sheet;
|
||||
}
|
||||
}
|
@ -1,11 +1,10 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '../../interfaces/interfaces';
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface DataProviderInterface {
|
||||
canHandle(symbol: string): boolean;
|
||||
@ -23,5 +22,5 @@ export interface DataProviderInterface {
|
||||
|
||||
getName(): DataSource;
|
||||
|
||||
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
||||
search(aQuery: string): Promise<{ items: LookupItem[] }>;
|
||||
}
|
||||
|
@ -2,12 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getToday,
|
||||
getYesterday,
|
||||
isRakutenRapidApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -31,10 +26,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return (
|
||||
isRakutenRapidApiSymbol(symbol) &&
|
||||
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
|
||||
);
|
||||
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
|
||||
}
|
||||
|
||||
public async get(
|
||||
@ -53,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return {
|
||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||
currency: undefined,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: fgi.now.value,
|
||||
marketState: MarketState.open,
|
||||
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
|
||||
@ -93,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
date: subWeeks(getToday(), 1),
|
||||
marketPrice: fgi.oneWeekAgo.value
|
||||
}
|
||||
@ -102,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
date: subMonths(getToday(), 1),
|
||||
marketPrice: fgi.oneMonthAgo.value
|
||||
}
|
||||
@ -111,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
date: subYears(getToday(), 1),
|
||||
marketPrice: fgi.oneYearAgo.value
|
||||
}
|
||||
@ -137,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return DataSource.RAKUTEN;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,60 @@
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
|
||||
import { YahooFinanceService } from './yahoo-finance.service';
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/cryptocurrency/cryptocurrency.service',
|
||||
() => {
|
||||
return {
|
||||
CryptocurrencyService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
isCryptocurrency: (symbol: string) => {
|
||||
switch (symbol) {
|
||||
case 'BTCUSD':
|
||||
return true;
|
||||
case 'DOGEUSD':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
describe('YahooFinanceService', () => {
|
||||
let cryptocurrencyService: CryptocurrencyService;
|
||||
let yahooFinanceService: YahooFinanceService;
|
||||
|
||||
beforeAll(async () => {
|
||||
cryptocurrencyService = new CryptocurrencyService();
|
||||
|
||||
yahooFinanceService = new YahooFinanceService(cryptocurrencyService);
|
||||
});
|
||||
|
||||
it('convertFromYahooFinanceSymbol', async () => {
|
||||
expect(
|
||||
await yahooFinanceService.convertFromYahooFinanceSymbol('BRK-B')
|
||||
).toEqual('BRK-B');
|
||||
expect(
|
||||
await yahooFinanceService.convertFromYahooFinanceSymbol('BTC-USD')
|
||||
).toEqual('BTCUSD');
|
||||
expect(
|
||||
await yahooFinanceService.convertFromYahooFinanceSymbol('EURUSD=X')
|
||||
).toEqual('EURUSD');
|
||||
});
|
||||
|
||||
it('convertToYahooFinanceSymbol', async () => {
|
||||
expect(
|
||||
await yahooFinanceService.convertToYahooFinanceSymbol('BTCUSD')
|
||||
).toEqual('BTC-USD');
|
||||
expect(
|
||||
await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD')
|
||||
).toEqual('DOGE-USD');
|
||||
expect(
|
||||
await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF')
|
||||
).toEqual('USDCHF=X');
|
||||
});
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -8,7 +8,7 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import Big from 'big.js';
|
||||
import { countries } from 'countries-list';
|
||||
import { format } from 'date-fns';
|
||||
import { addDays, format, isSameDay } from 'date-fns';
|
||||
import * as yahooFinance from 'yahoo-finance';
|
||||
|
||||
import {
|
||||
@ -35,6 +35,44 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||
const symbol = aYahooFinanceSymbol.replace(
|
||||
new RegExp(`-${baseCurrency}$`),
|
||||
baseCurrency
|
||||
);
|
||||
return symbol.replace('=X', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a symbol to a Yahoo Finance symbol
|
||||
*
|
||||
* Currency: USDCHF -> USDCHF=X
|
||||
* Cryptocurrency: BTCUSD -> BTC-USD
|
||||
* DOGEUSD -> DOGE-USD
|
||||
*/
|
||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||
if (aSymbol.includes(baseCurrency) && aSymbol.length >= 6) {
|
||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||
return `${aSymbol}=X`;
|
||||
} else if (
|
||||
this.cryptocurrencyService.isCryptocurrency(
|
||||
aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
||||
)
|
||||
) {
|
||||
// Add a dash before the last three characters
|
||||
// BTCUSD -> BTC-USD
|
||||
// DOGEUSD -> DOGE-USD
|
||||
// SOL1USD -> SOL1-USD
|
||||
return aSymbol.replace(
|
||||
new RegExp(`-?${baseCurrency}$`),
|
||||
`-${baseCurrency}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return aSymbol;
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
@ -65,11 +103,11 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency: value.price?.currency,
|
||||
dataSource: DataSource.YAHOO,
|
||||
dataSource: this.getName(),
|
||||
exchange: this.parseExchange(value.price?.exchangeName),
|
||||
marketState:
|
||||
value.price?.marketState === 'REGULAR' ||
|
||||
this.cryptocurrencyService.isCrypto(symbol)
|
||||
this.cryptocurrencyService.isCryptocurrency(symbol)
|
||||
? MarketState.open
|
||||
: MarketState.closed,
|
||||
marketPrice: value.price?.regularMarketPrice || 0,
|
||||
@ -135,6 +173,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (isSameDay(from, to)) {
|
||||
to = addDays(to, 1);
|
||||
}
|
||||
|
||||
const yahooFinanceSymbols = aSymbols.map((symbol) => {
|
||||
return this.convertToYahooFinanceSymbol(symbol);
|
||||
});
|
||||
@ -179,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return DataSource.YAHOO;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
|
||||
aQuery
|
||||
)}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
@ -200,8 +244,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
.filter(({ quoteType, symbol }) => {
|
||||
return (
|
||||
(quoteType === 'CRYPTOCURRENCY' &&
|
||||
this.cryptocurrencyService.isCrypto(
|
||||
symbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
|
||||
this.cryptocurrencyService.isCryptocurrency(
|
||||
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
||||
)) ||
|
||||
quoteType === 'EQUITY' ||
|
||||
quoteType === 'ETF'
|
||||
@ -209,9 +253,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
})
|
||||
.filter(({ quoteType, symbol }) => {
|
||||
if (quoteType === 'CRYPTOCURRENCY') {
|
||||
// Only allow cryptocurrencies in USD to avoid having redundancy in the database.
|
||||
// Trades need to be converted manually before to USD (or a UI converter needs to be developed)
|
||||
return symbol.includes('USD');
|
||||
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
|
||||
// Transactions need to be converted manually to the base currency before
|
||||
return symbol.includes(baseCurrency);
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -226,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
items.push({
|
||||
symbol,
|
||||
currency: value.currency,
|
||||
dataSource: DataSource.YAHOO,
|
||||
dataSource: this.getName(),
|
||||
name: value.name
|
||||
});
|
||||
}
|
||||
@ -235,44 +279,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return { items };
|
||||
}
|
||||
|
||||
private convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||
const symbol = aYahooFinanceSymbol.replace('-', '');
|
||||
return symbol.replace('=X', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a symbol to a Yahoo Finance symbol
|
||||
*
|
||||
* Currency: USDCHF -> USDCHF=X
|
||||
* Cryptocurrency: BTCUSD -> BTC-USD
|
||||
* DOGEUSD -> DOGE-USD
|
||||
* SOL1USD -> SOL1-USD
|
||||
*/
|
||||
private convertToYahooFinanceSymbol(aSymbol: string) {
|
||||
if (
|
||||
(aSymbol.includes('CHF') ||
|
||||
aSymbol.includes('EUR') ||
|
||||
aSymbol.includes('USD')) &&
|
||||
aSymbol.length >= 6
|
||||
) {
|
||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||
return `${aSymbol}=X`;
|
||||
} else if (
|
||||
this.cryptocurrencyService.isCrypto(
|
||||
aSymbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
|
||||
)
|
||||
) {
|
||||
// Add a dash before the last three characters
|
||||
// BTCUSD -> BTC-USD
|
||||
// DOGEUSD -> DOGE-USD
|
||||
// SOL1USD -> SOL1-USD
|
||||
return aSymbol.replace(new RegExp('-?USD$'), '-USD');
|
||||
}
|
||||
}
|
||||
|
||||
return aSymbol;
|
||||
}
|
||||
|
||||
private parseAssetClass(aPrice: IYahooFinancePrice): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
|
@ -3,9 +3,10 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaModule } from './prisma.module';
|
||||
import { PropertyModule } from './property/property.module';
|
||||
|
||||
@Module({
|
||||
imports: [DataProviderModule, PrismaModule],
|
||||
imports: [DataProviderModule, PrismaModule, PropertyModule],
|
||||
providers: [ExchangeRateDataService],
|
||||
exports: [ExchangeRateDataService]
|
||||
})
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
@ -7,6 +7,7 @@ import { isEmpty, isNumber, uniq } from 'lodash';
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { PropertyService } from './property/property.service';
|
||||
|
||||
@Injectable()
|
||||
export class ExchangeRateDataService {
|
||||
@ -16,7 +17,8 @@ export class ExchangeRateDataService {
|
||||
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {
|
||||
this.initialize();
|
||||
}
|
||||
@ -56,9 +58,9 @@ export class ExchangeRateDataService {
|
||||
getYesterday()
|
||||
);
|
||||
|
||||
if (isEmpty(result)) {
|
||||
if (Object.keys(result).length !== this.currencyPairs.length) {
|
||||
// Load currencies directly from data provider as a fallback
|
||||
// if historical data is not yet available
|
||||
// if historical data is not fully available
|
||||
const historicalData = await this.dataProviderService.get(
|
||||
this.currencyPairs.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
@ -149,13 +151,18 @@ export class ExchangeRateDataService {
|
||||
}
|
||||
|
||||
private async prepareCurrencies(): Promise<string[]> {
|
||||
const currencies: string[] = [];
|
||||
let currencies: string[] = [];
|
||||
|
||||
(
|
||||
await this.prismaService.account.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
select: { currency: true },
|
||||
where: {
|
||||
currency: {
|
||||
not: null
|
||||
}
|
||||
}
|
||||
})
|
||||
).forEach((account) => {
|
||||
currencies.push(account.currency);
|
||||
@ -165,7 +172,12 @@ export class ExchangeRateDataService {
|
||||
await this.prismaService.settings.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
select: { currency: true },
|
||||
where: {
|
||||
currency: {
|
||||
not: null
|
||||
}
|
||||
}
|
||||
})
|
||||
).forEach((userSettings) => {
|
||||
currencies.push(userSettings.currency);
|
||||
@ -175,12 +187,25 @@ export class ExchangeRateDataService {
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
select: { currency: true },
|
||||
where: {
|
||||
currency: {
|
||||
not: null
|
||||
}
|
||||
}
|
||||
})
|
||||
).forEach((symbolProfile) => {
|
||||
currencies.push(symbolProfile.currency);
|
||||
});
|
||||
|
||||
const customCurrencies = (await this.propertyService.getByKey(
|
||||
PROPERTY_CURRENCIES
|
||||
)) as string[];
|
||||
|
||||
if (customCurrencies?.length > 0) {
|
||||
currencies = currencies.concat(customCurrencies);
|
||||
}
|
||||
|
||||
return uniq(currencies).sort();
|
||||
}
|
||||
|
||||
|
@ -4,18 +4,25 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ACCESS_TOKEN_SALT: string;
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
CACHE_TTL: number;
|
||||
DATA_SOURCE_PRIMARY: string;
|
||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
||||
ENABLE_FEATURE_BLOG: boolean;
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||
ENABLE_FEATURE_IMPORT: boolean;
|
||||
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||
ENABLE_FEATURE_STATISTICS: boolean;
|
||||
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_SECRET: string;
|
||||
GOOGLE_SHEETS_ACCOUNT: string;
|
||||
GOOGLE_SHEETS_ID: string;
|
||||
GOOGLE_SHEETS_PRIVATE_KEY: string;
|
||||
JWT_SECRET_KEY: string;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
MAX_ORDERS_TO_IMPORT: number;
|
||||
PORT: number;
|
||||
RAKUTEN_RAPID_API_KEY: string;
|
||||
REDIS_HOST: string;
|
||||
|
@ -3,11 +3,10 @@ import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
SymbolProfile,
|
||||
Type as TypeOfOrder
|
||||
} from '@prisma/client';
|
||||
|
||||
import { OrderType } from '../../models/order-type';
|
||||
|
||||
export const MarketState = {
|
||||
closed: 'closed',
|
||||
delayed: 'delayed',
|
||||
@ -24,7 +23,7 @@ export interface IOrder {
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
symbolProfile: SymbolProfile;
|
||||
type: OrderType;
|
||||
type: TypeOfOrder;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MarketData, Prisma } from '@prisma/client';
|
||||
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class MarketDataService {
|
||||
@ -53,7 +54,7 @@ export class MarketDataService {
|
||||
take?: number;
|
||||
cursor?: Prisma.MarketDataWhereUniqueInput;
|
||||
where?: Prisma.MarketDataWhereInput;
|
||||
orderBy?: Prisma.MarketDataOrderByInput;
|
||||
orderBy?: Prisma.MarketDataOrderByWithRelationInput;
|
||||
}): Promise<MarketData[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
@ -65,4 +66,22 @@ export class MarketDataService {
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async updateMarketData(params: {
|
||||
data: { dataSource: DataSource } & UpdateMarketDataDto;
|
||||
where: Prisma.MarketDataWhereUniqueInput;
|
||||
}): Promise<MarketData> {
|
||||
const { data, where } = params;
|
||||
|
||||
return this.prismaService.marketData.upsert({
|
||||
where,
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
date: where.date_symbol.date,
|
||||
marketPrice: data.marketPrice,
|
||||
symbol: where.date_symbol.symbol
|
||||
},
|
||||
update: { marketPrice: data.marketPrice }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
6
apps/api/src/services/property/property.dto.ts
Normal file
6
apps/api/src/services/property/property.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class PropertyDto {
|
||||
@IsString()
|
||||
value: string;
|
||||
}
|
11
apps/api/src/services/property/property.module.ts
Normal file
11
apps/api/src/services/property/property.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PropertyService } from './property.service';
|
||||
|
||||
@Module({
|
||||
exports: [PropertyService],
|
||||
imports: [PrismaModule],
|
||||
providers: [PropertyService]
|
||||
})
|
||||
export class PropertyModule {}
|
49
apps/api/src/services/property/property.service.ts
Normal file
49
apps/api/src/services/property/property.service.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class PropertyService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async delete({ key }: { key: string }) {
|
||||
return this.prismaService.property.delete({
|
||||
where: { key }
|
||||
});
|
||||
}
|
||||
|
||||
public async get() {
|
||||
const response: {
|
||||
[key: string]: boolean | object | string | string[];
|
||||
} = {
|
||||
[PROPERTY_CURRENCIES]: []
|
||||
};
|
||||
|
||||
const properties = await this.prismaService.property.findMany();
|
||||
|
||||
for (const property of properties) {
|
||||
let value = property.value;
|
||||
|
||||
try {
|
||||
value = JSON.parse(property.value);
|
||||
} catch {}
|
||||
|
||||
response[property.key] = value;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async getByKey(aKey: string) {
|
||||
const properties = await this.get();
|
||||
return properties?.[aKey];
|
||||
}
|
||||
|
||||
public async put({ key, value }: { key: string; value: string }) {
|
||||
return this.prismaService.property.upsert({
|
||||
create: { key, value },
|
||||
update: { value },
|
||||
where: { key }
|
||||
});
|
||||
}
|
||||
}
|
@ -9,6 +9,13 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
||||
},
|
||||
{
|
||||
path: 'about/changelog',
|
||||
loadChildren: () =>
|
||||
import('./pages/about/changelog/changelog-page.module').then(
|
||||
(m) => m.ChangelogPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
loadChildren: () =>
|
||||
@ -33,6 +40,11 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog',
|
||||
loadChildren: () =>
|
||||
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
||||
},
|
||||
{
|
||||
path: 'de/blog/2021/07/hallo-ghostfolio',
|
||||
loadChildren: () =>
|
||||
@ -47,6 +59,13 @@ const routes: Routes = [
|
||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||
).then((m) => m.HelloGhostfolioPageModule)
|
||||
},
|
||||
{
|
||||
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
||||
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () =>
|
||||
@ -66,6 +85,13 @@ const routes: Routes = [
|
||||
(m) => m.PortfolioPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/activities',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/transactions/transactions-page.module').then(
|
||||
(m) => m.TransactionsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/allocations',
|
||||
loadChildren: () =>
|
||||
@ -87,13 +113,6 @@ const routes: Routes = [
|
||||
(m) => m.ReportPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'portfolio/transactions',
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/transactions/transactions-page.module').then(
|
||||
(m) => m.TransactionsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
loadChildren: () =>
|
||||
|
@ -9,18 +9,27 @@
|
||||
</header>
|
||||
|
||||
<main role="main">
|
||||
<div *ngIf="canCreateAccount" class="container create-account-container">
|
||||
<div
|
||||
*ngIf="canCreateAccount || (info?.systemMessage && user)"
|
||||
class="container info-message-container"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2 text-center">
|
||||
<a class="text-center" [routerLink]="['/']">
|
||||
<a *ngIf="canCreateAccount" class="text-center" [routerLink]="['/']">
|
||||
<div
|
||||
class="create-account-box d-inline-block px-3 py-2"
|
||||
class="cursor-pointer d-inline-block info-message px-3 py-2"
|
||||
(click)="onCreateAccount()"
|
||||
>
|
||||
<span i18n>You are using the Live Demo.</span>
|
||||
<a class="ml-2" href="#" i18n>Create Account</a>
|
||||
</div></a
|
||||
>
|
||||
<div
|
||||
*ngIf="!canCreateAccount && info?.systemMessage && user"
|
||||
class="d-inline-block info-message px-3 py-2"
|
||||
>
|
||||
{{ info.systemMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -8,14 +8,13 @@
|
||||
min-height: 100vh;
|
||||
padding-top: 5rem;
|
||||
|
||||
.create-account-container {
|
||||
.info-message-container {
|
||||
height: 3.5rem;
|
||||
margin-top: -0.5rem;
|
||||
|
||||
.create-account-box {
|
||||
.info-message {
|
||||
background-color: rgba(0, 0, 0, $alpha-hover);
|
||||
border-radius: 2rem;
|
||||
cursor: pointer;
|
||||
font-size: 80%;
|
||||
|
||||
a {
|
||||
|
@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.tokenStorageService.signOut();
|
||||
this.userService.remove();
|
||||
|
||||
window.location.reload();
|
||||
document.location.href = '/';
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -15,7 +15,7 @@
|
||||
>(Default)</span
|
||||
>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currency">
|
||||
|
@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
||||
import { AccountsTableComponent } from './accounts-table.component';
|
||||
|
||||
@NgModule({
|
||||
|
@ -1,21 +1,35 @@
|
||||
<div class="py-2">
|
||||
<div>
|
||||
<gf-line-chart
|
||||
class="mb-4"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
></gf-line-chart>
|
||||
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
||||
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||
<div class="align-items-center d-flex flex-grow-1 px-1">
|
||||
<div
|
||||
*ngFor="let dayItem of days; let i = index"
|
||||
class="day"
|
||||
[ngClass]="{
|
||||
'cursor-pointer valid': isDateOfInterest(
|
||||
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
||||
),
|
||||
available:
|
||||
marketDataByMonth[itemByMonth.key][
|
||||
i + 1 < 10 ? '0' + (i + 1) : i + 1
|
||||
]?.day ===
|
||||
i + 1
|
||||
}"
|
||||
[title]="
|
||||
(marketDataByMonth[itemByMonth.key][i + 1]?.date
|
||||
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
||||
| date: defaultDateFormat) ?? ''
|
||||
"
|
||||
[ngClass]="{
|
||||
'available cursor-pointer':
|
||||
marketDataByMonth[itemByMonth.key][i + 1]?.day == i + 1
|
||||
}"
|
||||
(click)="
|
||||
marketDataByMonth[itemByMonth.key][i + 1] &&
|
||||
onOpenMarketDataDetail(marketDataByMonth[itemByMonth.key][i + 1])
|
||||
onOpenMarketDataDetail({
|
||||
day: i + 1 < 10 ? '0' + (i + 1) : i + 1,
|
||||
yearMonth: itemByMonth.key
|
||||
})
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
|
@ -10,11 +10,18 @@
|
||||
}
|
||||
|
||||
.day {
|
||||
background-color: var(--danger);
|
||||
height: 0.5rem;
|
||||
margin-right: 0.25rem;
|
||||
width: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.valid {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
|
||||
&.available {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
@ -1,14 +1,18 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||
import { MarketData } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format, isBefore, isValid, parse } from 'date-fns';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@ -21,11 +25,16 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
|
||||
templateUrl: './admin-market-data-detail.component.html'
|
||||
})
|
||||
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
@Input() dataSource: DataSource;
|
||||
@Input() marketData: MarketData[];
|
||||
@Input() symbol: string;
|
||||
|
||||
@Output() marketDataChanged = new EventEmitter<boolean>();
|
||||
|
||||
public days = Array(31);
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public deviceType: string;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public marketDataByMonth: {
|
||||
[yearMonth: string]: { [day: string]: MarketData & { day: number } };
|
||||
} = {};
|
||||
@ -42,6 +51,12 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.historicalDataItems = this.marketData.map((marketDataItem) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value: marketDataItem.marketPrice
|
||||
};
|
||||
});
|
||||
this.marketDataByMonth = {};
|
||||
|
||||
for (const marketDataItem of this.marketData) {
|
||||
@ -52,19 +67,36 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
this.marketDataByMonth[key] = {};
|
||||
}
|
||||
|
||||
this.marketDataByMonth[key][currentDay] = {
|
||||
this.marketDataByMonth[key][
|
||||
currentDay < 10 ? `0${currentDay}` : currentDay
|
||||
] = {
|
||||
...marketDataItem,
|
||||
day: currentDay
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public onOpenMarketDataDetail({ date, marketPrice, symbol }: MarketData) {
|
||||
public isDateOfInterest(aDateString: string) {
|
||||
// Date is valid and in the past
|
||||
const date = parse(aDateString, DATE_FORMAT, new Date());
|
||||
return isValid(date) && isBefore(date, new Date());
|
||||
}
|
||||
|
||||
public onOpenMarketDataDetail({
|
||||
day,
|
||||
yearMonth
|
||||
}: {
|
||||
day: string;
|
||||
yearMonth: string;
|
||||
}) {
|
||||
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
|
||||
|
||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||
data: {
|
||||
marketPrice,
|
||||
symbol,
|
||||
date: format(date, DEFAULT_DATE_FORMAT)
|
||||
dataSource: this.dataSource,
|
||||
date: new Date(`${yearMonth}-${day}`),
|
||||
symbol: this.symbol
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
@ -73,7 +105,9 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
.subscribe(({ withRefresh }) => {
|
||||
this.marketDataChanged.next(withRefresh);
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
|
||||
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
|
||||
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
|
||||
@ -7,7 +8,7 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark
|
||||
@NgModule({
|
||||
declarations: [AdminMarketDataDetailComponent],
|
||||
exports: [AdminMarketDataDetailComponent],
|
||||
imports: [CommonModule, GfMarketDataDetailDialogModule],
|
||||
imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface MarketDataDetailDialogParams {
|
||||
date: string;
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
marketPrice: number;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Subject } from 'rxjs';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
import { MarketDataDetailDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@ -20,6 +22,8 @@ export class MarketDataDetailDialog implements OnDestroy {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams
|
||||
) {}
|
||||
@ -27,7 +31,36 @@ export class MarketDataDetailDialog implements OnDestroy {
|
||||
public ngOnInit() {}
|
||||
|
||||
public onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
this.dialogRef.close({ withRefresh: false });
|
||||
}
|
||||
|
||||
public onFetchSymbolForDate() {
|
||||
this.adminService
|
||||
.fetchSymbolForDate({
|
||||
dataSource: this.data.dataSource,
|
||||
date: this.data.date,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketPrice }) => {
|
||||
this.data.marketPrice = marketPrice;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public onUpdate() {
|
||||
this.adminService
|
||||
.putMarketData({
|
||||
dataSource: this.data.dataSource,
|
||||
date: this.data.date,
|
||||
marketData: { marketPrice: this.data.marketPrice },
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.dialogRef.close({ withRefresh: true });
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -1,10 +1,24 @@
|
||||
<form class="d-flex flex-column h-100">
|
||||
<h1 mat-dialog-title i18n>Details for {{ data.symbol }}</h1>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Date</mat-label>
|
||||
<input matInput name="date" readonly [(ngModel)]="data.date" />
|
||||
<input
|
||||
disabled
|
||||
matInput
|
||||
name="date"
|
||||
[matDatepicker]="date"
|
||||
[(ngModel)]="data.date"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="date">
|
||||
<ion-icon
|
||||
class="text-muted"
|
||||
matDatepickerToggleIcon
|
||||
name="calendar-clear-outline"
|
||||
></ion-icon>
|
||||
</mat-datepicker-toggle>
|
||||
<mat-datepicker #date disabled="true"></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
@ -13,13 +27,24 @@
|
||||
<input
|
||||
matInput
|
||||
name="marketPrice"
|
||||
readonly
|
||||
type="number"
|
||||
[(ngModel)]="data.marketPrice"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
title="Fetch market price"
|
||||
(click)="onFetchSymbolForDate()"
|
||||
>
|
||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="justify-content-end" mat-dialog-actions>
|
||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||
<button color="primary" i18n mat-flat-button (click)="onUpdate()">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
@ -15,6 +16,7 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatDatepickerModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
|
@ -3,5 +3,21 @@
|
||||
|
||||
.mat-dialog-content {
|
||||
max-height: unset;
|
||||
|
||||
.mat-form-field-appearance-outline {
|
||||
::ng-deep {
|
||||
.mat-form-field-suffix {
|
||||
top: -0.3rem;
|
||||
}
|
||||
|
||||
.mat-form-field-wrapper {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 130%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -43,6 +43,19 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
this.fetchAdminMarketData();
|
||||
}
|
||||
|
||||
public onGatherProfileDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.adminService
|
||||
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onGatherSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
@ -68,6 +81,13 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||
if (withRefresh) {
|
||||
this.fetchAdminMarketData();
|
||||
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -4,10 +4,11 @@
|
||||
<table class="gf-table w-100">
|
||||
<thead>
|
||||
<tr class="mat-header-row">
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
|
||||
<th class="mat-header-cell px-1 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -17,12 +18,13 @@
|
||||
class="cursor-pointer mat-row"
|
||||
(click)="setCurrentSymbol(item.symbol)"
|
||||
>
|
||||
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
{{ (item.date | date: defaultDateFormat) ?? '' }}
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
@ -40,14 +42,23 @@
|
||||
>
|
||||
Gather Data
|
||||
</button>
|
||||
<button
|
||||
i18n
|
||||
mat-menu-item
|
||||
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
||||
>
|
||||
Gather Profile Data
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
||||
<td></td>
|
||||
<td colspan="4">
|
||||
<td class="p-1" colspan="4">
|
||||
<gf-admin-market-data-detail
|
||||
[dataSource]="item.dataSource"
|
||||
[marketData]="marketDataDetails"
|
||||
[symbol]="item.symbol"
|
||||
(marketDataChanged)="onMarketDataChanged($event)"
|
||||
></gf-admin-market-data-detail>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -1,16 +1,25 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
DEFAULT_DATE_FORMAT,
|
||||
PROPERTY_COUPONS,
|
||||
PROPERTY_CURRENCIES,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import {
|
||||
differenceInSeconds,
|
||||
formatDistanceToNowStrict,
|
||||
isValid,
|
||||
parseISO
|
||||
} from 'date-fns';
|
||||
import { uniq } from 'lodash';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -20,10 +29,16 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './admin-overview.html'
|
||||
})
|
||||
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
public coupons: Coupon[];
|
||||
public customCurrencies: string[];
|
||||
public dataGatheringInProgress: boolean;
|
||||
public dataGatheringProgress: number;
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public exchangeRates: { label1: string; label2: string; value: number }[];
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionForSystemMessage: boolean;
|
||||
public hasPermissionToToggleReadOnlyMode: boolean;
|
||||
public info: InfoItem;
|
||||
public lastDataGathering: string;
|
||||
public transactionCount: number;
|
||||
public userCount: number;
|
||||
@ -40,23 +55,95 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.fetchAdminData();
|
||||
) {
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
this.info.globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.hasPermissionForSystemMessage = hasPermission(
|
||||
this.info.globalPermissions,
|
||||
permissions.enableSystemMessage
|
||||
);
|
||||
|
||||
this.hasPermissionToToggleReadOnlyMode = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.toggleReadOnlyMode
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.fetchAdminData();
|
||||
}
|
||||
|
||||
public formatDistanceToNow(aDateString: string) {
|
||||
if (aDateString) {
|
||||
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
|
||||
addSuffix: true
|
||||
});
|
||||
|
||||
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
|
||||
60
|
||||
? 'just now'
|
||||
: distanceString;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public onAddCoupon() {
|
||||
const coupons = [...this.coupons, { code: this.generateCouponCode(16) }];
|
||||
this.putCoupons(coupons);
|
||||
}
|
||||
|
||||
public onAddCurrency() {
|
||||
const currency = prompt('Please add a currency:');
|
||||
|
||||
if (currency) {
|
||||
const currencies = uniq([...this.customCurrencies, currency]);
|
||||
this.putCurrencies(currencies);
|
||||
}
|
||||
}
|
||||
|
||||
public onDeleteCoupon(aCouponCode: string) {
|
||||
const confirmation = confirm('Do you really want to delete this coupon?');
|
||||
|
||||
if (confirmation) {
|
||||
const coupons = this.coupons.filter((coupon) => {
|
||||
return coupon.code !== aCouponCode;
|
||||
});
|
||||
this.putCoupons(coupons);
|
||||
}
|
||||
}
|
||||
|
||||
public onDeleteCurrency(aCurrency: string) {
|
||||
const confirmation = confirm('Do you really want to delete this currency?');
|
||||
|
||||
if (confirmation) {
|
||||
const currencies = this.customCurrencies.filter((currency) => {
|
||||
return currency !== aCurrency;
|
||||
});
|
||||
this.putCurrencies(currencies);
|
||||
}
|
||||
}
|
||||
|
||||
public onDeleteSystemMessage() {
|
||||
this.putSystemMessage('');
|
||||
}
|
||||
|
||||
public onFlushCache() {
|
||||
this.cacheService
|
||||
.flush()
|
||||
@ -92,19 +179,16 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public formatDistanceToNow(aDateString: string) {
|
||||
if (aDateString) {
|
||||
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
|
||||
addSuffix: true
|
||||
});
|
||||
|
||||
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
|
||||
60
|
||||
? 'just now'
|
||||
: distanceString;
|
||||
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
|
||||
this.setReadOnlyMode(aEvent.checked);
|
||||
}
|
||||
|
||||
return '';
|
||||
public onSetSystemMessage() {
|
||||
const systemMessage = prompt('Please set your system message:');
|
||||
|
||||
if (systemMessage) {
|
||||
this.putSystemMessage(systemMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
@ -121,9 +205,12 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
dataGatheringProgress,
|
||||
exchangeRates,
|
||||
lastDataGathering,
|
||||
settings,
|
||||
transactionCount,
|
||||
userCount
|
||||
}) => {
|
||||
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||
this.dataGatheringProgress = dataGatheringProgress;
|
||||
this.exchangeRates = exchangeRates;
|
||||
|
||||
@ -147,4 +234,69 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private generateCouponCode(aLength: number) {
|
||||
const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ123456789';
|
||||
let couponCode = '';
|
||||
|
||||
for (let i = 0; i < aLength; i++) {
|
||||
couponCode += characters.charAt(
|
||||
Math.floor(Math.random() * characters.length)
|
||||
);
|
||||
}
|
||||
|
||||
return couponCode;
|
||||
}
|
||||
|
||||
private putCoupons(aCoupons: Coupon[]) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_COUPONS, {
|
||||
value: JSON.stringify(aCoupons)
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
private putCurrencies(aCurrencies: string[]) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_CURRENCIES, {
|
||||
value: JSON.stringify(aCurrencies)
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
private putSystemMessage(aSystemMessage: string) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_SYSTEM_MESSAGE, {
|
||||
value: aSystemMessage
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
private setReadOnlyMode(aValue: boolean) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_IS_READ_ONLY_MODE, {
|
||||
value: aValue ? 'true' : ''
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -3,10 +3,75 @@
|
||||
<div class="col">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-content>
|
||||
<div
|
||||
*ngIf="exchangeRates?.length > 0"
|
||||
class="align-items-start d-flex my-3"
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Count</div>
|
||||
<div class="w-50">{{ userCount }}</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>Transaction Count</div>
|
||||
<div class="w-50">
|
||||
<ng-container *ngIf="transactionCount">
|
||||
{{ transactionCount }} ({{ transactionCount / userCount | number
|
||||
: '1.2-2' }} <span i18n>per User</span>)
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>Data Gathering</div>
|
||||
<div class="w-50">
|
||||
<div>
|
||||
<ng-container *ngIf="lastDataGathering"
|
||||
>{{ lastDataGathering }}</ng-container
|
||||
>
|
||||
<ng-container *ngIf="dataGatheringInProgress" i18n
|
||||
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
|
||||
}})</ng-container
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 overflow-hidden">
|
||||
<div class="mb-2">
|
||||
<button
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onFlushCache()"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="close-circle-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Reset Data Gathering</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<button
|
||||
color="warn"
|
||||
mat-flat-button
|
||||
[disabled]="dataGatheringInProgress"
|
||||
(click)="onGatherMax()"
|
||||
>
|
||||
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
|
||||
<span i18n>Gather All Data</span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="mb-2 mr-2"
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
[disabled]="dataGatheringInProgress"
|
||||
(click)="onGatherProfileData()"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="cloud-download-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Gather Profile Data</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-start d-flex my-3">
|
||||
<div class="w-50" i18n>Exchange Rates</div>
|
||||
<div class="w-50">
|
||||
<table>
|
||||
@ -27,77 +92,89 @@
|
||||
></gf-value>
|
||||
</td>
|
||||
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
||||
<td>
|
||||
<button
|
||||
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
||||
class="mini-icon mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[disabled]="dataGatheringInProgress"
|
||||
(click)="onDeleteCurrency(exchangeRate.label2)"
|
||||
>
|
||||
<ion-icon name="trash-outline"></ion-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>Data Gathering</div>
|
||||
<div class="w-50">
|
||||
<div>
|
||||
<ng-container *ngIf="lastDataGathering"
|
||||
>{{ lastDataGathering }}</ng-container
|
||||
>
|
||||
<ng-container *ngIf="dataGatheringInProgress" i18n
|
||||
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
|
||||
}})</ng-container
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 overflow-hidden">
|
||||
<div class="mb-2">
|
||||
<div class="mt-2">
|
||||
<button
|
||||
class="mw-100"
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onFlushCache()"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="close-circle-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Reset Data Gathering</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<button
|
||||
class="mw-100"
|
||||
color="warn"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[disabled]="dataGatheringInProgress"
|
||||
(click)="onGatherMax()"
|
||||
(click)="onAddCurrency()"
|
||||
>
|
||||
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
|
||||
<span i18n>Gather All Data</span>
|
||||
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
||||
<span i18n>Add Currency</span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
||||
<div class="w-50" i18n>System Message</div>
|
||||
<div class="w-50">
|
||||
<div *ngIf="info.systemMessage">
|
||||
<span>{{ info.systemMessage }}</span>
|
||||
<button
|
||||
class="mb-2 mr-2 mw-100"
|
||||
class="mini-icon mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[disabled]="dataGatheringInProgress"
|
||||
(click)="onDeleteSystemMessage()"
|
||||
>
|
||||
<ion-icon name="trash-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
*ngIf="!info.systemMessage"
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onGatherProfileData()"
|
||||
(click)="onSetSystemMessage()"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="cloud-download-outline"
|
||||
name="information-circle-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Gather Profile Data</span>
|
||||
<span i18n>Set Message</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Count</div>
|
||||
<div class="w-50">{{ userCount }}</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>Transaction Count</div>
|
||||
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
||||
<div class="w-50" i18n>Read-only Mode</div>
|
||||
<div class="w-50">
|
||||
<ng-container *ngIf="transactionCount">
|
||||
{{ transactionCount }} ({{ transactionCount / userCount | number
|
||||
: '1.2-2' }} <span i18n>per User</span>)
|
||||
</ng-container>
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="info?.isReadOnlyMode"
|
||||
(change)="onReadOnlyModeChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForSubscription" class="d-flex my-3">
|
||||
<div class="w-50" i18n>Coupons</div>
|
||||
<div class="w-50">
|
||||
<div *ngFor="let coupon of coupons">
|
||||
<span>{{ coupon.code }}</span>
|
||||
<button
|
||||
class="mini-icon mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
(click)="onDeleteCoupon(coupon.code)"
|
||||
>
|
||||
<ion-icon name="trash-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button color="primary" mat-flat-button (click)="onAddCoupon()">
|
||||
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
||||
<span i18n>Add Coupon</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
@ -10,7 +11,13 @@ import { AdminOverviewComponent } from './admin-overview.component';
|
||||
@NgModule({
|
||||
declarations: [AdminOverviewComponent],
|
||||
exports: [],
|
||||
imports: [CommonModule, GfValueModule, MatButtonModule, MatCardModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatSlideToggleModule
|
||||
],
|
||||
providers: [CacheService],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -3,6 +3,12 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-button {
|
||||
&.mini-icon {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-flat-button {
|
||||
::ng-deep {
|
||||
.mat-button-wrapper {
|
||||
|
@ -5,7 +5,7 @@
|
||||
<table class="gf-table">
|
||||
<thead>
|
||||
<tr class="mat-header-row">
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
||||
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||
Registration
|
||||
|
@ -1,5 +1,6 @@
|
||||
<button
|
||||
*ngIf="deviceType === 'mobile'"
|
||||
class="mt-2"
|
||||
mat-button
|
||||
(click)="onClickCloseButton()"
|
||||
>
|
||||
|
@ -1,4 +1,7 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
<div class="align-items-center d-flex flex-row">
|
||||
<div class="h3 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
|
||||
<div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
|
||||
<div>
|
||||
<div class="h3 mb-0">
|
||||
<div class="h4 mb-0">
|
||||
<span class="mr-2">{{ fearAndGreedIndexText }}</span>
|
||||
<small class="text-muted"
|
||||
><strong>{{ fearAndGreedIndex }}</strong
|
||||
>/100</small
|
||||
>
|
||||
</div>
|
||||
<small class="d-block" i18n>Market Mood</small>
|
||||
<small class="d-block" i18n>Current Market Mood</small>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -270,7 +270,7 @@
|
||||
Sign In
|
||||
</button>
|
||||
<a
|
||||
*ngIf="currentRoute !== 'register'"
|
||||
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
|
||||
class="d-none d-sm-block"
|
||||
color="primary"
|
||||
i18n
|
||||
|
@ -1,10 +1,14 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import {
|
||||
RANGE,
|
||||
SettingsStorageService
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||
import { Position, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
@ -19,6 +23,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
})
|
||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public dateRange: DateRange;
|
||||
public dateRangeOptions = defaultDateRangeOptions;
|
||||
public deviceType: string;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public positions: Position[];
|
||||
@ -33,9 +38,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['positionDetailDialog'] && params['symbol']) {
|
||||
this.openPositionDialog({ symbol: params['symbol'] });
|
||||
}
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
@ -64,12 +80,48 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
this.update();
|
||||
}
|
||||
|
||||
public onChangeDateRange(aDateRange: DateRange) {
|
||||
this.dateRange = aDateRange;
|
||||
this.settingsStorageService.setSetting(RANGE, this.dateRange);
|
||||
this.update();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private openPositionDialog({ symbol }: { symbol: string }) {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.user = user;
|
||||
|
||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
deviceType: this.deviceType,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.positions = undefined;
|
||||
|
||||
this.dataService
|
||||
.fetchPositions({ range: this.dateRange })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
|
@ -1,4 +1,12 @@
|
||||
<div class="container justify-content-center pb-3 px-3">
|
||||
<div class="container justify-content-center p-3">
|
||||
<div class="mb-3 text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="dateRange"
|
||||
[isLoading]="positions === undefined"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
></gf-toggle>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
|
||||
<mat-card class="p-0">
|
||||
@ -6,6 +14,7 @@
|
||||
<gf-positions
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
[range]="dateRange"
|
||||
@ -17,8 +26,8 @@
|
||||
class="mt-3"
|
||||
i18n
|
||||
mat-button
|
||||
[routerLink]="['/portfolio', 'transactions']"
|
||||
>Manage Transactions...</a
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>Manage Activities...</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,7 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
|
||||
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
|
||||
import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
|
||||
@ -12,7 +14,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfPositionDetailDialogModule,
|
||||
GfPositionsModule,
|
||||
GfToggleModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
RouterModule
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -16,6 +18,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
public fearAndGreedIndex: number;
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public historicalData: HistoricalDataItem[];
|
||||
public isLoading = true;
|
||||
public user: User;
|
||||
|
||||
@ -46,11 +49,19 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
this.dataService
|
||||
.fetchSymbolItem({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
includeHistoricalData: true,
|
||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketPrice }) => {
|
||||
.subscribe(({ historicalData, marketPrice }) => {
|
||||
this.fearAndGreedIndex = marketPrice;
|
||||
this.historicalData = [
|
||||
...historicalData,
|
||||
{
|
||||
date: resetHours(new Date()).toISOString(),
|
||||
value: marketPrice
|
||||
}
|
||||
];
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
@ -9,17 +9,26 @@
|
||||
w-100
|
||||
"
|
||||
>
|
||||
<div class="row w-100">
|
||||
<div class="no-gutters row w-100">
|
||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||
<mat-card class="h-100">
|
||||
<mat-card-content>
|
||||
<div class="mb-2 text-center text-muted">
|
||||
<small i18n>Last 30 Days</small>
|
||||
</div>
|
||||
<gf-line-chart
|
||||
class="mb-5"
|
||||
yMax="100"
|
||||
yMaxLabel="Greed"
|
||||
yMin="0"
|
||||
yMinLabel="Fear"
|
||||
[historicalDataItems]="historicalData"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
></gf-line-chart>
|
||||
<gf-fear-and-greed-index
|
||||
class="d-flex justify-content-center"
|
||||
[fearAndGreedIndex]="fearAndGreedIndex"
|
||||
[hidden]="isLoading"
|
||||
></gf-fear-and-greed-index>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
|
||||
import { HomeMarketComponent } from './home-market.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [HomeMarketComponent],
|
||||
exports: [],
|
||||
imports: [CommonModule, GfFearAndGreedIndexModule, MatCardModule],
|
||||
imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user