Compare commits

...

79 Commits

Author SHA1 Message Date
e093041184 Release 1.196.0 (#1281) 2022-09-22 21:05:05 +02:00
8f2caa508a Feature/extend landing page (#1279)
* Extend landing page

* Update changelog
2022-09-22 20:52:46 +02:00
862f670ccf Feature/setup italiano (#1276)
* Setup italiano

* Update changelog
2022-09-22 20:52:03 +02:00
54bf4c7a43 Update messages.it.xlf (#1280) 2022-09-22 20:51:31 +02:00
c0ace51ee9 Release 1.195.0 (#1277) 2022-09-20 20:23:41 +02:00
b1b5689242 Feature/improve performance of chart calculation (#1271)
* Improve performance chart calculation

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>

* Update changelog

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>

* Improve chart tooltip of benchmark comparator

* Update changelog

Co-authored-by: gizmodus <11334553+gizmodus@users.noreply.github.com>
2022-09-20 20:22:01 +02:00
b68cdaf8ea Add issue template (#1275) 2022-09-19 21:22:54 +02:00
b387a80a0d Add bullet (#1270) 2022-09-17 21:29:37 +02:00
6e4660295a Release 1.194.0 (#1269) 2022-09-17 21:26:08 +02:00
d4c3a9d1e8 Feature/add percentage visualization of the current filter (#1268)
* Add percentage visualization of the active filter

* Update changelog
2022-09-17 21:24:09 +02:00
263f6b32f2 Bugfix/fix performance chart calculation (#1267)
* Respect end date in performance chart calculation

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>

* Update changelog

Co-Authored-By: gizmodus <11334553+gizmodus@users.noreply.github.com>
2022-09-17 08:33:04 +02:00
637f31ae3b Add instruction for NODE_ENV: production (#1266) 2022-09-17 08:31:56 +02:00
547e27c7a1 Feature/set node env in docker compose files (#1261)
* Set NODE_ENV to production

* Update changelog
2022-09-15 17:20:03 +02:00
f10dc176f2 Feature/clean up german localization (#1260)
* Clean up German localization

* Set up Italian

* Update changelog
2022-09-15 16:58:00 +02:00
0a966e46cd Improve translation (#1258) 2022-09-15 10:37:07 +02:00
4f281d25e1 Release 1.193.0 (#1257) 2022-09-14 20:11:47 +02:00
aaba8c35c2 Feature/extend pricing page with referral section (#1256)
* Add referral section

* Update changelog
2022-09-14 20:10:35 +02:00
7d27cb3398 Bugfix/use base currency in exchange rate service instead of usd (#1255)
* Change from USD to base currency

* Update changelog
2022-09-14 19:56:18 +02:00
91678028b5 Bugfix/fix missing assets during local development (#1253)
* Extend setup for development (missing assets)

* Update changelog
2022-09-14 19:26:59 +02:00
5e3cac8ac9 Feature/sort benchmarks by name (#1250)
* Sort benchmarks by name

* Update changelog
2022-09-12 13:34:06 +02:00
33f20b6b48 Release 1.192.0 (#1249) 2022-09-11 09:21:48 +02:00
e4fd255dd7 Bugfix/improve loading indicator of benchmark comparator (#1247)
* Improve loading indicator

* Update changelog
2022-09-11 09:20:15 +02:00
e320aa91f7 Feature/simplify benchmark configuration (#1248)
* Simplify benchmark configuration

* Update changelog
2022-09-11 09:19:50 +02:00
0fcfa6c1bd Bugfix/improve error handling in benchmark calculation (#1246)
* Improve error handling

* Update changelog
2022-09-10 18:58:31 +02:00
42d32ed652 Feature/upgrade yahoo finance2 to version 2.3.6 (#1245)
* Upgrade yahoo-finance2 to version 2.3.6

* Update changelog
2022-09-10 18:57:41 +02:00
21b4b0ef24 Release 1.191.0 (#1244) 2022-09-10 16:13:22 +02:00
4f8fe83a16 Feature/clean up user database schema (#1242)
* Clean up user database schema

* Update changelog
2022-09-10 16:11:49 +02:00
980ad1028c Feature/allow date range change for demo user (#1243)
* Allow date range change

* Update changelog
2022-09-10 16:10:57 +02:00
0d5bc3f51b Release 1.190.0 (#1241) 2022-09-10 13:36:39 +02:00
aece76d98f Feature/add date range component to benchmark comparator (#1240)
* Add date range component

* Update changelog
2022-09-10 13:33:37 +02:00
fc4bb71184 Feature/migrate date range setting to user settings (#1239)
* Migrate date range to user settings

* Refactor currency and view mode in the user user settings

* Update changelog
2022-09-10 11:38:06 +02:00
20bc7ef99c Feature/improve benchmark comparator on mobile (#1238)
* Improve layout for mobile

* Update changelog
2022-09-09 19:44:36 +02:00
7a733ae49b Release 1.189.0 (#1237) 2022-09-08 21:06:37 +02:00
376ce88492 Bugfix/fix benchmark chart (#1236)
* Fix benchmark chart

* Distinguish between currency and unit in tooltip

* Update changelog
2022-09-08 21:05:19 +02:00
c4d83aabe7 Setup tests (#1234) 2022-09-08 17:32:55 +02:00
d4e2cec77e Release 1.188.0 (#1233) 2022-09-06 20:40:59 +02:00
75db7bf79a Bugfix/fix asset profile details dialog (#1232)
* Fix dialog for assets without (first) activity

* Update changelog
2022-09-06 20:39:45 +02:00
3ad99c9991 Add data management (#1230)
* Add data management for benchmarks
2022-09-06 20:39:27 +02:00
00e402d286 Add translations 2022-09-04 09:48:18 +02:00
4ac0484025 Update changelog 2022-09-04 09:45:29 +02:00
75d61bff6d Setup benchmark comparator 2022-09-04 09:45:22 +02:00
0de28d733e Release 1.187.0 (#1227) 2022-09-03 21:42:23 +02:00
3b2f13850c Feature/improve chart calculation (#1226)
* Improve chart calculation

* Update changelog
2022-09-03 21:41:06 +02:00
0cc42ffd7c Add end date parameter (#1224)
* Add end date parameter
2022-09-03 21:28:57 +02:00
3ccb812ac3 Release 1.186.2 (#1223) 2022-09-03 11:37:14 +02:00
0a8549db3e Release 1.186.1 (#1222) 2022-09-03 11:19:31 +02:00
c95e90ff31 Release 1.186.0 (#1221) 2022-09-03 10:28:20 +02:00
b59af0d864 Feature/upgrade nx to version 14.6.4 (#1220)
* Upgrade nx and angular

* Update changelog
2022-09-03 10:26:56 +02:00
408bdbd187 Bugfix/fix GitHub contributors count (#1219)
* Fix GitHub contributors count

* Update changelog
2022-09-03 10:06:16 +02:00
a3bfa46fb0 Feature/remove alias from user (#1218)
* Remove alias

* Update changelog
2022-09-03 09:47:18 +02:00
8cb1b3f925 Feature/decrease rate limiter duration (#1217)
* Decrease rate limiter duration

* Update changelog
2022-09-03 09:09:57 +02:00
15c650f951 Bugfix/fix blog post link (#1216)
* Fix link

* Update sitemap.xml
2022-09-03 09:09:37 +02:00
c198bd78da Add architectures (#1205) 2022-09-03 08:32:01 +02:00
35963580bc Bugfix/improve error handling in portfolio calculations (#1215)
* Improve error handling

* Update changelog
2022-09-03 08:31:47 +02:00
cf2c5bad02 Bugfix/change environment variables redis host and port to mandatory (#1211)
* Change REDIS_HOST and REDIS_PORT to mandatory

* Update changelog
2022-09-01 15:35:37 +02:00
f332aea9b4 Release 1.185.0 (#1207) 2022-08-30 20:53:26 +02:00
7a9fd18407 Feature/improve markets overview (#1206)
* Improve markets overview

* Update changelog
2022-08-30 20:52:13 +02:00
ca08d3154a Bugfix/disable language selector for demo user (#1204)
* Disable language selector for demo user

* Update changelog
2022-08-28 21:11:27 +02:00
01d4ae8757 Feature/move build pipeline from travis to GitHub actions (#1203)
* Remove travis configurations

* Update changelog
2022-08-28 21:10:25 +02:00
43ce2786c1 Fetch all history for all tags and branches (#1202) 2022-08-28 11:01:54 +02:00
de2092c4d2 Release 1.184.2 (#1201) 2022-08-28 10:09:59 +02:00
435a180e54 Release 1.184.1 (#1200) 2022-08-28 09:44:09 +02:00
0ad30ffabe Add version to docker tag (#1199) 2022-08-28 09:43:06 +02:00
0cc5e558f1 Release 1.184.0 (#1198) 2022-08-28 09:12:03 +02:00
63b183cc6f Feature/finalize GitHub action to create arm64 docker image (#1197)
* Change order and refactor secrets

* Update changelog
2022-08-28 09:10:18 +02:00
10bae24c5c Build arm64 docker image and use github actions (#1134)
* Setup build pipeline for arm64 docker images using GitHub Actions
2022-08-27 14:37:22 +02:00
0e29278e96 Feature/add alias to access (#1193)
* Add alias to access

* Update changelog
2022-08-27 11:29:09 +02:00
2db46e5bbf Feature/support localization in date fns (#1195)
* Add locale to date-fns (formatDistanceToNow)

* Update changelog
2022-08-27 10:54:59 +02:00
e757e90e5a Improve translation (#1196) 2022-08-27 10:42:54 +02:00
184ddc6209 Bugfix/fix missing assets in local development (#1194)
* Fix missing assets in local development

* Update changelog
2022-08-27 10:29:50 +02:00
e3662a143c Improve localization (#1191)
* Improve localization

* Update changelog
2022-08-26 20:10:59 +02:00
25afd7e07b Remove database:baseline (#1192) 2022-08-26 18:08:05 +02:00
7fceaa1350 Add language to urls (#1187) 2022-08-26 17:52:42 +02:00
7c8530483c Release 1.183.0 (#1189) 2022-08-24 20:55:38 +02:00
539d3ff754 Feature/add asset sub class filter (#1188)
* Add asset sub class filter

* Update changelog
2022-08-24 20:53:50 +02:00
9d28b63da6 Release 1.182.0 (#1186) 2022-08-23 21:40:57 +02:00
24abbd85e6 Feature/move asset profile details to dialog (#1185)
* Introduce asset profile dialog

* Update changelog
2022-08-23 21:39:04 +02:00
b6f395fd3b Feature/improve i18n (#1183)
* Improve i18n

* Update changelog
2022-08-22 19:57:48 +02:00
04d894cf88 Release 1.181.2 (#1182) 2022-08-21 21:42:05 +02:00
146 changed files with 7551 additions and 2384 deletions

37
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,37 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: ''
assignees: ''
---
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
Steps to reproduce the behavior:
1.
2.
3.
4.
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**Logs**
<!-- If applicable, add logs to help explain your problem. -->
**Environment (please complete the following information):**
- Ghostfolio Version [e.g. 1.194.0]
- Browser [e.g. chrome]
- OS
**Additional context**
<!-- Add any other context about the problem here. -->

36
.github/workflows/build-code.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: Build code
on:
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node_version:
- 16
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node_version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Check formatting
run: yarn format:check
- name: Execute tests
run: yarn test
- name: Build application
run: yarn build:all

49
.github/workflows/docker-image.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: Docker image CD
on:
push:
tags:
- '*.*.*'
pull_request:
branches:
- 'main'
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: ghostfolio/ghostfolio
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.output.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -1,30 +0,0 @@
language: node_js
git:
depth: false
node_js:
- 16
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

View File

@ -5,7 +5,180 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.181.1 - 21.08.2022 ## 1.196.0 - 22.09.2022
### Added
- Set up the language localization for Italiano (`it`)
- Extended the landing page
## 1.195.0 - 20.09.2022
### Changed
- Improved the algorithm of the performance chart calculation
### Fixed
- Improved the chart tooltip of the benchmark comparator
## 1.194.0 - 17.09.2022
### Added
- Added `NODE_ENV: production` to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
- Visualized the percentage of the active filter on the allocations page
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Respected the end date in the performance chart calculation
### Todo
- Set `NODE_ENV: production` as in [docker-compose.yml](https://github.com/ghostfolio/ghostfolio/blob/main/docker/docker-compose.yml)
## 1.193.0 - 14.09.2022
### Changed
- Sorted the benchmarks by name
- Extended the pricing page
### Fixed
- Fixed the calculations of the exchange rate service by changing `USD` to the base currency
- Fixed the missing assets during the local development
## 1.192.0 - 11.09.2022
### Changed
- Simplified the configuration of the benchmarks: `symbolProfileId` instead of `dataSource` and `symbol`
- Upgraded `yahoo-finance2` from version `2.3.3` to `2.3.6`
### Fixed
- Improved the loading indicator of the benchmark comparator
- Improved the error handling in the benchmark calculation
## 1.191.0 - 10.09.2022
### Changed
- Removed the `currency` and `viewMode` from the `User` database schema
### Fixed
- Allowed the date range change for the demo user
## 1.190.0 - 10.09.2022
### Added
- Added the date range component to the benchmark comparator
### Changed
- Improved the mobile layout of the benchmark comparator
- Migrated the date range setting from the locale storage to the user settings
- Refactored the `currency` and `view mode` in the user settings
## 1.189.0 - 08.09.2022
### Changed
- Distinguished between currency and unit in the chart tooltip
### Fixed
- Fixed the benchmark chart in the benchmark comparator (experimental)
## 1.188.0 - 06.09.2022
### Added
- Added a benchmark comparator (experimental)
### Fixed
- Improved the asset profile details dialog for assets without a (first) activity in the admin control panel
## 1.187.0 - 03.09.2022
### Added
- Supported units in the line chart component
- Added a new chart calculation engine (experimental)
## 1.186.2 - 03.09.2022
### Changed
- Decreased the rate limiter duration of queue jobs from 5 to 4 seconds
- Removed the alias from the `User` database schema
- Upgraded `angular` from version `14.1.0` to `14.2.0`
- Upgraded `Nx` from version `14.5.1` to `14.6.4`
### Fixed
- Fixed the environment variables `REDIS_HOST`, `REDIS_PASSWORD` and `REDIS_PORT` in the Redis configuration
- Handled errors in the portfolio calculation if there is no internet connection
- Fixed the _GitHub_ contributors count on the about page
## 1.185.0 - 30.08.2022
### Added
- Added a skeleton loader to the market mood component in the markets overview
### Changed
- Moved the build pipeline from _Travis_ to _GitHub Actions_
- Increased the caching of the benchmarks
### Fixed
- Disabled the language selector for the demo user
## 1.184.2 - 28.08.2022
### Added
- Added the alias to the `Access` database schema
- Added support for translated time distances
- Added a _GitHub Action_ to create an `arm64` docker image
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed the missing assets during the local development
## 1.183.0 - 24.08.2022
### Added
- Added a filter by asset sub class for the asset profiles in the admin control
### Changed
- Improved the language localization for German (`de`)
## 1.182.0 - 23.08.2022
### Changed
- Improved the language localization for German (`de`)
- Extended and made the columns of the asset profiles sortable in the admin control
- Moved the asset profile details in the admin control panel to a dialog
## 1.181.2 - 21.08.2022
### Added ### Added
@ -29,7 +202,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow - Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow
- Set up language localization for German (`de`) - Set up the language localization for German (`de`)
- Resolved the feature graphic of the blog post - Resolved the feature graphic of the blog post
### Changed ### Changed

View File

@ -1,7 +1,6 @@
FROM node:16-alpine as builder FROM --platform=$BUILDPLATFORM node:16-slim as builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
# Only add basic files without the application itself to avoid rebuilding # Only add basic files without the application itself to avoid rebuilding
@ -10,9 +9,16 @@ COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE COPY ./LICENSE LICENSE
COPY ./package.json package.json COPY ./package.json package.json
COPY ./yarn.lock yarn.lock COPY ./yarn.lock yarn.lock
COPY ./.yarnrc .yarnrc
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apk add --no-cache python3 g++ make openssl git RUN apt update && apt install -y \
git \
g++ \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install RUN yarn install
# See https://github.com/nrwl/nx/issues/6586 for further details # See https://github.com/nrwl/nx/issues/6586 for further details
@ -45,7 +51,11 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings RUN yarn database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:16-alpine FROM node:16-slim
RUN apt update && apt install -y \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE 3333 EXPOSE 3333

View File

@ -12,13 +12,11 @@
<strong>Open Source Wealth Management Software</strong> <strong>Open Source Wealth Management Software</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/faq"><strong>FAQ</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> <a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/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>
<p> <p>
<a href="#contributing"> <a href="#contributing">
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a> <img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow"> <a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a> <img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</p> </p>
@ -33,7 +31,7 @@
## Ghostfolio Premium ## Ghostfolio Premium
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. Our official **[Ghostfolio Premium](https://ghostfol.io/en/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 [Self-hosting](#self-hosting) section. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
@ -81,6 +79,8 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## Self-hosting ## Self-hosting
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
### Supported Environment Variables ### Supported Environment Variables
| Name | Default Value | Description | | Name | Default Value | Description |
@ -94,9 +94,9 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | `localhost` | The host where _Redis_ is running | | `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ | | `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | `6379` | The port where _Redis_ is running | | `REDIS_PORT` | | The port where _Redis_ is running |
### Run with Docker Compose ### Run with Docker Compose
@ -153,6 +153,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
1. Run `yarn build:dev` to build the source code including the assets
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data 1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start the server and the client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))
@ -259,7 +260,7 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**. If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
## License ## License

View File

@ -136,6 +136,10 @@
"baseHref": "/en/", "baseHref": "/en/",
"localize": ["en"] "localize": ["en"]
}, },
"development-it": {
"baseHref": "/it/",
"localize": ["it"]
},
"production": { "production": {
"fileReplacements": [ "fileReplacements": [
{ {
@ -180,6 +184,9 @@
"development-en": { "development-en": {
"browserTarget": "client:build:development-en" "browserTarget": "client:build:development-en"
}, },
"development-it": {
"browserTarget": "client:build:development-it"
},
"production": { "production": {
"browserTarget": "client:build:production" "browserTarget": "client:build:production"
} }
@ -191,7 +198,7 @@
"browserTarget": "client:build", "browserTarget": "client:build",
"includeContext": true, "includeContext": true,
"outputPath": "src/locales", "outputPath": "src/locales",
"targetFiles": ["messages.de.xlf"] "targetFiles": ["messages.de.xlf", "messages.it.xlf"]
} }
}, },
"lint": { "lint": {
@ -214,6 +221,10 @@
"de": { "de": {
"baseHref": "/de/", "baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf" "translation": "apps/client/src/locales/messages.de.xlf"
},
"it": {
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
} }
}, },
"sourceLocale": "en" "sourceLocale": "en"

View File

@ -1,3 +1,4 @@
/* eslint-disable */
export default { export default {
displayName: 'api', displayName: 'api',

View File

@ -42,14 +42,16 @@ export class AccessController {
return accessesWithGranteeUser.map((access) => { return accessesWithGranteeUser.map((access) => {
if (access.GranteeUser) { if (access.GranteeUser) {
return { return {
granteeAlias: access.GranteeUser?.alias, alias: access.alias,
grantee: access.GranteeUser?.id,
id: access.id, id: access.id,
type: 'RESTRICTED_VIEW' type: 'RESTRICTED_VIEW'
}; };
} }
return { return {
granteeAlias: 'Public', alias: access.alias,
grantee: 'Public',
id: access.id, id: access.id,
type: 'PUBLIC' type: 'PUBLIC'
}; };
@ -71,6 +73,10 @@ export class AccessController {
} }
return this.accessService.createAccess({ return this.accessService.createAccess({
alias: data.alias || undefined,
GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
User: { connect: { id: this.request.user.id } } User: { connect: { id: this.request.user.id } }
}); });
} }

View File

@ -1 +1,11 @@
export class CreateAccessDto {} import { IsOptional, IsString } from 'class-validator';
export class CreateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsOptional()
@IsString()
granteeUserId?: string;
}

View File

@ -8,7 +8,8 @@ import {
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails AdminMarketDataDetails,
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -22,6 +23,7 @@ import {
Param, Param,
Post, Post,
Put, Put,
Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -226,7 +228,9 @@ export class AdminController {
@Get('market-data') @Get('market-data')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getMarketData(): Promise<AdminMarketData> { public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string
): Promise<AdminMarketData> {
if ( if (
!hasPermission( !hasPermission(
this.request.user.permissions, this.request.user.permissions,
@ -239,7 +243,18 @@ export class AdminController {
); );
} }
return this.adminService.getMarketData(); const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const filters: Filter[] = [
...assetSubClasses.map((assetSubClass) => {
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
return this.adminService.getMarketData(filters);
} }
@Get('market-data/:dataSource/:symbol') @Get('market-data/:dataSource/:symbol')

View File

@ -11,11 +11,13 @@ import {
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
Filter,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client'; import { AssetSubClass, Prisma, Property } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
@ -63,14 +65,27 @@ export class AdminService {
}; };
} }
public async getMarketData(): Promise<AdminMarketData> { public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
const where: Prisma.SymbolProfileWhereInput = {};
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
(filter) => {
return filter.type;
}
);
const marketData = await this.prismaService.marketData.groupBy({ const marketData = await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['dataSource', 'symbol'] by: ['dataSource', 'symbol']
}); });
const currencyPairsToGather: AdminMarketDataItem[] = let currencyPairsToGather: AdminMarketDataItem[] = [];
this.exchangeRateDataService
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} else {
currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs() .getCurrencyPairs()
.map(({ dataSource, symbol }) => { .map(({ dataSource, symbol }) => {
const marketDataItemCount = const marketDataItemCount =
@ -84,17 +99,24 @@ export class AdminService {
return { return {
dataSource, dataSource,
marketDataItemCount, marketDataItemCount,
symbol symbol,
countriesCount: 0,
sectorsCount: 0
}; };
}); });
}
const symbolProfilesToGather: AdminMarketDataItem[] = ( const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({ await this.prismaService.symbolProfile.findMany({
where,
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { select: {
_count: { _count: {
select: { Order: true } select: { Order: true }
}, },
assetClass: true,
assetSubClass: true,
countries: true,
dataSource: true, dataSource: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
@ -102,10 +124,14 @@ export class AdminService {
take: 1 take: 1
}, },
scraperConfiguration: true, scraperConfiguration: true,
sectors: true,
symbol: true symbol: true
} }
}) })
).map((symbolProfile) => { ).map((symbolProfile) => {
const countriesCount = symbolProfile.countries
? Object.keys(symbolProfile.countries).length
: 0;
const marketDataItemCount = const marketDataItemCount =
marketData.find((marketDataItem) => { marketData.find((marketDataItem) => {
return ( return (
@ -113,10 +139,17 @@ export class AdminService {
marketDataItem.symbol === symbolProfile.symbol marketDataItem.symbol === symbolProfile.symbol
); );
})?._count ?? 0; })?._count ?? 0;
const sectorsCount = symbolProfile.sectors
? Object.keys(symbolProfile.sectors).length
: 0;
return { return {
countriesCount,
marketDataItemCount, marketDataItemCount,
sectorsCount,
activityCount: symbolProfile._count.Order, activityCount: symbolProfile._count.Order,
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
dataSource: symbolProfile.dataSource, dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date, date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol symbol: symbolProfile.symbol

View File

@ -54,7 +54,7 @@ export class WebAuthService {
rpName: 'Ghostfolio', rpName: 'Ghostfolio',
rpID: this.rpID, rpID: this.rpID,
userID: user.id, userID: user.id,
userName: user.alias, userName: '',
timeout: 60000, timeout: 60000,
attestationType: 'indirect', attestationType: 'indirect',
authenticatorSelection: { authenticatorSelection: {

View File

@ -1,30 +1,48 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import {
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; BenchmarkMarketDataDetails,
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces'; BenchmarkResponse
import { Controller, Get, UseInterceptors } from '@nestjs/common'; } from '@ghostfolio/common/interfaces';
import {
Controller,
Get,
Param,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { BenchmarkService } from './benchmark.service'; import { BenchmarkService } from './benchmark.service';
@Controller('benchmark') @Controller('benchmark')
export class BenchmarkController { export class BenchmarkController {
public constructor( public constructor(private readonly benchmarkService: BenchmarkService) {}
private readonly benchmarkService: BenchmarkService,
private readonly propertyService: PropertyService
) {}
@Get() @Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> { public async getBenchmark(): Promise<BenchmarkResponse> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
return { return {
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets) benchmarks: await this.benchmarkService.getBenchmarks()
}; };
} }
@Get(':dataSource/:symbol/:startDateString')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'))
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
});
}
} }

View File

@ -1,4 +1,5 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
@ -18,6 +19,7 @@ import { BenchmarkService } from './benchmark.service';
MarketDataModule, MarketDataModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [BenchmarkService] providers: [BenchmarkService]

View File

@ -0,0 +1,15 @@
import { BenchmarkService } from './benchmark.service';
describe('BenchmarkService', () => {
let benchmarkService: BenchmarkService;
beforeAll(async () => {
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
});
it('calculateChangeInPercentage', async () => {
expect(benchmarkService.calculateChangeInPercentage(1, 2)).toEqual(1);
expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0);
expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5);
});
});

View File

@ -1,10 +1,24 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces'; import {
MAX_CHART_ITEMS,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
BenchmarkMarketDataDetails,
BenchmarkResponse,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { format } from 'date-fns';
import ms from 'ms';
@Injectable() @Injectable()
export class BenchmarkService { export class BenchmarkService {
@ -13,62 +27,74 @@ export class BenchmarkService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService,
private readonly symbolService: SymbolService
) {} ) {}
public async getBenchmarks( public calculateChangeInPercentage(baseValue: number, currentValue: number) {
benchmarkAssets: UniqueAsset[] if (baseValue && currentValue) {
): Promise<BenchmarkResponse['benchmarks']> { return new Big(currentValue).div(baseValue).minus(1).toNumber();
}
return 0;
}
public async getBenchmarks({ useCache = true } = {}): Promise<
BenchmarkResponse['benchmarks']
> {
let benchmarks: BenchmarkResponse['benchmarks']; let benchmarks: BenchmarkResponse['benchmarks'];
try { if (useCache) {
benchmarks = JSON.parse( try {
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS) benchmarks = JSON.parse(
); await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (benchmarks) { if (benchmarks) {
return benchmarks; return benchmarks;
} }
} catch {} } catch {}
}
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
const promises: Promise<number>[] = []; const promises: Promise<number>[] = [];
const [quotes, assetProfiles] = await Promise.all([ const quotes = await this.dataProviderService.getQuotes(
this.dataProviderService.getQuotes(benchmarkAssets), benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
this.symbolProfileService.getSymbolProfiles(benchmarkAssets) return { dataSource, symbol };
]); })
);
for (const benchmarkAsset of benchmarkAssets) { for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promises.push(this.marketDataService.getMax(benchmarkAsset)); promises.push(this.marketDataService.getMax({ dataSource, symbol }));
} }
const allTimeHighs = await Promise.all(promises); const allTimeHighs = await Promise.all(promises);
benchmarks = allTimeHighs.map((allTimeHigh, index) => { benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol]; const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
let performancePercentFromAllTimeHigh = new Big(0); let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh) { if (allTimeHigh && marketPrice) {
performancePercentFromAllTimeHigh = new Big(marketPrice) performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
.div(allTimeHigh) allTimeHigh,
.minus(1); marketPrice
);
} }
return { return {
marketCondition: this.getMarketCondition( marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh performancePercentFromAllTimeHigh
), ),
name: assetProfiles.find(({ dataSource, symbol }) => { name: benchmarkAssetProfiles[index].name,
return (
dataSource === benchmarkAssets[index].dataSource &&
symbol === benchmarkAssets[index].symbol
);
})?.name,
performances: { performances: {
allTimeHigh: { allTimeHigh: {
performancePercent: performancePercentFromAllTimeHigh.toNumber() performancePercent: performancePercentFromAllTimeHigh
} }
} }
}; };
@ -76,13 +102,99 @@ export class BenchmarkService {
await this.redisCacheService.set( await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS, this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks) JSON.stringify(benchmarks),
ms('4 hours') / 1000
); );
return benchmarks; return benchmarks;
} }
private getMarketCondition(aPerformanceInPercent: Big) { public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; const symbolProfileIds: string[] = (
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
symbolProfileId: string;
}[]) ?? []
).map(({ symbolProfileId }) => {
return symbolProfileId;
});
const assetProfiles =
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
return assetProfiles
.map(({ dataSource, id, name, symbol }) => {
return {
dataSource,
id,
name,
symbol
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
public async getMarketDataBySymbol({
dataSource,
startDate,
symbol
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
gte: startDate
}
}
})
]);
const step = Math.round(
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
);
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
return {
marketData: [
...marketDataItems
.filter((marketDataItem, index) => {
return index % step === 0;
})
.map((marketDataItem) => {
return {
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice
) * 100
};
}),
{
date: format(new Date(), DATE_FORMAT),
value:
this.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice
) * 100
}
]
};
}
private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
} }
} }

View File

@ -4,22 +4,41 @@ import * as path from 'path';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Injectable, NestMiddleware } from '@nestjs/common'; import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
@Injectable() @Injectable()
export class FrontendMiddleware implements NestMiddleware { export class FrontendMiddleware implements NestMiddleware {
public indexHtmlDe = fs.readFileSync( public indexHtmlDe = '';
this.getPathOfIndexHtmlFile('de'), public indexHtmlEn = '';
'utf8' public indexHtmlIt = '';
); public isProduction: boolean;
public indexHtmlEn = fs.readFileSync(
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
'utf8'
);
public constructor( public constructor(
private readonly configService: ConfigService,
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) {} ) {
const NODE_ENV =
this.configService.get<'development' | 'production'>('NODE_ENV') ??
'development';
this.isProduction = NODE_ENV === 'production';
try {
this.indexHtmlDe = fs.readFileSync(
this.getPathOfIndexHtmlFile('de'),
'utf8'
);
this.indexHtmlEn = fs.readFileSync(
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
'utf8'
);
this.indexHtmlIt = fs.readFileSync(
this.getPathOfIndexHtmlFile('it'),
'utf8'
);
} catch {}
}
public use(req: Request, res: Response, next: NextFunction) { public use(req: Request, res: Response, next: NextFunction) {
let featureGraphicPath = 'assets/cover.png'; let featureGraphicPath = 'assets/cover.png';
@ -31,7 +50,11 @@ export class FrontendMiddleware implements NestMiddleware {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg'; featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
} }
if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) { if (
req.path.startsWith('/api/') ||
this.isFileRequest(req.url) ||
!this.isProduction
) {
// Skip // Skip
next(); next();
} else if (req.path === '/de' || req.path.startsWith('/de/')) { } else if (req.path === '/de' || req.path.startsWith('/de/')) {
@ -43,6 +66,15 @@ export class FrontendMiddleware implements NestMiddleware {
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );
} else if (req.path === '/it' || req.path.startsWith('/it/')) {
res.send(
this.interpolate(this.indexHtmlIt, {
featureGraphicPath,
languageCode: 'it',
path: req.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else { } else {
res.send( res.send(
this.interpolate(this.indexHtmlEn, { this.interpolate(this.indexHtmlEn, {

View File

@ -1,5 +1,6 @@
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get } from '@nestjs/common'; import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { InfoService } from './info.service'; import { InfoService } from './info.service';
@ -8,6 +9,7 @@ export class InfoController {
public constructor(private readonly infoService: InfoService) {} public constructor(private readonly infoService: InfoService) {}
@Get() @Get()
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getInfo(): Promise<InfoItem> { public async getInfo(): Promise<InfoItem> {
return this.infoService.get(); return this.infoService.get();
} }

View File

@ -1,3 +1,4 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@ -16,6 +17,7 @@ import { InfoService } from './info.service';
@Module({ @Module({
controllers: [InfoController], controllers: [InfoController],
imports: [ imports: [
BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,

View File

@ -1,6 +1,6 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -13,7 +13,10 @@ import {
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper'; import {
encodeDataSource,
extractNumberFromString
} from '@ghostfolio/common/helper';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
@ -21,6 +24,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent'; import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
@Injectable() @Injectable()
@ -28,9 +32,9 @@ export class InfoService {
private static CACHE_KEY_STATISTICS = 'STATISTICS'; private static CACHE_KEY_STATISTICS = 'STATISTICS';
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -106,6 +110,7 @@ export class InfoService {
platforms, platforms,
systemMessage, systemMessage,
baseCurrency: this.configurationService.get('BASE_CURRENCY'), baseCurrency: this.configurationService.get('BASE_CURRENCY'),
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
currencies: this.exchangeRateDataService.getCurrencies(), currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
statistics: await this.getStatistics(), statistics: await this.getStatistics(),
@ -143,17 +148,21 @@ export class InfoService {
private async countGitHubContributors(): Promise<number> { private async countGitHubContributors(): Promise<number> {
try { try {
const get = bent( const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`, 'https://github.com/ghostfolio/ghostfolio',
'GET', 'GET',
'json', 'string',
200, 200,
{ {}
'User-Agent': 'request'
}
); );
const contributors = await get(); const html = await get();
return contributors?.length; const $ = cheerio.load(html);
return extractNumberFromString(
$(
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
).text()
);
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService');

View File

@ -103,7 +103,7 @@ export class OrderController {
impersonationId, impersonationId,
this.request.user.id this.request.user.id
); );
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
let activities = await this.orderService.getOrders({ let activities = await this.orderService.getOrders({
filters, filters,

View File

@ -20,7 +20,7 @@ import {
min, min,
set set
} from 'date-fns'; } from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash'; import { first, flatten, isNumber, last, sortBy } from 'lodash';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface'; import { CurrentPositions } from './interfaces/current-positions.interface';
@ -167,13 +167,146 @@ export class PortfolioCalculator {
this.transactionPoints = transactionPoints; this.transactionPoints = transactionPoints;
} }
public async getCurrentPositions(start: Date): Promise<CurrentPositions> { public async getChartData(start: Date, end = new Date(Date.now()), step = 1) {
if (!this.transactionPoints?.length) { const symbols: { [symbol: string]: boolean } = {};
const transactionPointsBeforeEndDate =
this.transactionPoints?.filter((transactionPoint) => {
return isBefore(parseDate(transactionPoint.date), end);
}) ?? [];
const firstIndex = transactionPointsBeforeEndDate.length;
const dates: Date[] = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
let day = start;
while (isBefore(day, end)) {
dates.push(resetHours(day));
day = addDays(day, step);
}
dates.push(resetHours(end));
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
symbols[item.symbol] = true;
}
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const dateString = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[dateString]) {
marketSymbolMap[dateString] = {};
}
if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[dateString][marketSymbol.symbol] = new Big(
marketSymbol.marketPriceInBaseCurrency
);
}
}
const netPerformanceValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const investmentValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const totalNetPerformanceValues: { [date: string]: Big } = {};
const totalInvestmentValues: { [date: string]: Big } = {};
for (const symbol of Object.keys(symbols)) {
const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({
end,
marketSymbolMap,
start,
step,
symbol,
isChartMode: true
});
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
investmentValuesBySymbol[symbol] = investmentValues;
}
for (const currentDate of dates) {
const dateString = format(currentDate, DATE_FORMAT);
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
totalNetPerformanceValues[dateString] =
totalNetPerformanceValues[dateString] ?? new Big(0);
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
dateString
].add(netPerformanceValuesBySymbol[symbol][dateString]);
}
totalInvestmentValues[dateString] =
totalInvestmentValues[dateString] ?? new Big(0);
if (investmentValuesBySymbol[symbol]?.[dateString]) {
totalInvestmentValues[dateString] = totalInvestmentValues[
dateString
].add(investmentValuesBySymbol[symbol][dateString]);
}
}
}
const isInPercentage = true;
return Object.keys(totalNetPerformanceValues).map((date) => {
return isInPercentage
? {
date,
value: totalInvestmentValues[date].eq(0)
? 0
: totalNetPerformanceValues[date]
.div(totalInvestmentValues[date])
.mul(100)
.toNumber()
}
: {
date,
value: totalNetPerformanceValues[date].toNumber()
};
});
}
public async getCurrentPositions(
start: Date,
end = new Date(Date.now())
): Promise<CurrentPositions> {
const transactionPointsBeforeEndDate =
this.transactionPoints?.filter((transactionPoint) => {
return isBefore(parseDate(transactionPoint.date), end);
}) ?? [];
if (!transactionPointsBeforeEndDate.length) {
return { return {
currentValue: new Big(0), currentValue: new Big(0),
hasErrors: false,
grossPerformance: new Big(0), grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
hasErrors: false,
netPerformance: new Big(0), netPerformance: new Big(0),
netPerformancePercentage: new Big(0), netPerformancePercentage: new Big(0),
positions: [], positions: [],
@ -182,39 +315,38 @@ export class PortfolioCalculator {
} }
const lastTransactionPoint = const lastTransactionPoint =
this.transactionPoints[this.transactionPoints.length - 1]; transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1];
// use Date.now() to use the mock for today
const today = new Date(Date.now());
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length; let firstIndex = transactionPointsBeforeEndDate.length;
const dates = []; const dates = [];
const dataGatheringItems: IDataGatheringItem[] = []; const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {}; const currencies: { [symbol: string]: string } = {};
dates.push(resetHours(start)); dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) { for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({ dataGatheringItems.push({
dataSource: item.dataSource, dataSource: item.dataSource,
symbol: item.symbol symbol: item.symbol
}); });
currencies[item.symbol] = item.currency; currencies[item.symbol] = item.currency;
} }
for (let i = 0; i < this.transactionPoints.length; i++) { for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) {
if ( if (
!isBefore(parseDate(this.transactionPoints[i].date), start) && !isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) &&
firstTransactionPoint === null firstTransactionPoint === null
) { ) {
firstTransactionPoint = this.transactionPoints[i]; firstTransactionPoint = transactionPointsBeforeEndDate[i];
firstIndex = i; firstIndex = i;
} }
if (firstTransactionPoint !== null) { if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(this.transactionPoints[i].date))); dates.push(
resetHours(parseDate(transactionPointsBeforeEndDate[i].date))
);
} }
} }
dates.push(resetHours(today)); dates.push(resetHours(end));
const marketSymbols = await this.currentRateService.getValues({ const marketSymbols = await this.currentRateService.getValues({
currencies, currencies,
@ -241,7 +373,7 @@ export class PortfolioCalculator {
} }
} }
const todayString = format(today, DATE_FORMAT); const endDateString = format(end, DATE_FORMAT);
if (firstIndex > 0) { if (firstIndex > 0) {
firstIndex--; firstIndex--;
@ -254,7 +386,7 @@ export class PortfolioCalculator {
const errors: ResponseError['errors'] = []; const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) { for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol]; const marketValue = marketSymbolMap[endDateString]?.[item.symbol];
const { const {
grossPerformance, grossPerformance,
@ -264,6 +396,7 @@ export class PortfolioCalculator {
netPerformance, netPerformance,
netPerformancePercentage netPerformancePercentage
} = this.getSymbolMetrics({ } = this.getSymbolMetrics({
end,
marketSymbolMap, marketSymbolMap,
start, start,
symbol: item.symbol symbol: item.symbol
@ -432,30 +565,36 @@ export class PortfolioCalculator {
} }
} }
let minNetPerformance = new Big(0);
let maxNetPerformance = new Big(0);
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all( const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises timelinePeriodPromises
); );
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
const maxNetPerformance = timelineInfoInterfaces try {
.map((timelineInfo) => timelineInfo.maxNetPerformance) minNetPerformance = timelineInfoInterfaces
.filter((performance) => performance !== null) .map((timelineInfo) => timelineInfo.minNetPerformance)
.reduce((maxPerformance, current) => { .filter((performance) => performance !== null)
if (maxPerformance.gt(current)) { .reduce((minPerformance, current) => {
return maxPerformance; if (minPerformance.lt(current)) {
} else { return minPerformance;
return current; } else {
} return current;
}); }
});
maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
} catch {}
const timelinePeriods = timelineInfoInterfaces.map( const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods (timelineInfo) => timelineInfo.timelinePeriods
@ -694,14 +833,20 @@ export class PortfolioCalculator {
} }
private getSymbolMetrics({ private getSymbolMetrics({
end,
isChartMode = false,
marketSymbolMap, marketSymbolMap,
start, start,
step = 1,
symbol symbol
}: { }: {
end: Date;
isChartMode?: boolean;
marketSymbolMap: { marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
}; };
start: Date; start: Date;
step?: number;
symbol: string; symbol: string;
}) { }) {
let orders: PortfolioOrderItem[] = this.orders.filter((order) => { let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
@ -720,13 +865,12 @@ export class PortfolioCalculator {
} }
const dateOfFirstTransaction = new Date(first(orders).date); const dateOfFirstTransaction = new Date(first(orders).date);
const endDate = new Date(Date.now());
const unitPriceAtStartDate = const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol]; marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate = const unitPriceAtEndDate =
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol]; marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol];
if ( if (
!unitPriceAtEndDate || !unitPriceAtEndDate ||
@ -751,10 +895,12 @@ export class PortfolioCalculator {
let grossPerformanceFromSells = new Big(0); let grossPerformanceFromSells = new Big(0);
let initialValue: Big; let initialValue: Big;
let investmentAtStartDate: Big; let investmentAtStartDate: Big;
const investmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0); let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0); let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0); let lastValueOfInvestmentBeforeTransaction = new Big(0);
let maxTotalInvestment = new Big(0); let maxTotalInvestment = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
let timeWeightedGrossPerformancePercentage = new Big(1); let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1); let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
@ -779,7 +925,7 @@ export class PortfolioCalculator {
orders.push({ orders.push({
symbol, symbol,
currency: null, currency: null,
date: format(endDate, DATE_FORMAT), date: format(end, DATE_FORMAT),
dataSource: null, dataSource: null,
fee: new Big(0), fee: new Big(0),
itemType: 'end', itemType: 'end',
@ -789,6 +935,41 @@ export class PortfolioCalculator {
unitPrice: unitPriceAtEndDate unitPrice: unitPriceAtEndDate
}); });
let day = start;
let lastUnitPrice: Big;
if (isChartMode) {
const datesWithOrders = {};
for (const order of orders) {
datesWithOrders[order.date] = true;
}
while (isBefore(day, end)) {
const hasDate = datesWithOrders[format(day, DATE_FORMAT)];
if (!hasDate) {
orders.push({
symbol,
currency: null,
date: format(day, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice:
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
lastUnitPrice
});
}
lastUnitPrice = last(orders).unitPrice;
day = addDays(day, step);
}
}
// Sort orders so that the start and end placeholder order are at the right // Sort orders so that the start and end placeholder order are at the right
// position // position
orders = sortBy(orders, (order) => { orders = sortBy(orders, (order) => {
@ -951,6 +1132,18 @@ export class PortfolioCalculator {
feesAtStartDate = fees; feesAtStartDate = fees;
grossPerformanceAtStartDate = grossPerformance; grossPerformanceAtStartDate = grossPerformance;
} }
if (isChartMode && i > indexOfStartOrder) {
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
investmentValues[order.date] = totalInvestment;
}
if (i === indexOfEndOrder) {
break;
}
} }
timeWeightedGrossPerformancePercentage = timeWeightedGrossPerformancePercentage =
@ -1036,7 +1229,9 @@ export class PortfolioCalculator {
return { return {
initialValue, initialValue,
grossPerformancePercentage, grossPerformancePercentage,
investmentValues,
netPerformancePercentage, netPerformancePercentage,
netPerformanceValues,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance, netPerformance: totalNetPerformance,
grossPerformance: totalGrossPerformance grossPerformance: totalGrossPerformance

View File

@ -35,11 +35,11 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors,
Version
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { ViewMode } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
@ -110,6 +110,26 @@ export class PortfolioController {
}; };
} }
@Get('chart')
@UseGuards(AuthGuard('jwt'))
@Version('2')
public async getChartV2(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioService.getChartV2(
impersonationId,
range
);
return {
chart: historicalDataContainer.items,
hasError: false,
isAllTimeHigh: false,
isAllTimeLow: false
};
}
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@ -148,13 +168,19 @@ export class PortfolioController {
}) })
]; ];
const { accounts, holdings, hasErrors } = const {
await this.portfolioService.getDetails( accounts,
impersonationId, filteredValueInBaseCurrency,
this.request.user.id, filteredValueInPercentage,
range, hasErrors,
filters holdings,
); totalValueInBaseCurrency
} = await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
range,
filters
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true; hasError = true;
@ -175,7 +201,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency, portfolioPosition.currency,
this.request.user.Settings.currency this.request.user.Settings.settings.baseCurrency
); );
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
@ -214,8 +240,11 @@ export class PortfolioController {
return { return {
accounts, accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasError, hasError,
holdings holdings,
totalValueInBaseCurrency
}; };
} }
@ -278,7 +307,7 @@ export class PortfolioController {
if ( if (
impersonationId || impersonationId ||
this.request.user.Settings.viewMode === ViewMode.ZEN || this.request.user.Settings.settings.viewMode === 'ZEN' ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance = nullifyValuesInObject(
@ -349,6 +378,7 @@ export class PortfolioController {
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails, hasDetails,
alias: access.alias,
holdings: {} holdings: {}
}; };
@ -357,7 +387,8 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency, portfolioPosition.currency,
this.request.user?.Settings?.currency ?? this.baseCurrency this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency
); );
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);

View File

@ -5,7 +5,6 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface'; import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; 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 { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
@ -22,6 +21,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
ASSET_SUB_CLASS_EMERGENCY_FUND, ASSET_SUB_CLASS_EMERGENCY_FUND,
MAX_CHART_ITEMS,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
@ -35,7 +35,8 @@ import {
PortfolioReport, PortfolioReport,
PortfolioSummary, PortfolioSummary,
Position, Position,
TimelinePosition TimelinePosition,
UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { import type {
@ -122,7 +123,7 @@ export class PortfolioService {
this.getDetails(aUserId, aUserId, undefined, aFilters) this.getDetails(aUserId, aUserId, undefined, aFilters)
]); ]);
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
return accounts.map((account) => { return accounts.map((account) => {
let transactionCount = 0; let transactionCount = 0;
@ -197,7 +198,7 @@ export class PortfolioService {
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency, currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
orders: portfolioOrders orders: portfolioOrders
}); });
@ -277,7 +278,7 @@ export class PortfolioService {
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency, currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
orders: portfolioOrders orders: portfolioOrders
}); });
@ -327,10 +328,10 @@ export class PortfolioService {
} }
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq( let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
lastItem?.netPerformance lastItem?.netPerformance ?? 0
); );
let isAllTimeLow = timelineInfo.minNetPerformance?.eq( let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
lastItem?.netPerformance lastItem?.netPerformance ?? 0
); );
if (isAllTimeHigh && isAllTimeLow) { if (isAllTimeHigh && isAllTimeLow) {
isAllTimeHigh = false; isAllTimeHigh = false;
@ -354,6 +355,54 @@ export class PortfolioService {
}; };
} }
public async getChartV2(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
userId
});
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
const endDate = new Date();
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const daysInMarket = differenceInDays(new Date(), startDate);
const step = Math.round(
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
);
const items = await portfolioCalculator.getChartData(
startDate,
endDate,
step
);
return {
items,
isAllTimeHigh: false,
isAllTimeLow: false
};
}
public async getDetails( public async getDetails(
aImpersonationId: string, aImpersonationId: string,
aUserId: string, aUserId: string,
@ -367,8 +416,8 @@ export class PortfolioService {
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const userCurrency = const userCurrency =
user.Settings?.currency ?? user.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.currency ?? this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency; this.baseCurrency;
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
@ -400,12 +449,21 @@ export class PortfolioService {
}); });
const holdings: PortfolioDetails['holdings'] = {}; const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus( const totalInvestmentInBaseCurrency = currentPositions.totalInvestment.plus(
cashDetails.balanceInBaseCurrency
);
const totalValue = currentPositions.currentValue.plus(
cashDetails.balanceInBaseCurrency cashDetails.balanceInBaseCurrency
); );
let filteredValueInBaseCurrency = currentPositions.currentValue;
if (
aFilters?.length === 0 ||
(aFilters?.length === 1 &&
aFilters[0].type === 'ASSET_CLASS' &&
aFilters[0].id === 'CASH')
) {
filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus(
cashDetails.balanceInBaseCurrency
);
}
const dataGatheringItems = currentPositions.positions.map((position) => { const dataGatheringItems = currentPositions.positions.map((position) => {
return { return {
@ -466,8 +524,12 @@ export class PortfolioService {
holdings[item.symbol] = { holdings[item.symbol] = {
markets, markets,
allocationCurrent: value.div(totalValue).toNumber(), allocationCurrent: filteredValueInBaseCurrency.eq(0)
allocationInvestment: item.investment.div(totalInvestment).toNumber(), ? 0
: value.div(filteredValueInBaseCurrency).toNumber(),
allocationInvestment: item.investment
.div(totalInvestmentInBaseCurrency)
.toNumber(),
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass, assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries, countries: symbolProfile.countries,
@ -478,7 +540,7 @@ export class PortfolioService {
item.grossPerformancePercentage?.toNumber() ?? 0, item.grossPerformancePercentage?.toNumber() ?? 0,
investment: item.investment.toNumber(), investment: item.investment.toNumber(),
marketPrice: item.marketPrice, marketPrice: item.marketPrice,
marketState: dataProviderResponse.marketState, marketState: dataProviderResponse?.marketState ?? 'delayed',
name: symbolProfile.name, name: symbolProfile.name,
netPerformance: item.netPerformance?.toNumber() ?? 0, netPerformance: item.netPerformance?.toNumber() ?? 0,
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0, netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
@ -501,8 +563,8 @@ export class PortfolioService {
cashDetails, cashDetails,
emergencyFund, emergencyFund,
userCurrency, userCurrency,
investment: totalInvestment, investment: totalInvestmentInBaseCurrency,
value: totalValue value: filteredValueInBaseCurrency
}); });
for (const symbol of Object.keys(cashPositions)) { for (const symbol of Object.keys(cashPositions)) {
@ -518,7 +580,18 @@ export class PortfolioService {
filters: aFilters filters: aFilters
}); });
return { accounts, holdings, hasErrors: currentPositions.hasErrors }; const summary = await this.getSummary(aImpersonationId);
return {
accounts,
holdings,
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
filteredValueInPercentage: summary.netWorth
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber()
: 0,
hasErrors: currentPositions.hasErrors,
totalValueInBaseCurrency: summary.netWorth
};
} }
public async getPosition( public async getPosition(
@ -526,7 +599,7 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const orders = ( const orders = (
@ -779,7 +852,7 @@ export class PortfolioService {
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency, currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
orders: portfolioOrders orders: portfolioOrders
}); });
@ -855,7 +928,7 @@ export class PortfolioService {
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency, currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
orders: portfolioOrders orders: portfolioOrders
}); });
@ -915,7 +988,7 @@ export class PortfolioService {
} }
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(impersonationId: string): Promise<PortfolioReport> {
const currency = this.request.user.Settings.currency; const currency = this.request.user.Settings.settings.baseCurrency;
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
@ -969,7 +1042,7 @@ export class PortfolioService {
accounts accounts
) )
], ],
{ baseCurrency: currency } <UserSettings>this.request.user.Settings.settings
), ),
currencyClusterRisk: await this.rulesService.evaluate( currencyClusterRisk: await this.rulesService.evaluate(
[ [
@ -990,7 +1063,7 @@ export class PortfolioService {
currentPositions currentPositions
) )
], ],
{ baseCurrency: currency } <UserSettings>this.request.user.Settings.settings
), ),
fees: await this.rulesService.evaluate( fees: await this.rulesService.evaluate(
[ [
@ -1000,14 +1073,14 @@ export class PortfolioService {
this.getFees(orders).toNumber() this.getFees(orders).toNumber()
) )
], ],
{ baseCurrency: currency } <UserSettings>this.request.user.Settings.settings
) )
} }
}; };
} }
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> { public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -1181,7 +1254,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(), new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.SymbolProfile.currency, order.SymbolProfile.currency,
this.request.user.Settings.currency this.request.user.Settings.settings.baseCurrency
); );
}) })
.reduce( .reduce(
@ -1200,7 +1273,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
order.fee, order.fee,
order.SymbolProfile.currency, order.SymbolProfile.currency,
this.request.user.Settings.currency this.request.user.Settings.settings.baseCurrency
); );
}) })
.reduce( .reduce(
@ -1222,7 +1295,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(), new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.SymbolProfile.currency, order.SymbolProfile.currency,
this.request.user.Settings.currency this.request.user.Settings.settings.baseCurrency
); );
}) })
.reduce( .reduce(
@ -1263,7 +1336,7 @@ export class PortfolioService {
portfolioOrders: PortfolioOrder[]; portfolioOrders: PortfolioOrder[];
}> { }> {
const userCurrency = const userCurrency =
this.request.user?.Settings?.currency ?? this.baseCurrency; this.request.user?.Settings?.settings.baseCurrency ?? this.baseCurrency;
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
filters, filters,

View File

@ -1,5 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
@ -8,7 +9,7 @@ export class RulesService {
public async evaluate<T extends RuleSettings>( public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[], aRules: Rule<T>[],
aUserSettings: { baseCurrency: string } aUserSettings: UserSettings
) { ) {
return aRules return aRules
.filter((rule) => { .filter((rule) => {

View File

@ -1,7 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CacheModule, Module } from '@nestjs/common'; import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import * as redisStore from 'cache-manager-redis-store'; import * as redisStore from 'cache-manager-redis-store';
import { RedisCacheService } from './redis-cache.service'; import { RedisCacheService } from './redis-cache.service';
@ -9,16 +8,18 @@ import { RedisCacheService } from './redis-cache.service';
@Module({ @Module({
imports: [ imports: [
CacheModule.registerAsync({ CacheModule.registerAsync({
imports: [ConfigModule], imports: [ConfigurationModule],
inject: [ConfigService], inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => ({ useFactory: async (configurationService: ConfigurationService) => {
host: configurationService.get('REDIS_HOST'), return <CacheManagerOptions>{
max: configurationService.get('MAX_ITEM_IN_CACHE'), host: configurationService.get('REDIS_HOST'),
password: configurationService.get('REDIS_PASSWORD'), max: configurationService.get('MAX_ITEM_IN_CACHE'),
port: configurationService.get('REDIS_PORT'), password: configurationService.get('REDIS_PASSWORD'),
store: redisStore, port: configurationService.get('REDIS_PORT'),
ttl: configurationService.get('CACHE_TTL') store: redisStore,
}) ttl: configurationService.get('CACHE_TTL')
};
}
}), }),
ConfigurationModule ConfigurationModule
], ],

View File

@ -1,7 +0,0 @@
import { ViewMode } from '@prisma/client';
export interface UserSettingsParams {
currency?: string;
userId: string;
viewMode?: ViewMode;
}

View File

@ -1,5 +0,0 @@
export interface UserSettings {
emergencyFund?: number;
locale?: string;
isRestrictedView?: boolean;
}

View File

@ -1,10 +1,33 @@
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator'; import type { DateRange, ViewMode } from '@ghostfolio/common/types';
import {
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class UpdateUserSettingDto { export class UpdateUserSettingDto {
@IsOptional()
@IsString()
baseCurrency?: string;
@IsString()
@IsOptional()
benchmark?: string;
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
@IsOptional()
dateRange?: DateRange;
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
emergencyFund?: number; emergencyFund?: number;
@IsBoolean()
@IsOptional()
isExperimentalFeatures?: boolean;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isRestrictedView?: boolean; isRestrictedView?: boolean;
@ -20,4 +43,8 @@ export class UpdateUserSettingDto {
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
savingsRate?: number; savingsRate?: number;
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
@IsOptional()
viewMode?: ViewMode;
} }

View File

@ -1,10 +0,0 @@
import { ViewMode } from '@prisma/client';
import { IsString } from 'class-validator';
export class UpdateUserSettingsDto {
@IsString()
baseCurrency: string;
@IsString()
viewMode: ViewMode;
}

View File

@ -1,7 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config'; import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -22,12 +22,10 @@ import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { User as UserModel } from '@prisma/client'; import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { size } from 'lodash';
import { UserItem } from './interfaces/user-item.interface'; import { UserItem } from './interfaces/user-item.interface';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto'; import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@Controller('user') @Controller('user')
@ -103,6 +101,12 @@ export class UserController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async updateUserSetting(@Body() data: UpdateUserSettingDto) { public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if ( if (
size(data) === 1 &&
(data.benchmark || data.dateRange) &&
this.request.user.role === 'DEMO'
) {
// Allow benchmark or date range change for demo user
} else if (
!hasPermission( !hasPermission(
this.request.user.permissions, this.request.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
@ -130,33 +134,4 @@ export class UserController {
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@Put('settings')
@UseGuards(AuthGuard('jwt'))
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
if (
!hasPermission(
this.request.user.permissions,
permissions.updateUserSettings
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const userSettings: UserSettingsParams = {
currency: data.baseCurrency,
userId: this.request.user.id
};
if (
hasPermission(this.request.user.permissions, permissions.updateViewMode)
) {
userSettings.viewMode = data.viewMode;
}
return await this.userService.updateUserSettings(userSettings);
}
} }

View File

@ -4,19 +4,20 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config'; import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import {
User as IUser,
UserSettings,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { import {
getPermissions, getPermissions,
hasRole, hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User, ViewMode } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
const crypto = require('crypto'); const crypto = require('crypto');
@Injectable() @Injectable()
@ -43,7 +44,7 @@ export class UserService {
include: { include: {
User: true User: true
}, },
orderBy: { User: { alias: 'asc' } }, orderBy: { alias: 'asc' },
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}); });
let tags = await this.tagService.getByUser(id); let tags = await this.tagService.getByUser(id);
@ -62,16 +63,14 @@ export class UserService {
tags, tags,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
alias: accessItem.User.alias, alias: accessItem.alias,
id: accessItem.id id: accessItem.id
}; };
}), }),
accounts: Account, accounts: Account,
settings: { settings: {
...(<UserSettings>Settings.settings), ...(<UserSettings>Settings.settings),
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
} }
}; };
} }
@ -89,7 +88,7 @@ export class UserService {
} }
public isRestrictedView(aUser: UserWithSettings) { public isRestrictedView(aUser: UserWithSettings) {
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false; return aUser.Settings.settings.isRestrictedView ?? false;
} }
public async user( public async user(
@ -98,7 +97,6 @@ export class UserService {
const { const {
accessToken, accessToken,
Account, Account,
alias,
authChallenge, authChallenge,
createdAt, createdAt,
id, id,
@ -116,7 +114,6 @@ export class UserService {
const user: UserWithSettings = { const user: UserWithSettings = {
accessToken, accessToken,
Account, Account,
alias,
authChallenge, authChallenge,
createdAt, createdAt,
id, id,
@ -128,21 +125,35 @@ export class UserService {
}; };
if (user?.Settings) { if (user?.Settings) {
if (!user.Settings.currency) { if (!user.Settings.settings) {
// Set default currency if needed user.Settings.settings = {};
user.Settings.currency = UserService.DEFAULT_CURRENCY;
} }
} else if (user) { } else if (user) {
// Set default settings if needed // Set default settings if needed
user.Settings = { user.Settings = {
currency: UserService.DEFAULT_CURRENCY, settings: {},
settings: null,
updatedAt: new Date(), updatedAt: new Date(),
userId: user?.id, userId: user?.id
viewMode: ViewMode.DEFAULT
}; };
} }
// Set default value for base currency
if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
(user.Settings.settings as UserSettings).baseCurrency =
UserService.DEFAULT_CURRENCY;
}
// Set default value for date range
(user.Settings.settings as UserSettings).dateRange =
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
? 'max'
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
// Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription = user.subscription =
this.subscriptionService.getSubscription(Subscription); this.subscriptionService.getSubscription(Subscription);
@ -223,7 +234,9 @@ export class UserService {
}, },
Settings: { Settings: {
create: { create: {
currency: this.baseCurrency settings: {
currency: this.baseCurrency
}
} }
} }
} }
@ -297,7 +310,7 @@ export class UserService {
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
}) { }) {
const settings = userSettings as Prisma.JsonObject; const settings = userSettings as unknown as Prisma.JsonObject;
await this.prismaService.settings.upsert({ await this.prismaService.settings.upsert({
create: { create: {
@ -319,33 +332,6 @@ export class UserService {
return; return;
} }
public async updateUserSettings({
currency,
userId,
viewMode
}: UserSettingsParams) {
await this.prismaService.settings.upsert({
create: {
currency,
User: {
connect: {
id: userId
}
},
viewMode
},
update: {
currency,
viewMode
},
where: {
userId: userId
}
});
return;
}
private getRandomString(length: number) { private getRandomString(length: number) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const result = []; const result = [];

View File

@ -41,6 +41,14 @@ export class RedactValuesInResponseInterceptor<T>
return activity; return activity;
}); });
} }
if (data.filteredValueInBaseCurrency) {
data.filteredValueInBaseCurrency = null;
}
if (data.totalValueInBaseCurrency) {
data.totalValueInBaseCurrency = null;
}
} }
return data; return data;

View File

@ -5,6 +5,7 @@ import {
Injectable, Injectable,
NestInterceptor NestInterceptor
} from '@nestjs/common'; } from '@nestjs/common';
import { isArray } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@ -36,6 +37,13 @@ export class TransformDataSourceInResponseInterceptor<T>
}); });
} }
if (isArray(data.benchmarks)) {
data.benchmarks.map((benchmark) => {
benchmark.dataSource = encodeDataSource(benchmark.dataSource);
return benchmark;
});
}
if (data.dataSource) { if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource); data.dataSource = encodeDataSource(data.dataSource);
} }

View File

@ -1,5 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './evaluation-result.interface'; import { EvaluationResult } from './evaluation-result.interface';

View File

@ -1,3 +0,0 @@
export interface UserSettings {
baseCurrency: string;
}

View File

@ -1,8 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface'; import { RuleInterface } from './interfaces/rule.interface';

View File

@ -1,9 +1,9 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPosition PortfolioPosition,
UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,9 +1,9 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPosition PortfolioPosition,
UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioDetails } from '@ghostfolio/common/interfaces'; import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,6 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -17,7 +17,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
imports: [ imports: [
BullModule.registerQueue({ BullModule.registerQueue({
limiter: { limiter: {
duration: ms('5 seconds'), duration: ms('4 seconds'),
max: 1 max: 1
}, },
name: DATA_GATHERING_QUEUE name: DATA_GATHERING_QUEUE

View File

@ -6,7 +6,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -16,8 +20,6 @@ import { addDays, format, isBefore } from 'date-fns';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
public constructor( public constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -77,7 +79,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
const html = await get(); const html = await get();
const $ = cheerio.load(html); const $ = cheerio.load(html);
const value = this.extractNumberFromString($(selector).text()); const value = extractNumberFromString($(selector).text());
return { return {
[symbol]: { [symbol]: {
@ -175,15 +177,4 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return { items }; return { items };
} }
private extractNumberFromString(aString: string): number {
try {
const [numberString] = aString.match(
GhostfolioScraperApiService.NUMERIC_REGEXP
);
return parseFloat(numberString.trim());
} catch {
return undefined;
}
}
} }

View File

@ -183,10 +183,10 @@ export class YahooFinanceService implements DataProviderInterface {
for (const historicalItem of historicalResult) { for (const historicalItem of historicalResult) {
let marketPrice = historicalItem.close; let marketPrice = historicalItem.close;
if (symbol === 'USDGBp') { if (symbol === `${this.baseCurrency}GBp`) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
marketPrice = new Big(marketPrice).mul(100).toNumber(); marketPrice = new Big(marketPrice).mul(100).toNumber();
} else if (symbol === 'USDILA') { } else if (symbol === `${this.baseCurrency}ILA`) {
// Convert ILS to ILA // Convert ILS to ILA
marketPrice = new Big(marketPrice).mul(100).toNumber(); marketPrice = new Big(marketPrice).mul(100).toNumber();
} }
@ -246,9 +246,12 @@ export class YahooFinanceService implements DataProviderInterface {
marketPrice: quote.regularMarketPrice || 0 marketPrice: quote.regularMarketPrice || 0
}; };
if (symbol === 'USDGBP' && yahooFinanceSymbols.includes('USDGBp=X')) { if (
symbol === `${this.baseCurrency}GBP` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}GBp=X`)
) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
response['USDGBp'] = { response[`${this.baseCurrency}GBp`] = {
...response[symbol], ...response[symbol],
currency: 'GBp', currency: 'GBp',
marketPrice: new Big(response[symbol].marketPrice) marketPrice: new Big(response[symbol].marketPrice)
@ -256,11 +259,11 @@ export class YahooFinanceService implements DataProviderInterface {
.toNumber() .toNumber()
}; };
} else if ( } else if (
symbol === 'USDILS' && symbol === `${this.baseCurrency}ILS` &&
yahooFinanceSymbols.includes('USDILA=X') yahooFinanceSymbols.includes(`${this.baseCurrency}ILA=X`)
) { ) {
// Convert ILS to ILA // Convert ILS to ILA
response['USDILA'] = { response[`${this.baseCurrency}ILA`] = {
...response[symbol], ...response[symbol],
currency: 'ILA', currency: 'ILA',
marketPrice: new Big(response[symbol].marketPrice) marketPrice: new Big(response[symbol].marketPrice)
@ -270,9 +273,9 @@ export class YahooFinanceService implements DataProviderInterface {
} }
} }
if (yahooFinanceSymbols.includes('USDUSX=X')) { if (yahooFinanceSymbols.includes(`${this.baseCurrency}USX=X`)) {
// Convert USD to USX (cent) // Convert USD to USX (cent)
response['USDUSX'] = { response[`${this.baseCurrency}USX`] = {
currency: 'USX', currency: 'USX',
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(), marketPrice: new Big(1).mul(100).toNumber(),

View File

@ -99,10 +99,12 @@ export class ExchangeRateDataService {
this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice; this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice;
if (!this.exchangeRates[symbol]) { if (!this.exchangeRates[symbol]) {
// Not found, calculate indirectly via USD // Not found, calculate indirectly via base currency
this.exchangeRates[symbol] = this.exchangeRates[symbol] =
resultExtended[`${currency1}${'USD'}`]?.[date]?.marketPrice * resultExtended[`${currency1}${this.baseCurrency}`]?.[date]
resultExtended[`${'USD'}${currency2}`]?.[date]?.marketPrice; ?.marketPrice *
resultExtended[`${this.baseCurrency}${currency2}`]?.[date]
?.marketPrice;
// Calculate the opposite direction // Calculate the opposite direction
this.exchangeRates[`${currency2}${currency1}`] = this.exchangeRates[`${currency2}${currency1}`] =
@ -126,9 +128,11 @@ export class ExchangeRateDataService {
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) { if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`]; factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
} else { } else {
// Calculate indirectly via USD // Calculate indirectly via base currency
const factor1 = this.exchangeRates[`${aFromCurrency}${'USD'}`]; const factor1 =
const factor2 = this.exchangeRates[`${'USD'}${aToCurrency}`]; this.exchangeRates[`${aFromCurrency}${this.baseCurrency}`];
const factor2 =
this.exchangeRates[`${this.baseCurrency}${aToCurrency}`];
factor = factor1 * factor2; factor = factor1 * factor2;
@ -166,21 +170,6 @@ export class ExchangeRateDataService {
currencies.push(account.currency); currencies.push(account.currency);
}); });
(
await this.prismaService.settings.findMany({
distinct: ['currency'],
orderBy: [{ currency: 'asc' }],
select: { currency: true },
where: {
currency: {
not: null
}
}
})
).forEach((userSettings) => {
currencies.push(userSettings.currency);
});
( (
await this.prismaService.symbolProfile.findMany({ await this.prismaService.symbolProfile.findMany({
distinct: ['currency'], distinct: ['currency'],

View File

@ -64,6 +64,23 @@ export class SymbolProfileService {
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .then((symbolProfiles) => this.getSymbols(symbolProfiles));
} }
public async getSymbolProfilesByIds(
symbolProfileIds: string[]
): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile
.findMany({
include: { SymbolProfileOverrides: true },
where: {
id: {
in: symbolProfileIds.map((symbolProfileId) => {
return symbolProfileId;
})
}
}
})
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
}
/** /**
* @deprecated * @deprecated
*/ */

View File

@ -1,13 +1,12 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@Module({ @Module({
exports: [TwitterBotService], exports: [TwitterBotService],
imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule], imports: [BenchmarkModule, ConfigurationModule, SymbolModule],
providers: [TwitterBotService] providers: [TwitterBotService]
}) })
export class TwitterBotModule {} export class TwitterBotModule {}

View File

@ -1,9 +1,7 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
PROPERTY_BENCHMARKS,
ghostfolioFearAndGreedIndexDataSource, ghostfolioFearAndGreedIndexDataSource,
ghostfolioFearAndGreedIndexSymbol ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -11,7 +9,6 @@ import {
resolveFearAndGreedIndex, resolveFearAndGreedIndex,
resolveMarketCondition resolveMarketCondition
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { isWeekend } from 'date-fns'; import { isWeekend } from 'date-fns';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@ -23,7 +20,6 @@ export class TwitterBotService {
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService private readonly symbolService: SymbolService
) { ) {
this.twitterClient = new TwitterApi({ this.twitterClient = new TwitterApi({
@ -82,14 +78,9 @@ export class TwitterBotService {
} }
private async getBenchmarkListing(aMax: number) { private async getBenchmarkListing(aMax: number) {
const benchmarkAssets: UniqueAsset[] = const benchmarks = await this.benchmarkService.getBenchmarks({
((await this.propertyService.getByKey( useCache: false
PROPERTY_BENCHMARKS });
)) as UniqueAsset[]) ?? [];
const benchmarks = await this.benchmarkService.getBenchmarks(
benchmarkAssets
);
const benchmarkListing: string[] = []; const benchmarkListing: string[] = [];

View File

@ -1,3 +1,4 @@
/* eslint-disable */
export default { export default {
displayName: 'client', displayName: 'client',

View File

@ -2,5 +2,13 @@
"/api": { "/api": {
"target": "http://localhost:3333", "target": "http://localhost:3333",
"secure": false "secure": false
},
"/assets": {
"target": "http://localhost:3333",
"secure": false
},
"/ionicons": {
"target": "http://localhost:3333",
"secure": false
} }
} }

View File

@ -1,8 +1,15 @@
<table class="gf-table w-100" mat-table [dataSource]="dataSource"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="granteeAlias"> <ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.alias }}
</td>
</ng-container>
<ng-container matColumnDef="grantee">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.granteeAlias }} {{ element.grantee }}
</td> </td>
</ng-container> </ng-container>

View File

@ -33,7 +33,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = ['granteeAlias', 'type', 'details']; this.displayedColumns = ['alias', 'grantee', 'type', 'details'];
if (this.showActions) { if (this.showActions) {
this.displayedColumns.push('actions'); this.displayedColumns.push('actions');

View File

@ -14,8 +14,7 @@ import {
getDateFormatString, getDateFormatString,
getLocale getLocale
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { import {
addDays, addDays,

View File

@ -3,17 +3,27 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, OnDestroy,
OnInit OnInit,
ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { DataSource, MarketData } from '@prisma/client'; import { AssetSubClass, DataSource } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -22,11 +32,46 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-market-data.html' templateUrl: './admin-market-data.html'
}) })
export class AdminMarketDataComponent implements OnDestroy, OnInit { export class AdminMarketDataComponent implements OnDestroy, OnInit {
@ViewChild(MatSort) sort: MatSort;
public activeFilters: Filter[] = [];
public allFilters: Filter[] = [
AssetSubClass.BOND,
AssetSubClass.COMMODITY,
AssetSubClass.CRYPTOCURRENCY,
AssetSubClass.ETF,
AssetSubClass.MUTUALFUND,
AssetSubClass.PRECIOUS_METAL,
AssetSubClass.PRIVATE_EQUITY,
AssetSubClass.STOCK
].map((id) => {
return {
id,
label: id,
type: 'ASSET_SUB_CLASS'
};
});
public currentDataSource: DataSource; public currentDataSource: DataSource;
public currentSymbol: string; public currentSymbol: string;
public dataSource: MatTableDataSource<AdminMarketDataItem> =
new MatTableDataSource();
public defaultDateFormat: string; public defaultDateFormat: string;
public marketData: AdminMarketDataItem[] = []; public deviceType: string;
public marketDataDetails: MarketData[] = []; public displayedColumns = [
'symbol',
'dataSource',
'assetClass',
'assetSubClass',
'date',
'activityCount',
'marketDataItemCount',
'countriesCount',
'sectorsCount',
'actions'
];
public filters$ = new Subject<Filter[]>();
public isLoading = false;
public placeholder = '';
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -35,8 +80,28 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService private userService: UserService
) { ) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['assetProfileDialog'] &&
params['dataSource'] &&
params['symbol']
) {
this.openAssetProfileDialog({
dataSource: params['dataSource'],
dateOfFirstActivity: params['dateOfFirstActivity'],
symbol: params['symbol']
});
}
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
@ -51,7 +116,31 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.fetchAdminMarketData(); this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.filters$
.pipe(
distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
return this.dataService.fetchAdminMarketData({
filters: this.activeFilters
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ marketData }) => {
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
} }
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) { public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
@ -75,28 +164,23 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {}); .subscribe(() => {});
} }
public onMarketDataChanged(withRefresh: boolean = false) { public onOpenAssetProfileDialog({
if (withRefresh) { dataSource,
this.fetchAdminMarketData(); dateOfFirstActivity,
this.fetchAdminMarketDataBySymbol({ symbol
dataSource: this.currentDataSource, }: UniqueAsset & { dateOfFirstActivity: string }) {
symbol: this.currentSymbol try {
}); dateOfFirstActivity = format(parseISO(dateOfFirstActivity), DATE_FORMAT);
} } catch {}
}
public setCurrentProfile({ dataSource, symbol }: UniqueAsset) { this.router.navigate([], {
this.marketDataDetails = []; queryParams: {
dateOfFirstActivity,
if (this.currentSymbol === symbol) { dataSource,
this.currentDataSource = undefined; symbol,
this.currentSymbol = ''; assetProfileDialog: true
} else { }
this.currentDataSource = dataSource; });
this.currentSymbol = symbol;
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
}
} }
public ngOnDestroy() { public ngOnDestroy() {
@ -104,25 +188,40 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private fetchAdminMarketData() { private openAssetProfileDialog({
this.dataService dataSource,
.fetchAdminMarketData() dateOfFirstActivity,
symbol
}: {
dataSource: DataSource;
dateOfFirstActivity: string;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => { .subscribe((user) => {
this.marketData = marketData; this.user = user;
this.changeDetectorRef.markForCheck(); const dialogRef = this.dialog.open(AssetProfileDialog, {
}); autoFocus: false,
} data: <AssetProfileDialogParams>{
dataSource,
dateOfFirstActivity,
symbol,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) { dialogRef
this.adminService .afterClosed()
.fetchAdminMarketDataBySymbol({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => {
.subscribe(({ marketData }) => { this.router.navigate(['.'], { relativeTo: this.route });
this.marketDataDetails = marketData; });
this.changeDetectorRef.markForCheck();
}); });
} }
} }

View File

@ -1,76 +1,147 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<table class="gf-table w-100"> <gf-activities-filter
<thead> [allFilters]="allFilters"
<tr class="mat-header-row"> [isLoading]="isLoading"
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th> [placeholder]="placeholder"
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th> (valueChanged)="filters$.next($event)"
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th> ></gf-activities-filter>
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th> </div>
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th> </div>
<th class="mat-header-cell px-1 py-2"></th> <div class="row">
</tr> <div class="col">
</thead> <table
<tbody> class="gf-table w-100"
<ng-container *ngFor="let item of marketData; let i = index"> matSort
<tr matSortActive="symbol"
class="cursor-pointer mat-row" matSortDirection="asc"
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })" mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Symbol</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol }}
</td>
</ng-container>
<ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Data Source</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.dataSource }}
</td>
</ng-container>
<ng-container matColumnDef="assetClass">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetClass }}
</td>
</ng-container>
<ng-container matColumnDef="assetSubClass">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Sub Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetSubClass }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>First Activity</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ (element.date | date: defaultDateFormat) ?? '' }}
</td>
</ng-container>
<ng-container matColumnDef="activityCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Activity Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="marketDataItemCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Historical Data</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.marketDataItemCount }}
</td>
</ng-container>
<ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Countries Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.countriesCount }}
</td>
</ng-container>
<ng-container matColumnDef="sectorsCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Sectors Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.sectorsCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
> >
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td> <ion-icon name="ellipsis-vertical"></ion-icon>
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td> </button>
<td class="mat-cell px-1 py-2"> <mat-menu #accountMenu="matMenu" xPosition="before">
{{ (item.date | date: defaultDateFormat) ?? '' }} <button
</td> mat-menu-item
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td> (click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td> >
<td class="mat-cell px-1 py-2"> <ng-container i18n>Gather Data</ng-container>
<button </button>
class="mx-1 no-min-width px-2" <button
mat-button mat-menu-item
[matMenuTriggerFor]="accountMenu" (click)="onGatherProfileDataBySymbol({dataSource: element.dataSource, symbol: element.symbol})"
(click)="$event.stopPropagation()" >
> <ng-container i18n>Gather Profile Data</ng-container>
<ion-icon name="ellipsis-vertical"></ion-icon> </button>
</button> <button
<mat-menu #accountMenu="matMenu" xPosition="before"> mat-menu-item
<button [disabled]="element.activityCount !== 0"
mat-menu-item (click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
(click)="onGatherSymbol({dataSource: item.dataSource, symbol: item.symbol})" >
> <ng-container i18n>Delete</ng-container>
<ng-container i18n>Gather Data</ng-container> </button>
</button> </mat-menu>
<button </td>
mat-menu-item </ng-container>
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<ng-container i18n>Gather Profile Data</ng-container> <tr
</button> *matRowDef="let row; columns: displayedColumns"
<button class="cursor-pointer"
mat-menu-item mat-row
[disabled]="item.activityCount !== 0" (click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})" ></tr>
>
<ng-container i18n>Delete</ng-container>
</button>
</mat-menu>
</td>
</tr>
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
<td class="p-1" colspan="6">
<gf-admin-market-data-detail
[dataSource]="item.dataSource"
[dateOfFirstActivity]="item.date"
[locale]="user?.settings?.locale"
[marketData]="marketDataDetails"
[symbol]="item.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail>
</td>
</tr>
</ng-container>
</tbody>
</table> </table>
</div> </div>
</div> </div>

View File

@ -2,17 +2,23 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { AdminMarketDataComponent } from './admin-market-data.component'; import { AdminMarketDataComponent } from './admin-market-data.component';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/assset-profile-dialog.module';
@NgModule({ @NgModule({
declarations: [AdminMarketDataComponent], declarations: [AdminMarketDataComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfAdminMarketDataDetailModule, GfActivitiesFilterModule,
GfAssetProfileDialogModule,
MatButtonModule, MatButtonModule,
MatMenuModule MatMenuModule,
MatSortModule,
MatTableModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -0,0 +1,7 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
}
}

View File

@ -0,0 +1,73 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { MarketData } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AssetProfileDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'd-flex flex-column h-100' },
selector: 'gf-asset-profile-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'asset-profile-dialog.html',
styleUrls: ['./asset-profile-dialog.component.scss']
})
export class AssetProfileDialog implements OnDestroy, OnInit {
public marketDataDetails: MarketData[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
public dialogRef: MatDialogRef<AssetProfileDialog>,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams
) {}
public ngOnInit(): void {
this.initialize();
}
public onClose(): void {
this.dialogRef.close();
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.initialize();
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataDetails = marketData;
this.changeDetectorRef.markForCheck();
});
}
private initialize() {
this.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
symbol: this.data.symbol
});
}
}

View File

@ -0,0 +1,24 @@
<gf-dialog-header
mat-dialog-title
position="center"
[deviceType]="data.deviceType"
[title]="data.symbol"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<gf-admin-market-data-detail
[dataSource]="data.dataSource"
[dateOfFirstActivity]="data.dateOfFirstActivity"
[locale]="data.locale"
[marketData]="marketDataDetails"
[symbol]="data.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { AssetProfileDialog } from './asset-profile-dialog.component';
@NgModule({
declarations: [AssetProfileDialog],
imports: [
CommonModule,
GfAdminMarketDataDetailModule,
GfDialogFooterModule,
GfDialogHeaderModule,
MatButtonModule,
MatDialogModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAssetProfileDialogModule {}

View File

@ -0,0 +1,9 @@
import { DataSource } from '@prisma/client';
export interface AssetProfileDialogParams {
dateOfFirstActivity: string;
dataSource: DataSource;
deviceType: string;
locale: string;
symbol: string;
}

View File

@ -14,8 +14,8 @@ import { AdminOverviewComponent } from './admin-overview.component';
declarations: [AdminOverviewComponent], declarations: [AdminOverviewComponent],
exports: [], exports: [],
imports: [ imports: [
FormsModule,
CommonModule, CommonModule,
FormsModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,

View File

@ -0,0 +1,51 @@
<div class="row">
<div class="col-md-6 col-xs-12 d-flex">
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
<span i18n>Benchmarks</span>
<sup i18n>Beta</sup>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</div>
</div>
<div class="col-md-6 col-xs-12 d-flex justify-content-end">
<mat-form-field appearance="outline" class="w-100" color="accent">
<mat-label i18n>Compare with...</mat-label>
<mat-select
name="benchmark"
[value]="benchmark"
(selectionChange)="onChangeBenchmark($event.value)"
>
<mat-option
*ngFor="let symbolProfile of benchmarks"
[value]="symbolProfile.id"
>{{ symbolProfile.name }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoading"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
<div class="chart-container">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '100%',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#chartCanvas
class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>
</div>

View File

@ -0,0 +1,11 @@
:host {
display: block;
.chart-container {
aspect-ratio: 16 / 9;
ngx-skeleton-loader {
height: 100%;
}
}
}

View File

@ -0,0 +1,220 @@
import 'chartjs-adapter-date-fns';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
ViewChild
} from '@angular/core';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import {
getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import {
getBackgroundColor,
getDateFormatString,
getTextColor,
parseDate
} from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import {
Chart,
LineController,
LineElement,
LinearScale,
PointElement,
TimeScale,
Tooltip
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { SymbolProfile } from '@prisma/client';
@Component({
selector: 'gf-benchmark-comparator',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './benchmark-comparator.component.html',
styleUrls: ['./benchmark-comparator.component.scss']
})
export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() benchmarkDataItems: LineChartItem[] = [];
@Input() benchmark: string;
@Input() benchmarks: Partial<SymbolProfile>[];
@Input() daysInMarket: number;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() performanceDataItems: LineChartItem[];
@Input() user: User;
@Output() benchmarkChanged = new EventEmitter<string>();
@Output() dateRangeChanged = new EventEmitter<DateRange>();
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart<any>;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public constructor() {
Chart.register(
annotationPlugin,
LinearScale,
LineController,
LineElement,
PointElement,
TimeScale,
Tooltip
);
Tooltip.positioners['top'] = (elements, position) =>
getTooltipPositionerMapTop(this.chart, position);
}
public ngOnChanges() {
if (this.performanceDataItems) {
this.initialize();
}
}
public onChangeBenchmark(symbolProfileId: string) {
this.benchmarkChanged.next(symbolProfileId);
}
public onChangeDateRange(dateRange: DateRange) {
this.dateRangeChanged.next(dateRange);
}
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() {
const data = {
datasets: [
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.performanceDataItems.map(({ date, value }) => {
return { x: parseDate(date), y: value };
}),
label: $localize`Portfolio`
},
{
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 2,
data: this.benchmarkDataItems.map(({ date, value }) => {
return { x: parseDate(date), y: value };
}),
label: $localize`Benchmark`
}
]
};
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration()
);
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
animation: false,
elements: {
line: {
tension: 0
},
point: {
hoverBackgroundColor: getBackgroundColor(),
hoverRadius: 2,
radius: 0
}
},
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true,
plugins: <unknown>{
annotation: {
annotations: {
yAxis: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
value: 0
}
}
},
legend: {
display: false
},
tooltip: this.getTooltipPluginConfiguration(),
verticalHoverLine: {
color: `rgba(${getTextColor()}, 0.1)`
}
},
responsive: true,
scales: {
x: {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: 1,
color: `rgba(${getTextColor()}, 0.8)`,
display: false
},
type: 'time',
time: {
tooltipFormat: getDateFormatString(this.locale),
unit: 'year'
}
},
y: {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
display: false,
drawBorder: false
},
position: 'right',
ticks: {
callback: (value: number) => {
return `${value} %`;
},
display: true,
mirror: true,
z: 1
}
}
}
},
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
type: 'line'
});
}
}
}
private getTooltipPluginConfiguration() {
return {
...getTooltipOptions({
locale: this.locale,
unit: '%'
}),
mode: 'x',
position: <unknown>'top',
xAlign: 'center',
yAlign: 'bottom'
};
}
}

View File

@ -0,0 +1,22 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
@NgModule({
declarations: [BenchmarkComparatorComponent],
exports: [BenchmarkComparatorComponent],
imports: [
CommonModule,
FormsModule,
GfToggleModule,
MatSelectModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule
]
})
export class GfBenchmarkComparatorModule {}

View File

@ -1,13 +1,23 @@
<div class="align-items-center d-flex flex-row"> <div class="position-relative">
<div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div> <div class="align-items-center d-flex flex-row" [hidden]="!fearAndGreedIndex">
<div> <div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
<div class="h4 mb-0"> <div>
<span class="mr-2">{{ fearAndGreedIndexText }}</span> <div class="h4 mb-0">
<small class="text-muted" <span class="mr-2">{{ fearAndGreedIndexText }}</span>
><strong>{{ fearAndGreedIndex }}</strong <small class="text-muted"
>/100</small ><strong>{{ fearAndGreedIndex }}</strong
> >/100</small
>
</div>
<small class="d-block" i18n>Current Market Mood</small>
</div> </div>
<small class="d-block" i18n>Current Market Mood</small>
</div> </div>
<ngx-skeleton-loader
*ngIf="!fearAndGreedIndex"
animation="pulse"
class="position-absolute w-100"
[theme]="{
height: '100%'
}"
></ngx-skeleton-loader>
</div> </div>

View File

@ -1,3 +1,8 @@
:host { :host {
display: block; display: block;
ngx-skeleton-loader {
bottom: 0;
top: 0;
}
} }

View File

@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { FearAndGreedIndexComponent } from './fear-and-greed-index.component'; import { FearAndGreedIndexComponent } from './fear-and-greed-index.component';
@NgModule({ @NgModule({
declarations: [FearAndGreedIndexComponent], declarations: [FearAndGreedIndexComponent],
exports: [FearAndGreedIndexComponent], exports: [FearAndGreedIndexComponent],
imports: [CommonModule] imports: [CommonModule, NgxSkeletonLoaderModule]
}) })
export class GfFearAndGreedIndexModule {} export class GfFearAndGreedIndexModule {}

View File

@ -5,10 +5,6 @@ import { PositionDetailDialog } from '@ghostfolio/client/components/position/pos
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component'; import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
RANGE,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Position, User } from '@ghostfolio/common/interfaces'; import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -26,7 +22,6 @@ import { PositionDetailDialogParams } from '../position/position-detail-dialog/i
templateUrl: './home-holdings.html' templateUrl: './home-holdings.html'
}) })
export class HomeHoldingsComponent implements OnDestroy, OnInit { export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
@ -44,10 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService,
private userService: UserService private userService: UserService
) { ) {
route.queryParams this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if ( if (
@ -73,7 +67,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
permissions.createOrder permissions.createOrder
); );
this.changeDetectorRef.markForCheck(); this.update();
} }
}); });
} }
@ -88,18 +82,25 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
}); });
this.dateRange =
this.user.settings.viewMode === 'ZEN'
? 'max'
: <DateRange>this.settingsStorageService.getSetting(RANGE) ?? 'max';
this.update(); this.update();
} }
public onChangeDateRange(aDateRange: DateRange) { public onChangeDateRange(dateRange: DateRange) {
this.dateRange = aDateRange; this.dataService
this.settingsStorageService.setSetting(RANGE, this.dateRange); .putUserSetting({ dateRange })
this.update(); .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
} }
public ngOnDestroy() { public ngOnDestroy() {
@ -151,7 +152,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.positions = undefined; this.positions = undefined;
this.dataService this.dataService
.fetchPositions({ range: this.dateRange }) .fetchPositions({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe((response) => {
this.positions = response.positions; this.positions = response.positions;

View File

@ -1,7 +1,7 @@
<div class="container justify-content-center p-3"> <div class="container justify-content-center p-3">
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center"> <div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center">
<gf-toggle <gf-toggle
[defaultValue]="dateRange" [defaultValue]="user?.settings?.dateRange"
[isLoading]="positions === undefined" [isLoading]="positions === undefined"
[options]="dateRangeOptions" [options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)" (change)="onChangeDateRange($event.value)"
@ -17,7 +17,7 @@
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder" [hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions" [positions]="positions"
[range]="dateRange" [range]="user?.settings?.dateRange"
></gf-positions> ></gf-positions>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

View File

@ -21,6 +21,8 @@ import { takeUntil } from 'rxjs/operators';
export class HomeMarketComponent implements OnDestroy, OnInit { export class HomeMarketComponent implements OnDestroy, OnInit {
public benchmarks: Benchmark[]; public benchmarks: Benchmark[];
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
public fearLabel = $localize`Fear`;
public greedLabel = $localize`Greed`;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[]; public historicalData: HistoricalDataItem[];
public info: InfoItem; public info: InfoItem;

View File

@ -9,18 +9,17 @@
class="mb-3" class="mb-3"
symbol="Fear & Greed Index" symbol="Fear & Greed Index"
yMax="100" yMax="100"
yMaxLabel="Greed"
yMin="0" yMin="0"
yMinLabel="Fear"
[historicalDataItems]="historicalData" [historicalDataItems]="historicalData"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showXAxis]="true" [showXAxis]="true"
[showYAxis]="true" [showYAxis]="true"
[yMaxLabel]="greedLabel"
[yMinLabel]="fearLabel"
></gf-line-chart> ></gf-line-chart>
<gf-fear-and-greed-index <gf-fear-and-greed-index
class="d-flex justify-content-center" class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex" [fearAndGreedIndex]="fearAndGreedIndex"
[hidden]="isLoading"
></gf-fear-and-greed-index> ></gf-fear-and-greed-index>
</div> </div>
</div> </div>

View File

@ -2,19 +2,15 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component'; import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
RANGE,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
LineChartItem,
PortfolioPerformance, PortfolioPerformance,
UniqueAsset, UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -25,7 +21,6 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './home-overview.html' templateUrl: './home-overview.html'
}) })
export class HomeOverviewComponent implements OnDestroy, OnInit { export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string; public deviceType: string;
public errors: UniqueAsset[]; public errors: UniqueAsset[];
@ -47,7 +42,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private settingsStorageService: SettingsStorageService,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
@ -61,7 +55,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
permissions.createOrder permissions.createOrder
); );
this.changeDetectorRef.markForCheck(); this.update();
} }
}); });
} }
@ -78,11 +72,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dateRange =
this.user.settings.viewMode === 'ZEN'
? 'max'
: <DateRange>this.settingsStorageService.getSetting(RANGE) ?? 'max';
this.showDetails = this.showDetails =
!this.hasImpersonationId && !this.hasImpersonationId &&
!this.user.settings.isRestrictedView && !this.user.settings.isRestrictedView &&
@ -91,10 +80,22 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.update(); this.update();
} }
public onChangeDateRange(aDateRange: DateRange) { public onChangeDateRange(dateRange: DateRange) {
this.dateRange = aDateRange; this.dataService
this.settingsStorageService.setSetting(RANGE, this.dateRange); .putUserSetting({ dateRange })
this.update(); .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
} }
public ngOnDestroy() { public ngOnDestroy() {
@ -106,7 +107,10 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.isLoadingPerformance = true; this.isLoadingPerformance = true;
this.dataService this.dataService
.fetchChart({ range: this.dateRange }) .fetchChart({
range: this.user?.settings?.dateRange,
version: this.user?.settings?.isExperimentalFeatures ? 2 : 1
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => { .subscribe((chartData) => {
this.historicalDataItems = chartData.chart.map((chartDataItem) => { this.historicalDataItems = chartData.chart.map((chartDataItem) => {
@ -122,7 +126,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
}); });
this.dataService this.dataService
.fetchPortfolioPerformance({ range: this.dateRange }) .fetchPortfolioPerformance({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe((response) => {
this.errors = response.errors; this.errors = response.errors;

View File

@ -15,7 +15,7 @@
<gf-line-chart <gf-line-chart
class="position-absolute" class="position-absolute"
symbol="Performance" symbol="Performance"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.isExperimentalFeatures ? undefined : user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[hidden]="historicalDataItems?.length === 0" [hidden]="historicalDataItems?.length === 0"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -24,6 +24,7 @@
[showLoader]="false" [showLoader]="false"
[showXAxis]="false" [showXAxis]="false"
[showYAxis]="false" [showYAxis]="false"
[unit]="user?.settings?.isExperimentalFeatures ? '%' : undefined"
></gf-line-chart> ></gf-line-chart>
</div> </div>
</div> </div>
@ -45,7 +46,7 @@
></gf-portfolio-performance> ></gf-portfolio-performance>
<div *ngIf="showDetails" class="text-center"> <div *ngIf="showDetails" class="text-center">
<gf-toggle <gf-toggle
[defaultValue]="dateRange" [defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingPerformance" [isLoading]="isLoadingPerformance"
[options]="dateRangeOptions" [options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)" (change)="onChangeDateRange($event.value)"

View File

@ -38,7 +38,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
permissions.updateUserSettings permissions.updateUserSettings
); );
this.changeDetectorRef.markForCheck(); this.update();
} }
}); });
} }
@ -59,7 +59,16 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
.putUserSetting({ emergencyFund }) .putUserSetting({ emergencyFund })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.update(); this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
}); });
} }

View File

@ -10,6 +10,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings" [hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings"
[isLoading]="isLoading" [isLoading]="isLoading"
[language]="user?.settings?.language"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[summary]="summary" [summary]="summary"
(emergencyFundChanged)="onChangeEmergencyFund($event)" (emergencyFundChanged)="onChangeEmergencyFund($event)"

View File

@ -57,6 +57,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
public chart: Chart; public chart: Chart;
public isLoading = true; public isLoading = true;
private data: InvestmentItem[];
public constructor() { public constructor() {
Chart.register( Chart.register(
annotationPlugin, annotationPlugin,
@ -87,10 +89,13 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
if (!this.groupBy && this.investments?.length > 0) { // Create a clone
this.data = this.investments.map((a) => Object.assign({}, a));
if (!this.groupBy && this.data?.length > 0) {
// Extend chart by 5% of days in market (before) // Extend chart by 5% of days in market (before)
const firstItem = this.investments[0]; const firstItem = this.data[0];
this.investments.unshift({ this.data.unshift({
...firstItem, ...firstItem,
date: subDays( date: subDays(
parseISO(firstItem.date), parseISO(firstItem.date),
@ -100,8 +105,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}); });
// Extend chart by 5% of days in market (after) // Extend chart by 5% of days in market (after)
const lastItem = this.investments[this.investments.length - 1]; const lastItem = this.data[this.data.length - 1];
this.investments.push({ this.data.push({
...lastItem, ...lastItem,
date: addDays( date: addDays(
parseDate(lastItem.date), parseDate(lastItem.date),
@ -111,7 +116,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
const data = { const data = {
labels: this.investments.map((investmentItem) => { labels: this.data.map((investmentItem) => {
return investmentItem.date; return investmentItem.date;
}), }),
datasets: [ datasets: [
@ -119,10 +124,12 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: this.groupBy ? 0 : 2, borderWidth: this.groupBy ? 0 : 2,
data: this.investments.map((position) => { data: this.data.map((position) => {
return position.investment; return this.isInPercent
? position.investment * 100
: position.investment;
}), }),
label: 'Investment', label: $localize`Deposit`,
segment: { segment: {
borderColor: (context: unknown) => borderColor: (context: unknown) =>
this.isInFuture( this.isInFuture(
@ -249,10 +256,11 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration() {
return { return {
...getTooltipOptions( ...getTooltipOptions({
this.isInPercent ? undefined : this.currency, currency: this.isInPercent ? undefined : this.currency,
this.isInPercent ? undefined : this.locale locale: this.isInPercent ? undefined : this.locale,
), unit: this.isInPercent ? '%' : undefined
}),
mode: 'index', mode: 'index',
position: <unknown>'top', position: <unknown>'top',
xAlign: 'center', xAlign: 'center',

View File

@ -7,6 +7,7 @@ import {
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { getDateFnsLocale } from '@ghostfolio/common/helper';
import { PortfolioSummary } from '@ghostfolio/common/interfaces'; import { PortfolioSummary } from '@ghostfolio/common/interfaces';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@ -20,6 +21,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() language: string;
@Input() locale: string; @Input() locale: string;
@Input() summary: PortfolioSummary; @Input() summary: PortfolioSummary;
@ -34,7 +36,9 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
public ngOnChanges() { public ngOnChanges() {
if (this.summary) { if (this.summary) {
if (this.summary.firstOrderDate) { if (this.summary.firstOrderDate) {
this.timeInMarket = formatDistanceToNow(this.summary.firstOrderDate); this.timeInMarket = formatDistanceToNow(this.summary.firstOrderDate, {
locale: getDateFnsLocale(this.language)
});
} else { } else {
this.timeInMarket = '-'; this.timeInMarket = '-';
} }

View File

@ -9,9 +9,11 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; import {
EnhancedSymbolProfile,
LineChartItem
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';

View File

@ -7,7 +7,6 @@ import {
} from '@angular/router'; } from '@angular/router';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service'; import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ViewMode } from '@prisma/client';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -80,13 +79,13 @@ export class AuthGuard implements CanActivate {
return; return;
} else if ( } else if (
state.url.startsWith('/home') && state.url.startsWith('/home') &&
user.settings.viewMode === ViewMode.ZEN user.settings.viewMode === 'ZEN'
) { ) {
this.router.navigate(['/zen']); this.router.navigate(['/zen']);
resolve(false); resolve(false);
return; return;
} else if (state.url.startsWith('/start')) { } else if (state.url.startsWith('/start')) {
if (user.settings.viewMode === ViewMode.ZEN) { if (user.settings.viewMode === 'ZEN') {
this.router.navigate(['/zen']); this.router.navigate(['/zen']);
} else { } else {
this.router.navigate(['/home']); this.router.navigate(['/home']);
@ -96,7 +95,7 @@ export class AuthGuard implements CanActivate {
return; return;
} else if ( } else if (
state.url.startsWith('/zen') && state.url.startsWith('/zen') &&
user.settings.viewMode === ViewMode.DEFAULT user.settings.viewMode === 'DEFAULT'
) { ) {
this.router.navigate(['/home']); this.router.navigate(['/home']);
resolve(false); resolve(false);

View File

@ -108,7 +108,6 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<gf-value <gf-value
i18n
size="large" size="large"
subLabel="(Last 24 hours)" subLabel="(Last 24 hours)"
[value]="statistics?.activeUsers1d ?? '-'" [value]="statistics?.activeUsers1d ?? '-'"
@ -117,7 +116,6 @@
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<gf-value <gf-value
i18n
size="large" size="large"
subLabel="(Last 30 days)" subLabel="(Last 30 days)"
[value]="statistics?.newUsers30d ?? '-'" [value]="statistics?.newUsers30d ?? '-'"
@ -126,7 +124,6 @@
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<gf-value <gf-value
i18n
size="large" size="large"
subLabel="(Last 30 days)" subLabel="(Last 30 days)"
[value]="statistics?.activeUsers30d ?? '-'" [value]="statistics?.activeUsers30d ?? '-'"
@ -139,7 +136,6 @@
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
> >
<gf-value <gf-value
i18n
size="large" size="large"
[value]="statistics?.slackCommunityUsers ?? '-'" [value]="statistics?.slackCommunityUsers ?? '-'"
>Users in Slack community</gf-value >Users in Slack community</gf-value
@ -152,7 +148,6 @@
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors" href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
> >
<gf-value <gf-value
i18n
size="large" size="large"
[value]="statistics?.gitHubContributors ?? '-'" [value]="statistics?.gitHubContributors ?? '-'"
>Contributors on GitHub</gf-value >Contributors on GitHub</gf-value
@ -165,7 +160,6 @@
href="https://github.com/ghostfolio/ghostfolio/stargazers" href="https://github.com/ghostfolio/ghostfolio/stargazers"
> >
<gf-value <gf-value
i18n
size="large" size="large"
[value]="statistics?.gitHubStargazers ?? '-'" [value]="statistics?.gitHubStargazers ?? '-'"
>Stars on GitHub</gf-value >Stars on GitHub</gf-value

View File

@ -54,7 +54,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public language = document.documentElement.lang; public language = document.documentElement.lang;
public locales = ['de', 'de-CH', 'en-GB', 'en-US']; public locales = ['de', 'de-CH', 'en-GB', 'en-US', 'it'];
public price: number; public price: number;
public priceId: string; public priceId: string;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>; public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
@ -175,29 +175,6 @@ export class AccountPageComponent implements OnDestroy, OnInit {
}); });
} }
public onChangeUserSettings(aKey: string, aValue: string) {
const settings = { ...this.user.settings, [aKey]: aValue };
this.dataService
.putUserSettings({
baseCurrency: settings?.baseCurrency,
viewMode: settings?.viewMode
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onCheckout() { public onCheckout() {
this.dataService this.dataService
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) .createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
@ -226,6 +203,24 @@ export class AccountPageComponent implements OnDestroy, OnInit {
}); });
} }
public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onRedeemCoupon() { public onRedeemCoupon() {
let couponCode = prompt($localize`Please enter your coupon code:`); let couponCode = prompt($localize`Please enter your coupon code:`);
couponCode = couponCode?.trim(); couponCode = couponCode?.trim();
@ -249,7 +244,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
) )
.subscribe(() => { .subscribe(() => {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
'✅' + $localize`Coupon code has been redeemed`, '✅ ' + $localize`Coupon code has been redeemed`,
$localize`Reload`, $localize`Reload`,
{ {
duration: 3000 duration: 3000
@ -316,6 +311,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, { const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
data: { data: {
access: { access: {
alias: '',
type: 'PUBLIC' type: 'PUBLIC'
} }
}, },
@ -331,7 +327,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
if (access) { if (access) {
this.dataService this.dataService
.postAccess({}) .postAccess({ alias: access.alias })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe({ .subscribe({
next: () => { next: () => {

View File

@ -99,7 +99,7 @@
name="baseCurrency" name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency" [value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSettings('baseCurrency', $event.value)" (selectionChange)="onChangeUserSetting('baseCurrency', $event.value)"
> >
<mat-option <mat-option
*ngFor="let currency of currencies" *ngFor="let currency of currencies"
@ -119,12 +119,14 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-select <mat-select
name="language" name="language"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="language" [value]="language"
(selectionChange)="onChangeUserSetting('language', $event.value)" (selectionChange)="onChangeUserSetting('language', $event.value)"
> >
<mat-option [value]="null"></mat-option> <mat-option [value]="null"></mat-option>
<mat-option value="de">Deutsch</mat-option> <mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option> <mat-option value="en">English</mat-option>
<mat-option value="it">Italiano</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@ -165,7 +167,7 @@
name="viewMode" name="viewMode"
[disabled]="!hasPermissionToUpdateViewMode" [disabled]="!hasPermissionToUpdateViewMode"
[value]="user.settings.viewMode" [value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)" (selectionChange)="onChangeUserSetting('viewMode', $event.value)"
> >
<mat-option value="DEFAULT">Default</mat-option> <mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option> <mat-option value="ZEN">Zen</mat-option>
@ -187,6 +189,22 @@
></mat-slide-toggle> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div
*ngIf="hasPermissionToUpdateUserSettings && user?.subscription"
class="align-items-center d-flex mt-4 py-1"
>
<div class="pr-1 w-50">
<div i18n>Experimental Features</div>
</div>
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
[checked]="user.settings.isExperimentalFeatures"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onExperimentalFeaturesChange($event)"
></mat-slide-toggle>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50" i18n>User ID</div> <div class="pr-1 w-50" i18n>User ID</div>
<div class="pl-1 w-50">{{ user?.id }}</div> <div class="pl-1 w-50">{{ user?.id }}</div>

View File

@ -1,6 +1,17 @@
<form #addAccessForm="ngForm" class="d-flex flex-column h-100"> <form #addAccessForm="ngForm" class="d-flex flex-column h-100">
<h1 i18n mat-dialog-title>Grant access</h1> <h1 i18n mat-dialog-title>Grant access</h1>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Alias</mat-label>
<input
matInput
name="alias"
type="text"
[(ngModel)]="data.access.alias"
/>
</mat-form-field>
</div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>

View File

@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component'; import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component';
@ -16,6 +17,7 @@ import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.com
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule,
MatSelectModule, MatSelectModule,
ReactiveFormsModule ReactiveFormsModule
] ]

View File

@ -84,7 +84,7 @@
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <a
class="d-flex w-100" class="d-flex w-100"
href="'../en/blog/2022/01/ghostfolio-first-months-in-open-source" href="../en/blog/2022/01/ghostfolio-first-months-in-open-source"
> >
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="h6 m-0 text-truncate"> <div class="h6 m-0 text-truncate">

View File

@ -55,6 +55,28 @@
</div> </div>
</div> </div>
<div class="row my-3">
<div class="col-md-4 my-2">
<mat-card>
<mat-card-title class="text-center">360° View</mat-card-title>
Get the full picture of your personal finances across multiple
platforms.
</mat-card>
</div>
<div class="col-md-4 my-2">
<mat-card>
<mat-card-title class="text-center">Web3 Ready</mat-card-title>
Use Ghostfolio anonymously and own your financial data.
</mat-card>
</div>
<div class="col-md-4 my-2">
<mat-card>
<mat-card-title class="text-center">Open Source</mat-card-title>
Benefit from continuous improvements through a strong community.
</mat-card>
</div>
</div>
<div class="row my-5"> <div class="row my-5">
<div class="col-md-6 offset-md-3"> <div class="col-md-6 offset-md-3">
<h2 class="h4 mb-1 text-center">Why <strong>Ghostfolio</strong>?</h2> <h2 class="h4 mb-1 text-center">Why <strong>Ghostfolio</strong>?</h2>
@ -133,19 +155,43 @@
</div> </div>
</div> </div>
<div class="row my-5"> <div class="row my-3">
<div class="col-md-6 offset-md-3"> <div class="col-12">
<h2 class="h4 mb-1 text-center"> <h2 class="h4 mb-1 text-center">
How does <strong>Ghostfolio</strong> work? How does <strong>Ghostfolio</strong> work?
</h2> </h2>
<p class="lead mb-3 text-center">Get started in only 3 steps</p> <p class="lead mb-3 text-center">Get started in only 3 steps</p>
<ol class="m-0 pl-3"> </div>
<li class="mb-2"> <div class="col-md-4 my-2">
Sign up anonymously<br />(no e-mail address nor credit card required) <mat-card class="d-flex flex-row h-100">
</li> <div class="flex-grow-1">
<li class="mb-2">Add any of your historical transactions</li> <div class="font-weight-bold">Sign up anonymously*</div>
<li>Get valuable insights of your portfolio composition</li> <div class="text-muted">
</ol> <small>* no e-mail address nor credit card required</small>
</div>
</div>
<div class="pl-2 text-muted text-right">1</div>
</mat-card>
</div>
<div class="col-md-4 my-2">
<mat-card class="d-flex flex-row h-100">
<div class="flex-grow-1">
<div class="font-weight-bold">
Add any of your historical transactions
</div>
</div>
<div class="pl-2 text-muted text-right">2</div>
</mat-card>
</div>
<div class="col-md-4 my-2">
<mat-card class="d-flex flex-row h-100">
<div class="flex-grow-1">
<div class="font-weight-bold">
Get valuable insights of your portfolio composition
</div>
</div>
<div class="pl-2 text-muted text-right">3</div>
</mat-card>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfLogoModule } from '@ghostfolio/ui/logo'; import { GfLogoModule } from '@ghostfolio/ui/logo';
@ -14,6 +15,7 @@ import { LandingPageComponent } from './landing-page.component';
GfLogoModule, GfLogoModule,
LandingPageRoutingModule, LandingPageRoutingModule,
MatButtonModule, MatButtonModule,
MatCardModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -54,8 +54,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}; };
public period = 'current'; public period = 'current';
public periodOptions: ToggleOption[] = [ public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' }, { label: $localize`Initial`, value: 'original' },
{ label: 'Current', value: 'current' } { label: $localize`Current`, value: 'current' }
]; ];
public placeholder = ''; public placeholder = '';
public portfolioDetails: PortfolioDetails; public portfolioDetails: PortfolioDetails;
@ -85,7 +85,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public user: User; public user: User;
private readonly SEARCH_PLACEHOLDER = 'Filter by account or tag...';
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@ -133,7 +132,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.isLoading = true; this.isLoading = true;
this.activeFilters = filters; this.activeFilters = filters;
this.placeholder = this.placeholder =
this.activeFilters.length <= 0 ? this.SEARCH_PLACEHOLDER : ''; this.activeFilters.length <= 0
? $localize`Filter by account or tag...`
: '';
return this.dataService.fetchPortfolioDetails({ return this.dataService.fetchPortfolioDetails({
filters: this.activeFilters filters: this.activeFilters

View File

@ -10,6 +10,22 @@
></gf-activities-filter> ></gf-activities-filter>
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<mat-card class="mb-3">
<mat-card-header>
<mat-card-title i18n>Proportion of Net Worth</mat-card-title>
</mat-card-header>
<mat-card-content>
<mat-progress-bar
mode="determinate"
[title]="(portfolioDetails?.filteredValueInPercentage * 100).toFixed(2) + '%'"
[value]="portfolioDetails?.filteredValueInPercentage * 100"
></mat-progress-bar>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="proportion-charts row"> <div class="proportion-charts row">
<div class="col-md-4"> <div class="col-md-4">
<mat-card class="mb-3"> <mat-card class="mb-3">

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module'; import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
@ -22,7 +23,8 @@ import { AllocationsPageComponent } from './allocations-page.component';
GfToggleModule, GfToggleModule,
GfWorldMapChartModule, GfWorldMapChartModule,
GfValueModule, GfValueModule,
MatCardModule MatCardModule,
MatProgressBarModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -28,4 +28,33 @@
} }
} }
} }
.mat-progress-bar {
border-radius: 0.25rem;
height: 0.5rem;
::ng-deep {
.mat-progress-bar-background {
fill: rgb(var(--palette-background-unselected-chip));
}
.mat-progress-bar-buffer {
background-color: rgb(var(--palette-background-unselected-chip));
}
}
}
}
:host-context(.is-dark-theme) {
.mat-progress-bar {
::ng-deep {
.mat-progress-bar-background {
fill: rgb(var(--palette-background-unselected-chip-dark));
}
.mat-progress-bar-buffer {
background-color: rgb(var(--palette-background-unselected-chip-dark));
}
}
}
} }

View File

@ -2,9 +2,14 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Position, User } from '@ghostfolio/common/interfaces'; import {
HistoricalDataItem,
Position,
User
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { GroupBy, ToggleOption } from '@ghostfolio/common/types'; import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types';
import { SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -18,17 +23,22 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './analysis-page.html' templateUrl: './analysis-page.html'
}) })
export class AnalysisPageComponent implements OnDestroy, OnInit { export class AnalysisPageComponent implements OnDestroy, OnInit {
public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[];
public bottom3: Position[]; public bottom3: Position[];
public daysInMarket: number; public daysInMarket: number;
public deviceType: string; public deviceType: string;
public firstOrderDate: Date;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public investments: InvestmentItem[]; public investments: InvestmentItem[];
public investmentsByMonth: InvestmentItem[]; public investmentsByMonth: InvestmentItem[];
public isLoadingBenchmarkComparator: boolean;
public mode: GroupBy; public mode: GroupBy;
public modeOptions: ToggleOption[] = [ public modeOptions: ToggleOption[] = [
{ label: 'Monthly', value: 'month' }, { label: $localize`Monthly`, value: 'month' },
{ label: 'Accumulating', value: undefined } { label: $localize`Accumulating`, value: undefined }
]; ];
public performanceDataItems: HistoricalDataItem[];
public top3: Position[]; public top3: Position[];
public user: User; public user: User;
@ -40,7 +50,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private userService: UserService private userService: UserService
) {} ) {
const { benchmarks } = this.dataService.fetchInfo();
this.benchmarks = benchmarks;
}
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
@ -52,6 +65,79 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
}); });
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.update();
}
});
}
public onChangeBenchmark(symbolProfileId: string) {
this.dataService
.putUserSetting({ benchmark: symbolProfileId })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onChangeDateRange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private update() {
if (this.user.settings.isExperimentalFeatures) {
this.isLoadingBenchmarkComparator = true;
this.dataService
.fetchChart({ range: this.user?.settings?.dateRange, version: 2 })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {
this.firstOrderDate = new Date(chart?.[0]?.date ?? new Date());
this.performanceDataItems = chart;
this.updateBenchmarkDataItems();
this.changeDetectorRef.markForCheck();
});
}
this.dataService this.dataService
.fetchInvestments() .fetchInvestments()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -91,23 +177,37 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.userService.stateChanged this.changeDetectorRef.markForCheck();
.pipe(takeUntil(this.unsubscribeSubject)) }
.subscribe((state) => {
if (state?.user) { private updateBenchmarkDataItems() {
this.user = state.user; if (this.user.settings.benchmark) {
const { dataSource, symbol } =
this.benchmarks.find(({ id }) => {
return id === this.user.settings.benchmark;
}) ?? {};
this.dataService
.fetchBenchmarkBySymbol({
dataSource,
symbol,
startDate: this.firstOrderDate
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.benchmarkDataItems = marketData.map(({ date, value }) => {
return {
date,
value
};
});
this.isLoadingBenchmarkComparator = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} });
}); } else {
} this.isLoadingBenchmarkComparator = false;
}
public onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
} }
} }

Some files were not shown because too many files have changed in this diff Show More