Compare commits
166 Commits
Author | SHA1 | Date | |
---|---|---|---|
f72d31bab3 | |||
4c893c4dcc | |||
ffb11cd10e | |||
d424b7731e | |||
6043c87481 | |||
fca0a688b6 | |||
5c6cc4fed5 | |||
64a7d38ff9 | |||
68d0d39161 | |||
233a8a8a18 | |||
190779ee35 | |||
6ef8121561 | |||
58bf57d1e6 | |||
71c5412dd5 | |||
ae85398c3d | |||
048900d01b | |||
074b09b543 | |||
f9e04022f4 | |||
8fd1fbd44a | |||
0fb33ae71c | |||
3a35d72ec2 | |||
32fe3e195f | |||
805f4b05be | |||
5b51a6840a | |||
36bd6164e6 | |||
eac52a215b | |||
9ff8cd5471 | |||
33cc7e4e7e | |||
47f84dab06 | |||
384d18b2a6 | |||
2363983bdc | |||
4af76764be | |||
a65424aafa | |||
f9cd629470 | |||
ccb8c86596 | |||
246de7aa86 | |||
a323313c71 | |||
538c8947cd | |||
1ec5fd12fe | |||
4376b8903e | |||
a8e096f9ac | |||
8e577592f6 | |||
c896bf9199 | |||
16145f18d9 | |||
5398da0dc8 | |||
2466f4ff5d | |||
8f3a9bdfbb | |||
44dfd2bd48 | |||
3fc2228f1d | |||
b018819a1f | |||
ac9311d783 | |||
e23ce0f35d | |||
f4b52aa41c | |||
655b040d4d | |||
0f637a5d0f | |||
3f85c327f5 | |||
c2df99072d | |||
e8afbcad9c | |||
e6d8de781b | |||
6e1935899f | |||
169cb85b66 | |||
fe6658d0ac | |||
1f0381228e | |||
f4b63b5de5 | |||
e45a0ad068 | |||
81c6cc021d | |||
859b24aa5b | |||
2bc325f182 | |||
a6186c23e2 | |||
cf234003ec | |||
8d3954304e | |||
9562139fa6 | |||
c857ea9a8f | |||
5c9fa71d95 | |||
fefbfa31d1 | |||
93a1fae51c | |||
3715edd9ba | |||
e3916e1ba3 | |||
76ceac4edc | |||
333b63bfe2 | |||
3006c21b12 | |||
f01a3f893d | |||
72974e888f | |||
0cee7a0b35 | |||
f3d337b044 | |||
7667af059c | |||
1095b47f45 | |||
dacd7271eb | |||
e093041184 | |||
8f2caa508a | |||
862f670ccf | |||
54bf4c7a43 | |||
c0ace51ee9 | |||
b1b5689242 | |||
b68cdaf8ea | |||
b387a80a0d | |||
6e4660295a | |||
d4c3a9d1e8 | |||
263f6b32f2 | |||
637f31ae3b | |||
547e27c7a1 | |||
f10dc176f2 | |||
0a966e46cd | |||
4f281d25e1 | |||
aaba8c35c2 | |||
7d27cb3398 | |||
91678028b5 | |||
5e3cac8ac9 | |||
33f20b6b48 | |||
e4fd255dd7 | |||
e320aa91f7 | |||
0fcfa6c1bd | |||
42d32ed652 | |||
21b4b0ef24 | |||
4f8fe83a16 | |||
980ad1028c | |||
0d5bc3f51b | |||
aece76d98f | |||
fc4bb71184 | |||
20bc7ef99c | |||
7a733ae49b | |||
376ce88492 | |||
c4d83aabe7 | |||
d4e2cec77e | |||
75db7bf79a | |||
3ad99c9991 | |||
00e402d286 | |||
4ac0484025 | |||
75d61bff6d | |||
0de28d733e | |||
3b2f13850c | |||
0cc42ffd7c | |||
3ccb812ac3 | |||
0a8549db3e | |||
c95e90ff31 | |||
b59af0d864 | |||
408bdbd187 | |||
a3bfa46fb0 | |||
8cb1b3f925 | |||
15c650f951 | |||
c198bd78da | |||
35963580bc | |||
cf2c5bad02 | |||
f332aea9b4 | |||
7a9fd18407 | |||
ca08d3154a | |||
01d4ae8757 | |||
43ce2786c1 | |||
de2092c4d2 | |||
435a180e54 | |||
0ad30ffabe | |||
0cc5e558f1 | |||
63b183cc6f | |||
10bae24c5c | |||
0e29278e96 | |||
2db46e5bbf | |||
e757e90e5a | |||
184ddc6209 | |||
e3662a143c | |||
25afd7e07b | |||
7fceaa1350 | |||
7c8530483c | |||
539d3ff754 | |||
9d28b63da6 | |||
24abbd85e6 | |||
b6f395fd3b |
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
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).
|
||||
|
||||
**Bug Description**
|
||||
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**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 X.Y.Z
|
||||
- Browser
|
||||
- OS
|
||||
|
||||
**Additional context**
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
36
.github/workflows/build-code.yml
vendored
Normal file
36
.github/workflows/build-code.yml
vendored
Normal 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
49
.github/workflows/docker-image.yml
vendored
Normal 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
|
@ -1,4 +1,13 @@
|
||||
{
|
||||
"attributeGroups": [
|
||||
"$ANGULAR_ELEMENT_REF",
|
||||
"$ANGULAR_STRUCTURAL_DIRECTIVE",
|
||||
"$DEFAULT",
|
||||
"$ANGULAR_INPUT",
|
||||
"$ANGULAR_TWO_WAY_BINDING",
|
||||
"$ANGULAR_OUTPUT"
|
||||
],
|
||||
"attributeSort": "ASC",
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
|
30
.travis.yml
30
.travis.yml
@ -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
|
325
CHANGELOG.md
325
CHANGELOG.md
@ -5,6 +5,325 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.207.0 - 31.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for translated labels of asset and asset sub class
|
||||
- Added support for dates in _ISO 8601_ date format (`YYYY-MM-DD`) in the activities import
|
||||
|
||||
### Changed
|
||||
|
||||
- Darkened the background color of the dark mode
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the public page
|
||||
- Improved the loading indicator of the portfolio evolution chart
|
||||
|
||||
## 1.206.2 - 20.10.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed the `rxjs` version to `7.5.6` (resolutions)
|
||||
- Migrated the `angular.json` to `project.json` files in the `Nx` workspace
|
||||
- Upgraded `nestjs` from version `9.0.7` to `9.1.4`
|
||||
- Upgraded `Nx` from version `14.6.4` to `15.0.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the performance calculation including `SELL` activities with a significant performance gain
|
||||
|
||||
## 1.205.2 - 16.10.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Persisted the language on url change
|
||||
- Improved the portfolio evolution chart
|
||||
- Refactored the appearance (dark mode) in user settings (from `appearance` to `colorScheme`)
|
||||
- Improved the wording on the landing page
|
||||
|
||||
## 1.204.1 - 15.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to change the appearance (dark mode) in user settings
|
||||
- Added the total amount chart to the investment timeline
|
||||
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||
|
||||
### Changed
|
||||
|
||||
- Respected the current date in the _FIRE_ calculator
|
||||
- Simplified the settings management in the admin control panel
|
||||
- Renamed the data source type `RAKUTEN` to `RAPID_API`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed some links in the blog posts
|
||||
- Fixed the alignment of the value component on the allocations page
|
||||
|
||||
### Todo
|
||||
|
||||
- Rename the environment variable from `RAKUTEN_RAPID_API_KEY` to `RAPID_API_API_KEY`
|
||||
|
||||
## 1.203.0 - 08.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Supported a progressive line animation in the line chart component
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the benchmark comparator from experimental to general availability
|
||||
- Improved the user interface of the benchmark comparator
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the performance and chart calculation of today
|
||||
- Fixed the alignment of the value component in the admin control panel
|
||||
|
||||
## 1.202.0 - 07.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for a translated 4% rule in the _FIRE_ section
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the caching of the benchmarks in the markets overview (only cache if fetching was successful)
|
||||
- Improved the wording in the twitter bot service
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the support for cryptocurrencies having a symbol with less than 3 characters (e.g. `SC-USD`)
|
||||
- Fixed the text truncation in the value component
|
||||
|
||||
## 1.201.0 - 01.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a blog post: _Hacktoberfest 2022_
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usage of the value component in the admin control panel
|
||||
- Improved the language localization for Español (`es`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the usage of the value component on the allocations page
|
||||
|
||||
## 1.200.0 - 01.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a mini statistics section to the landing page including pulls on _Docker Hub_
|
||||
- Added an _As seen in_ section to the landing page
|
||||
- Added support for an icon in the value component
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `prisma` from version `4.1.1` to `4.4.0`
|
||||
|
||||
## 1.199.1 - 27.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for Español (`es`)
|
||||
- Added support for sectors in mutual funds
|
||||
|
||||
## 1.198.0 - 25.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to exclude an account from analysis
|
||||
- Set up the language localization for Nederlands (`nl`)
|
||||
|
||||
## 1.197.0 - 24.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the value of the active filter in percentage on the allocations page
|
||||
- Extended the feature overview page by multi-language support (English, German, Italian)
|
||||
|
||||
### Changed
|
||||
|
||||
- Combined the performance and chart calculation
|
||||
- Improved the style of various selectors (density)
|
||||
|
||||
## 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
|
||||
@ -29,7 +348,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- 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
|
||||
|
||||
### Changed
|
||||
@ -691,8 +1010,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Display the value in base currency in the accounts table on mobile
|
||||
- Display the value in base currency in the activities table on mobile
|
||||
- Displayed the value in base currency in the accounts table on mobile
|
||||
- Displayed the value in base currency in the activities table on mobile
|
||||
- Renamed `orders` to `activities` in import and export functionality
|
||||
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
|
||||
- Improved the pricing page
|
||||
|
19
Dockerfile
19
Dockerfile
@ -1,7 +1,6 @@
|
||||
FROM node:16-alpine as builder
|
||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
||||
|
||||
# Build application and add additional files
|
||||
|
||||
WORKDIR /ghostfolio
|
||||
|
||||
# Only add basic files without the application itself to avoid rebuilding
|
||||
@ -10,16 +9,22 @@ COPY ./CHANGELOG.md CHANGELOG.md
|
||||
COPY ./LICENSE LICENSE
|
||||
COPY ./package.json package.json
|
||||
COPY ./yarn.lock yarn.lock
|
||||
COPY ./.yarnrc .yarnrc
|
||||
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
|
||||
|
||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
RUN node decorate-angular-cli.js
|
||||
|
||||
COPY ./angular.json angular.json
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.js replace.build.js
|
||||
COPY ./jest.preset.js jest.preset.js
|
||||
@ -45,7 +50,11 @@ COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# 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
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE 3333
|
||||
|
33
README.md
33
README.md
@ -12,13 +12,11 @@
|
||||
<strong>Open Source Wealth Management Software</strong>
|
||||
</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>
|
||||
<a href="#contributing">
|
||||
<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">
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||
</p>
|
||||
@ -33,7 +31,7 @@
|
||||
|
||||
## 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.
|
||||
|
||||
@ -81,6 +79,8 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
||||
|
||||
## 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
|
||||
|
||||
| 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_PASSWORD` | | The password 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_PORT` | `6379` | The port where _Redis_ is running |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -153,6 +153,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
||||
### Setup
|
||||
|
||||
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 `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))
|
||||
@ -189,20 +190,22 @@ Run `yarn test`
|
||||
|
||||
## Public API
|
||||
|
||||
### Authorization: Bearer Token
|
||||
|
||||
Set the header for each request as follows:
|
||||
|
||||
```
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||
|
||||
### Import Activities
|
||||
|
||||
#### Request
|
||||
|
||||
`POST http://localhost:3333/api/v1/import`
|
||||
|
||||
#### Authorization: Bearer Token
|
||||
|
||||
Set the header as follows:
|
||||
|
||||
```
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
#### Body
|
||||
|
||||
```
|
||||
@ -259,7 +262,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.
|
||||
|
||||
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
|
||||
|
||||
|
357
angular.json
357
angular.json
@ -1,357 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"projects": {
|
||||
"api": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/api",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"prefix": "api",
|
||||
"schematics": {},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@nrwl/node:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/api/src/environments/environment.ts",
|
||||
"with": "apps/api/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@nrwl/node:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/api"]
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "apps/client",
|
||||
"sourceRoot": "apps/client/src",
|
||||
"prefix": "gf",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/client",
|
||||
"index": "apps/client/src/index.html",
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "assetlinks.json",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../.well-known"
|
||||
},
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/ionicons/dist/ionicons",
|
||||
"output": "./../ionicons"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.js",
|
||||
"input": "node_modules/ionicons/dist/",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../assets/"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
},
|
||||
"development-en": {
|
||||
"baseHref": "/en/",
|
||||
"localize": ["en"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/client/src/environments/environment.ts",
|
||||
"with": "apps/client/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "10kb"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"proxyConfig": "apps/client/proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"browserTarget": "client:build:development-de"
|
||||
},
|
||||
"development-en": {
|
||||
"browserTarget": "client:build:development-en"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": ["messages.de.xlf"]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/client/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/client"]
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/client-e2e",
|
||||
"sourceRoot": "apps/client-e2e/src",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/client-e2e/cypress.json",
|
||||
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
|
||||
"devServerTarget": "client:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "client:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
},
|
||||
"common": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "libs/common",
|
||||
"sourceRoot": "libs/common/src",
|
||||
"projectType": "library",
|
||||
"architect": {
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/common/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/common"],
|
||||
"options": {
|
||||
"jestConfig": "libs/common/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "libs/ui",
|
||||
"sourceRoot": "libs/ui/src",
|
||||
"prefix": "gf",
|
||||
"architect": {
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/ui"],
|
||||
"options": {
|
||||
"jestConfig": "libs/ui/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"port": 4400,
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@storybook/angular:build-storybook",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputDir": "dist/storybook/ui",
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/ui-e2e",
|
||||
"sourceRoot": "apps/ui-e2e/src",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/ui-e2e/cypress.json",
|
||||
"devServerTarget": "ui:storybook",
|
||||
"tsConfig": "apps/ui-e2e/tsconfig.json"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"devServerTarget": "ui:storybook:ci"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["ui"]
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'api',
|
||||
|
||||
|
56
apps/api/project.json
Normal file
56
apps/api/project.json
Normal file
@ -0,0 +1,56 @@
|
||||
{
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"prefix": "api",
|
||||
"generators": {},
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/webpack:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"],
|
||||
"target": "node",
|
||||
"compiler": "tsc"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/api/src/environments/environment.ts",
|
||||
"with": "apps/api/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nrwl/node:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
@ -42,14 +42,16 @@ export class AccessController {
|
||||
return accessesWithGranteeUser.map((access) => {
|
||||
if (access.GranteeUser) {
|
||||
return {
|
||||
granteeAlias: access.GranteeUser?.alias,
|
||||
alias: access.alias,
|
||||
grantee: access.GranteeUser?.id,
|
||||
id: access.id,
|
||||
type: 'RESTRICTED_VIEW'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
granteeAlias: 'Public',
|
||||
alias: access.alias,
|
||||
grantee: 'Public',
|
||||
id: access.id,
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
@ -71,6 +73,10 @@ export class AccessController {
|
||||
}
|
||||
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
}
|
||||
|
@ -1 +1,11 @@
|
||||
export class CreateAccessDto {}
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateAccessDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
alias?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
granteeUserId?: string;
|
||||
}
|
||||
|
@ -95,9 +95,10 @@ export class AccountController {
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
@ -137,10 +138,11 @@ export class AccountController {
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id,
|
||||
[{ id, type: 'ACCOUNT' }]
|
||||
);
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
filters: [{ id, type: 'ACCOUNT' }],
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
|
@ -107,15 +107,23 @@ export class AccountService {
|
||||
public async getCashDetails({
|
||||
currency,
|
||||
filters = [],
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
currency: string;
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<CashDetails> {
|
||||
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||
|
||||
const where: Prisma.AccountWhereInput = { userId };
|
||||
const where: Prisma.AccountWhereInput = {
|
||||
userId
|
||||
};
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.isExcluded = false;
|
||||
}
|
||||
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
|
@ -1,5 +1,11 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString()
|
||||
@ -11,6 +17,10 @@ export class CreateAccountDto {
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
|
@ -1,5 +1,11 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
@ -14,6 +20,10 @@ export class UpdateAccountDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
|
@ -8,7 +8,8 @@ import {
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails
|
||||
AdminMarketDataDetails,
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
@ -22,6 +23,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
@ -226,7 +228,9 @@ export class AdminController {
|
||||
|
||||
@Get('market-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketData(): Promise<AdminMarketData> {
|
||||
public async getMarketData(
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
||||
): Promise<AdminMarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
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')
|
||||
|
@ -11,11 +11,13 @@ import {
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem,
|
||||
Filter,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Property } from '@prisma/client';
|
||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
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({
|
||||
_count: true,
|
||||
by: ['dataSource', 'symbol']
|
||||
});
|
||||
|
||||
const currencyPairsToGather: AdminMarketDataItem[] =
|
||||
this.exchangeRateDataService
|
||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||
|
||||
if (filtersByAssetSubClass) {
|
||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||
} else {
|
||||
currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
const marketDataItemCount =
|
||||
@ -84,17 +99,24 @@ export class AdminService {
|
||||
return {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol
|
||||
symbol,
|
||||
countriesCount: 0,
|
||||
sectorsCount: 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
where,
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
countries: true,
|
||||
dataSource: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
@ -102,10 +124,14 @@ export class AdminService {
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
sectors: true,
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
const countriesCount = symbolProfile.countries
|
||||
? Object.keys(symbolProfile.countries).length
|
||||
: 0;
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
@ -113,10 +139,17 @@ export class AdminService {
|
||||
marketDataItem.symbol === symbolProfile.symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = symbolProfile.sectors
|
||||
? Object.keys(symbolProfile.sectors).length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
countriesCount,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activityCount: symbolProfile._count.Order,
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
@ -148,10 +181,10 @@ export class AdminService {
|
||||
public async putSetting(key: string, value: string) {
|
||||
let response: Property;
|
||||
|
||||
if (value === '') {
|
||||
response = await this.propertyService.delete({ key });
|
||||
} else {
|
||||
if (value) {
|
||||
response = await this.propertyService.put({ key, value });
|
||||
} else {
|
||||
response = await this.propertyService.delete({ key });
|
||||
}
|
||||
|
||||
if (key === PROPERTY_CURRENCIES) {
|
||||
|
@ -54,7 +54,7 @@ export class WebAuthService {
|
||||
rpName: 'Ghostfolio',
|
||||
rpID: this.rpID,
|
||||
userID: user.id,
|
||||
userName: user.alias,
|
||||
userName: '',
|
||||
timeout: 60000,
|
||||
attestationType: 'indirect',
|
||||
authenticatorSelection: {
|
||||
|
@ -1,30 +1,48 @@
|
||||
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
|
||||
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse
|
||||
} 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';
|
||||
|
||||
@Controller('benchmark')
|
||||
export class BenchmarkController {
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {}
|
||||
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
||||
|
||||
@Get()
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||
const benchmarkAssets: UniqueAsset[] =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as UniqueAsset[]) ?? [];
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
@ -18,6 +19,7 @@ import { BenchmarkService } from './benchmark.service';
|
||||
MarketDataModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [BenchmarkService]
|
||||
|
15
apps/api/src/app/benchmark/benchmark.service.spec.ts
Normal file
15
apps/api/src/app/benchmark/benchmark.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -1,10 +1,24 @@
|
||||
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 { 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 { 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 { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
export class BenchmarkService {
|
||||
@ -13,76 +27,184 @@ export class BenchmarkService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
private readonly symbolProfileService: SymbolProfileService,
|
||||
private readonly symbolService: SymbolService
|
||||
) {}
|
||||
|
||||
public async getBenchmarks(
|
||||
benchmarkAssets: UniqueAsset[]
|
||||
): Promise<BenchmarkResponse['benchmarks']> {
|
||||
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
||||
if (baseValue && currentValue) {
|
||||
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async getBenchmarks({ useCache = true } = {}): Promise<
|
||||
BenchmarkResponse['benchmarks']
|
||||
> {
|
||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||
|
||||
try {
|
||||
benchmarks = JSON.parse(
|
||||
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||
);
|
||||
if (useCache) {
|
||||
try {
|
||||
benchmarks = JSON.parse(
|
||||
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||
);
|
||||
|
||||
if (benchmarks) {
|
||||
return benchmarks;
|
||||
}
|
||||
} catch {}
|
||||
if (benchmarks) {
|
||||
return benchmarks;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||
|
||||
const promises: Promise<number>[] = [];
|
||||
|
||||
const [quotes, assetProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes(benchmarkAssets),
|
||||
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
|
||||
]);
|
||||
const quotes = await this.dataProviderService.getQuotes(
|
||||
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
|
||||
for (const benchmarkAsset of benchmarkAssets) {
|
||||
promises.push(this.marketDataService.getMax(benchmarkAsset));
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||
}
|
||||
|
||||
const allTimeHighs = await Promise.all(promises);
|
||||
let storeInCache = true;
|
||||
|
||||
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) {
|
||||
performancePercentFromAllTimeHigh = new Big(marketPrice)
|
||||
.div(allTimeHigh)
|
||||
.minus(1);
|
||||
if (allTimeHigh && marketPrice) {
|
||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||
allTimeHigh,
|
||||
marketPrice
|
||||
);
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
),
|
||||
name: assetProfiles.find(({ dataSource, symbol }) => {
|
||||
return (
|
||||
dataSource === benchmarkAssets[index].dataSource &&
|
||||
symbol === benchmarkAssets[index].symbol
|
||||
);
|
||||
})?.name,
|
||||
name: benchmarkAssetProfiles[index].name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
performancePercent: performancePercentFromAllTimeHigh.toNumber()
|
||||
performancePercent: performancePercentFromAllTimeHigh
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks)
|
||||
);
|
||||
if (storeInCache) {
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: Big) {
|
||||
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||
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;
|
||||
const response = {
|
||||
marketData: [
|
||||
...marketDataItems
|
||||
.filter((marketDataItem, index) => {
|
||||
return index % step === 0;
|
||||
})
|
||||
.map((marketDataItem) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value:
|
||||
marketPriceAtStartDate === 0
|
||||
? 0
|
||||
: this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
marketDataItem.marketPrice
|
||||
) * 100
|
||||
};
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
if (currentSymbolItem?.marketPrice) {
|
||||
response.marketData.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
value:
|
||||
this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
currentSymbolItem.marketPrice
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: number) {
|
||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||
}
|
||||
}
|
||||
|
@ -4,22 +4,51 @@ import * as path from 'path';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlDe = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('de'),
|
||||
'utf8'
|
||||
);
|
||||
public indexHtmlEn = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||
'utf8'
|
||||
);
|
||||
public indexHtmlDe = '';
|
||||
public indexHtmlEn = '';
|
||||
public indexHtmlEs = '';
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public isProduction: boolean;
|
||||
|
||||
public constructor(
|
||||
private readonly configService: ConfigService,
|
||||
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.indexHtmlEs = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('es'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlIt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('it'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlNl = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('nl'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public use(req: Request, res: Response, next: NextFunction) {
|
||||
let featureGraphicPath = 'assets/cover.png';
|
||||
@ -29,9 +58,18 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||
} else if (
|
||||
req.path === '/en/blog/2022/10/hacktoberfest-2022' ||
|
||||
req.path === '/en/blog/2022/10/hacktoberfest-2022/'
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) {
|
||||
if (
|
||||
req.path.startsWith('/api/') ||
|
||||
this.isFileRequest(req.url) ||
|
||||
!this.isProduction
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
||||
@ -43,6 +81,33 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/es' || req.path.startsWith('/es/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlEs, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'es',
|
||||
path: req.path,
|
||||
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 if (req.path === '/nl' || req.path.startsWith('/nl/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlNl, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'nl',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlEn, {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||
|
||||
import { InfoService } from './info.service';
|
||||
|
||||
@ -8,6 +9,7 @@ export class InfoController {
|
||||
public constructor(private readonly infoService: InfoService) {}
|
||||
|
||||
@Get()
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getInfo(): Promise<InfoItem> {
|
||||
return this.infoService.get();
|
||||
}
|
||||
|
@ -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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
@ -16,6 +17,7 @@ import { InfoService } from './info.service';
|
||||
@Module({
|
||||
controllers: [InfoController],
|
||||
imports: [
|
||||
BenchmarkModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
@ -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 { 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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
@ -13,7 +13,10 @@ import {
|
||||
PROPERTY_SYSTEM_MESSAGE,
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
} from '@ghostfolio/common/config';
|
||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
encodeDataSource,
|
||||
extractNumberFromString
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.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 { JwtService } from '@nestjs/jwt';
|
||||
import * as bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
@ -28,9 +32,9 @@ export class InfoService {
|
||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@ -106,6 +110,7 @@ export class InfoService {
|
||||
platforms,
|
||||
systemMessage,
|
||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
statistics: await this.getStatistics(),
|
||||
@ -140,10 +145,10 @@ export class InfoService {
|
||||
});
|
||||
}
|
||||
|
||||
private async countGitHubContributors(): Promise<number> {
|
||||
private async countDockerHubPulls(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
|
||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||
'GET',
|
||||
'json',
|
||||
200,
|
||||
@ -152,8 +157,33 @@ export class InfoService {
|
||||
}
|
||||
);
|
||||
|
||||
const contributors = await get();
|
||||
return contributors?.length;
|
||||
const { pull_count } = await get();
|
||||
return pull_count;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async countGitHubContributors(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
'https://github.com/ghostfolio/ghostfolio',
|
||||
'GET',
|
||||
'string',
|
||||
200,
|
||||
{}
|
||||
);
|
||||
|
||||
const html = await get();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
return extractNumberFromString(
|
||||
$(
|
||||
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||
).text()
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService');
|
||||
|
||||
@ -236,6 +266,8 @@ export class InfoService {
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const newUsers30d = await this.countNewUsers(30);
|
||||
|
||||
const dockerHubPulls = await this.countDockerHubPulls();
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||
@ -243,6 +275,7 @@ export class InfoService {
|
||||
statistics = {
|
||||
activeUsers1d,
|
||||
activeUsers30d,
|
||||
dockerHubPulls,
|
||||
gitHubContributors,
|
||||
gitHubStargazers,
|
||||
newUsers30d,
|
||||
|
@ -3,8 +3,8 @@ import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
@ -36,6 +36,7 @@ import { UpdateOrderDto } from './update-order.dto';
|
||||
@Controller('order')
|
||||
export class OrderController {
|
||||
public constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
@ -73,43 +74,25 @@ export class OrderController {
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Activities> {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
const filters: Filter[] = [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
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({
|
||||
filters,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userId: impersonationUserId || this.request.user.id
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
|
@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -18,6 +19,7 @@ import { OrderService } from './order.service';
|
||||
controllers: [OrderController],
|
||||
exports: [OrderService],
|
||||
imports: [
|
||||
ApiModule,
|
||||
CacheModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
|
@ -189,13 +189,15 @@ export class OrderService {
|
||||
includeDrafts = false,
|
||||
types,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<Activity[]> {
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
@ -284,24 +286,28 @@ export class OrderService {
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
).map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
)
|
||||
.filter((order) => {
|
||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
||||
})
|
||||
.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async updateOrder({
|
||||
|
@ -21,6 +21,17 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'BTCUSD':
|
||||
if (isSameDay(parseDate('2015-01-01'), date)) {
|
||||
return { marketPrice: 314.25 };
|
||||
} else if (isSameDay(parseDate('2017-12-31'), date)) {
|
||||
return { marketPrice: 14156.4 };
|
||||
} else if (isSameDay(parseDate('2018-01-01'), date)) {
|
||||
return { marketPrice: 13657.2 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'NOVN.SW':
|
||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||
return { marketPrice: 87.8 };
|
||||
|
@ -0,0 +1,110 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BTCUSD buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2015-01-01',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BTCUSD',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(320.43)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2017-12-31',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BTCUSD',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(14156.4)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2015-01-01')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('13657.2'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('320.43'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
investment: new Big('320.43'),
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
marketPrice: 13657.2,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'BTCUSD',
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('320.43')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2017-12-31', investment: new Big('320.43') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -22,7 +22,7 @@ describe('PortfolioCalculator', () => {
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell', async () => {
|
||||
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
|
@ -14,13 +14,14 @@ import {
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
max,
|
||||
min,
|
||||
set
|
||||
} 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 { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
@ -167,13 +168,148 @@ export class PortfolioCalculator {
|
||||
this.transactionPoints = transactionPoints;
|
||||
}
|
||||
|
||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
||||
if (!this.transactionPoints?.length) {
|
||||
public async getChartData(start: Date, end = new Date(Date.now()), step = 1) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (!isSameDay(last(dates), end)) {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
||||
const netPerformanceInPercentage = totalInvestmentValues[date].eq(0)
|
||||
? 0
|
||||
: totalNetPerformanceValues[date]
|
||||
.div(totalInvestmentValues[date])
|
||||
.mul(100)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
||||
totalInvestment: totalInvestmentValues[date].toNumber(),
|
||||
value: totalInvestmentValues[date]
|
||||
.plus(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 {
|
||||
currentValue: new Big(0),
|
||||
hasErrors: false,
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
positions: [],
|
||||
@ -182,39 +318,38 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
const lastTransactionPoint =
|
||||
this.transactionPoints[this.transactionPoints.length - 1];
|
||||
|
||||
// use Date.now() to use the mock for today
|
||||
const today = new Date(Date.now());
|
||||
transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1];
|
||||
|
||||
let firstTransactionPoint: TransactionPoint = null;
|
||||
let firstIndex = this.transactionPoints.length;
|
||||
let firstIndex = transactionPointsBeforeEndDate.length;
|
||||
const dates = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
|
||||
dates.push(resetHours(start));
|
||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
||||
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
currencies[item.symbol] = item.currency;
|
||||
}
|
||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
||||
for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) {
|
||||
if (
|
||||
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
||||
!isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) &&
|
||||
firstTransactionPoint === null
|
||||
) {
|
||||
firstTransactionPoint = this.transactionPoints[i];
|
||||
firstTransactionPoint = transactionPointsBeforeEndDate[i];
|
||||
firstIndex = i;
|
||||
}
|
||||
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({
|
||||
currencies,
|
||||
@ -241,7 +376,7 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
const todayString = format(today, DATE_FORMAT);
|
||||
const endDateString = format(end, DATE_FORMAT);
|
||||
|
||||
if (firstIndex > 0) {
|
||||
firstIndex--;
|
||||
@ -254,7 +389,7 @@ export class PortfolioCalculator {
|
||||
const errors: ResponseError['errors'] = [];
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||
const marketValue = marketSymbolMap[endDateString]?.[item.symbol];
|
||||
|
||||
const {
|
||||
grossPerformance,
|
||||
@ -264,6 +399,7 @@ export class PortfolioCalculator {
|
||||
netPerformance,
|
||||
netPerformancePercentage
|
||||
} = this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol: item.symbol
|
||||
@ -432,30 +568,36 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
let minNetPerformance = new Big(0);
|
||||
let maxNetPerformance = new Big(0);
|
||||
|
||||
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||
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
|
||||
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
||||
.filter((performance) => performance !== null)
|
||||
.reduce((maxPerformance, current) => {
|
||||
if (maxPerformance.gt(current)) {
|
||||
return maxPerformance;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
});
|
||||
try {
|
||||
minNetPerformance = timelineInfoInterfaces
|
||||
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||
.filter((performance) => performance !== null)
|
||||
.reduce((minPerformance, current) => {
|
||||
if (minPerformance.lt(current)) {
|
||||
return minPerformance;
|
||||
} 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(
|
||||
(timelineInfo) => timelineInfo.timelinePeriods
|
||||
@ -694,14 +836,20 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
private getSymbolMetrics({
|
||||
end,
|
||||
isChartMode = false,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step = 1,
|
||||
symbol
|
||||
}: {
|
||||
end: Date;
|
||||
isChartMode?: boolean;
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
start: Date;
|
||||
step?: number;
|
||||
symbol: string;
|
||||
}) {
|
||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
||||
@ -720,13 +868,12 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
const dateOfFirstTransaction = new Date(first(orders).date);
|
||||
const endDate = new Date(Date.now());
|
||||
|
||||
const unitPriceAtStartDate =
|
||||
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
||||
|
||||
const unitPriceAtEndDate =
|
||||
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
||||
marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol];
|
||||
|
||||
if (
|
||||
!unitPriceAtEndDate ||
|
||||
@ -751,12 +898,14 @@ export class PortfolioCalculator {
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
const investmentValues: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastTransactionInvestment = new Big(0);
|
||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
||||
// let lastTransactionInvestment = new Big(0);
|
||||
// let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
||||
let maxTotalInvestment = new Big(0);
|
||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
const netPerformanceValues: { [date: string]: Big } = {};
|
||||
// let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
// let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
@ -779,7 +928,7 @@ export class PortfolioCalculator {
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(endDate, DATE_FORMAT),
|
||||
date: format(end, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
itemType: 'end',
|
||||
@ -789,6 +938,41 @@ export class PortfolioCalculator {
|
||||
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
|
||||
// position
|
||||
orders = sortBy(orders, (order) => {
|
||||
@ -816,6 +1000,12 @@ export class PortfolioCalculator {
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(i + 1, order.type, order.itemType);
|
||||
}
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
// Take the unit price of the order as the market price if there are no
|
||||
// orders of this symbol before the start date
|
||||
@ -843,9 +1033,21 @@ export class PortfolioCalculator {
|
||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||
}
|
||||
|
||||
const transactionInvestment = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
const transactionInvestment =
|
||||
order.type === 'BUY'
|
||||
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
: totalUnits.gt(0)
|
||||
? totalInvestment
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(this.getFactor(order.type))
|
||||
: new Big(0);
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
console.log('order.quantity', order.quantity.toNumber());
|
||||
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||
}
|
||||
|
||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||
|
||||
@ -894,70 +1096,101 @@ export class PortfolioCalculator {
|
||||
? new Big(0)
|
||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
'totalInvestmentWithGrossPerformanceFromSell',
|
||||
totalInvestmentWithGrossPerformanceFromSell.toNumber()
|
||||
);
|
||||
console.log(
|
||||
'grossPerformanceFromSells',
|
||||
grossPerformanceFromSells.toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
||||
.minus(totalInvestment)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
if (
|
||||
i > indexOfStartOrder &&
|
||||
!lastValueOfInvestmentBeforeTransaction
|
||||
.plus(lastTransactionInvestment)
|
||||
.eq(0)
|
||||
) {
|
||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
// if (
|
||||
// i > indexOfStartOrder &&
|
||||
// !lastValueOfInvestmentBeforeTransaction
|
||||
// .plus(lastTransactionInvestment)
|
||||
// .eq(0)
|
||||
// ) {
|
||||
// const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
// .minus(
|
||||
// lastValueOfInvestmentBeforeTransaction.plus(
|
||||
// lastTransactionInvestment
|
||||
// )
|
||||
// )
|
||||
// .div(
|
||||
// lastValueOfInvestmentBeforeTransaction.plus(
|
||||
// lastTransactionInvestment
|
||||
// )
|
||||
// );
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.mul(
|
||||
new Big(1).plus(grossHoldingPeriodReturn)
|
||||
);
|
||||
// timeWeightedGrossPerformancePercentage =
|
||||
// timeWeightedGrossPerformancePercentage.mul(
|
||||
// new Big(1).plus(grossHoldingPeriodReturn)
|
||||
// );
|
||||
|
||||
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(fees.minus(feesAtStartDate))
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
// const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
// .minus(fees.minus(feesAtStartDate))
|
||||
// .minus(
|
||||
// lastValueOfInvestmentBeforeTransaction.plus(
|
||||
// lastTransactionInvestment
|
||||
// )
|
||||
// )
|
||||
// .div(
|
||||
// lastValueOfInvestmentBeforeTransaction.plus(
|
||||
// lastTransactionInvestment
|
||||
// )
|
||||
// );
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.mul(
|
||||
new Big(1).plus(netHoldingPeriodReturn)
|
||||
);
|
||||
}
|
||||
// timeWeightedNetPerformancePercentage =
|
||||
// timeWeightedNetPerformancePercentage.mul(
|
||||
// new Big(1).plus(netHoldingPeriodReturn)
|
||||
// );
|
||||
// }
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
|
||||
lastTransactionInvestment = transactionInvestment;
|
||||
// lastTransactionInvestment = transactionInvestment;
|
||||
|
||||
lastValueOfInvestmentBeforeTransaction =
|
||||
valueOfInvestmentBeforeTransaction;
|
||||
// lastValueOfInvestmentBeforeTransaction =
|
||||
// valueOfInvestmentBeforeTransaction;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
}
|
||||
|
||||
if (isChartMode && i > indexOfStartOrder) {
|
||||
netPerformanceValues[order.date] = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
investmentValues[order.date] = maxTotalInvestment;
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
console.log(
|
||||
'totalGrossPerformance',
|
||||
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
if (i === indexOfEndOrder) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.minus(1);
|
||||
// timeWeightedGrossPerformancePercentage =
|
||||
// timeWeightedGrossPerformancePercentage.minus(1);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.minus(1);
|
||||
// timeWeightedNetPerformancePercentage =
|
||||
// timeWeightedNetPerformancePercentage.minus(1);
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
@ -1036,7 +1269,9 @@ export class PortfolioCalculator {
|
||||
return {
|
||||
initialValue,
|
||||
grossPerformancePercentage,
|
||||
investmentValues,
|
||||
netPerformancePercentage,
|
||||
netPerformanceValues,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance,
|
||||
grossPerformance: totalGrossPerformance
|
||||
|
@ -7,18 +7,15 @@ import {
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Filter,
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
PortfolioReport
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
@ -35,11 +32,12 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
UseInterceptors,
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ViewMode } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
@ -52,6 +50,7 @@ export class PortfolioController {
|
||||
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@ -61,55 +60,6 @@ export class PortfolioController {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getChart(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioService.getChart(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
let chartData = historicalDataContainer.items;
|
||||
|
||||
let hasError = false;
|
||||
|
||||
chartData.forEach((chartDataItem) => {
|
||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||
hasError = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
let maxValue = 0;
|
||||
|
||||
chartData.forEach((portfolioItem) => {
|
||||
if (portfolioItem.value > maxValue) {
|
||||
maxValue = portfolioItem.value;
|
||||
}
|
||||
});
|
||||
|
||||
chartData = chartData.map((historicalDataItem) => {
|
||||
return {
|
||||
...historicalDataItem,
|
||||
marketPrice: Number((historicalDataItem.value / maxValue).toFixed(2))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasError,
|
||||
chart: chartData,
|
||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||
};
|
||||
}
|
||||
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@ -118,48 +68,38 @@ export class PortfolioController {
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') range?: DateRange,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
let hasError = false;
|
||||
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const filters: Filter[] = [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
this.request.user.id,
|
||||
range,
|
||||
filters
|
||||
);
|
||||
const {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasErrors,
|
||||
holdings,
|
||||
summary,
|
||||
totalValueInBaseCurrency
|
||||
} = await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
let portfolioSummary = summary;
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
@ -175,7 +115,7 @@ export class PortfolioController {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
portfolioPosition.currency,
|
||||
this.request.user.Settings.currency
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
);
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
@ -193,6 +133,22 @@ export class PortfolioController {
|
||||
accounts[name].current = current / totalValue;
|
||||
accounts[name].original = original / totalInvestment;
|
||||
}
|
||||
|
||||
portfolioSummary = nullifyValuesInObject(summary, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'emergencyFund',
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'items',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
]);
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
@ -214,8 +170,12 @@ export class PortfolioController {
|
||||
|
||||
return {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasError,
|
||||
holdings
|
||||
holdings,
|
||||
totalValueInBaseCurrency,
|
||||
summary: hasDetails ? portfolioSummary : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@ -223,6 +183,7 @@ export class PortfolioController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('groupBy') groupBy?: GroupBy
|
||||
): Promise<PortfolioInvestments> {
|
||||
if (
|
||||
@ -238,12 +199,16 @@ export class PortfolioController {
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
if (groupBy === 'month') {
|
||||
investments = await this.portfolioService.getInvestments(
|
||||
investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
'month'
|
||||
);
|
||||
groupBy: 'month'
|
||||
});
|
||||
} else {
|
||||
investments = await this.portfolioService.getInvestments(impersonationId);
|
||||
investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
impersonationId
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@ -261,29 +226,51 @@ export class PortfolioController {
|
||||
}));
|
||||
}
|
||||
|
||||
return { firstOrderDate: parseDate(investments[0]?.date), investments };
|
||||
return { investments };
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPerformance(
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
@Query('range') dateRange: DateRange = 'max'
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
const performanceInformation = await this.portfolioService.getPerformance({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.request.user.Settings.viewMode === ViewMode.ZEN ||
|
||||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
performanceInformation.chart = performanceInformation.chart.map(
|
||||
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment: new Big(totalInvestment)
|
||||
.div(performanceInformation.performance.totalInvestment)
|
||||
.toNumber(),
|
||||
value: new Big(value)
|
||||
.div(performanceInformation.performance.currentValue)
|
||||
.toNumber()
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentGrossPerformance', 'currentValue']
|
||||
[
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'totalInvestment'
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -295,11 +282,11 @@ export class PortfolioController {
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
@Query('range') dateRange: DateRange = 'max'
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
range
|
||||
dateRange
|
||||
);
|
||||
|
||||
if (
|
||||
@ -340,15 +327,16 @@ export class PortfolioController {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails(
|
||||
access.userId,
|
||||
access.userId,
|
||||
'max',
|
||||
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
||||
);
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
dateRange: 'max',
|
||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||
impersonationId: access.userId,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
hasDetails,
|
||||
alias: access.alias,
|
||||
holdings: {}
|
||||
};
|
||||
|
||||
@ -357,7 +345,8 @@ export class PortfolioController {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
portfolioPosition.currency,
|
||||
this.request.user?.Settings?.currency ?? this.baseCurrency
|
||||
this.request.user?.Settings?.settings.baseCurrency ??
|
||||
this.baseCurrency
|
||||
);
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
@ -380,46 +369,6 @@ export class PortfolioController {
|
||||
return portfolioPublicDetails;
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getSummary(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<PortfolioSummary> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let summary = await this.portfolioService.getSummary(impersonationId);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
summary = nullifyValuesInObject(summary, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'emergencyFund',
|
||||
'fees',
|
||||
'items',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
]);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Get('position/:dataSource/:symbol')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
|
@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -22,6 +23,7 @@ import { RulesService } from './rules.service';
|
||||
exports: [PortfolioService],
|
||||
imports: [
|
||||
AccessModule,
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
@ -3,9 +3,7 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.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 { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
||||
@ -22,6 +20,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
ASSET_SUB_CLASS_EMERGENCY_FUND,
|
||||
MAX_CHART_ITEMS,
|
||||
UNKNOWN_KEY
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
@ -35,7 +34,9 @@ import {
|
||||
PortfolioReport,
|
||||
PortfolioSummary,
|
||||
Position,
|
||||
TimelinePosition
|
||||
TimelinePosition,
|
||||
UserSettings,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
@ -49,8 +50,11 @@ import type {
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import {
|
||||
Account,
|
||||
AssetClass,
|
||||
DataSource,
|
||||
Order,
|
||||
Platform,
|
||||
Prisma,
|
||||
Tag,
|
||||
Type as TypeOfOrder
|
||||
@ -63,11 +67,9 @@ import {
|
||||
isAfter,
|
||||
isBefore,
|
||||
max,
|
||||
parse,
|
||||
parseISO,
|
||||
set,
|
||||
setDayOfYear,
|
||||
startOfDay,
|
||||
subDays,
|
||||
subYears
|
||||
} from 'date-fns';
|
||||
@ -103,14 +105,19 @@ export class PortfolioService {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public async getAccounts(
|
||||
aUserId: string,
|
||||
aFilters?: Filter[]
|
||||
): Promise<AccountWithValue[]> {
|
||||
const where: Prisma.AccountWhereInput = { userId: aUserId };
|
||||
public async getAccounts({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<AccountWithValue[]> {
|
||||
const where: Prisma.AccountWhereInput = { userId: userId };
|
||||
|
||||
if (aFilters?.[0].id && aFilters?.[0].type === 'ACCOUNT') {
|
||||
where.id = aFilters[0].id;
|
||||
if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') {
|
||||
where.id = filters[0].id;
|
||||
}
|
||||
|
||||
const [accounts, details] = await Promise.all([
|
||||
@ -119,10 +126,15 @@ export class PortfolioService {
|
||||
include: { Order: true, Platform: true },
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
this.getDetails(aUserId, aUserId, undefined, aFilters)
|
||||
this.getDetails({
|
||||
filters,
|
||||
withExcludedAccounts,
|
||||
impersonationId: userId,
|
||||
userId: this.request.user.id
|
||||
})
|
||||
]);
|
||||
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
return accounts.map((account) => {
|
||||
let transactionCount = 0;
|
||||
@ -157,11 +169,20 @@ export class PortfolioService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getAccountsWithAggregations(
|
||||
aUserId: string,
|
||||
aFilters?: Filter[]
|
||||
): Promise<Accounts> {
|
||||
const accounts = await this.getAccounts(aUserId, aFilters);
|
||||
public async getAccountsWithAggregations({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<Accounts> {
|
||||
const accounts = await this.getAccounts({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
});
|
||||
let totalBalanceInBaseCurrency = new Big(0);
|
||||
let totalValueInBaseCurrency = new Big(0);
|
||||
let transactionCount = 0;
|
||||
@ -184,11 +205,16 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getInvestments(
|
||||
aImpersonationId: string,
|
||||
groupBy?: GroupBy
|
||||
): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
public async getInvestments({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
groupBy
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
impersonationId: string;
|
||||
groupBy?: GroupBy;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -197,7 +223,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.currency,
|
||||
currency: this.request.user.Settings.settings.baseCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
@ -260,16 +286,32 @@ export class PortfolioService {
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(investments, (investment) => {
|
||||
investments = sortBy(investments, (investment) => {
|
||||
return investment.date;
|
||||
});
|
||||
|
||||
const startDate = this.getStartDate(
|
||||
dateRange,
|
||||
parseDate(investments[0]?.date)
|
||||
);
|
||||
|
||||
return investments.filter(({ date }) => {
|
||||
return !isBefore(parseDate(date), startDate);
|
||||
});
|
||||
}
|
||||
|
||||
public async getChart(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<HistoricalDataContainer> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
public async getChart({
|
||||
dateRange = 'max',
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
impersonationId: string;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<HistoricalDataContainer> {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -277,7 +319,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.currency,
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
@ -290,91 +332,55 @@ export class PortfolioService {
|
||||
items: []
|
||||
};
|
||||
}
|
||||
let portfolioStart = parse(
|
||||
transactionPoints[0].date,
|
||||
DATE_FORMAT,
|
||||
new Date()
|
||||
const endDate = new Date();
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), startDate);
|
||||
const step = Math.round(
|
||||
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
|
||||
);
|
||||
|
||||
// Get start date for the full portfolio because of because of the
|
||||
// min and max calculation
|
||||
portfolioStart = this.getStartDate('max', portfolioStart);
|
||||
|
||||
const timelineSpecification: TimelineSpecification[] = [
|
||||
{
|
||||
start: format(portfolioStart, DATE_FORMAT),
|
||||
accuracy: 'day'
|
||||
}
|
||||
];
|
||||
|
||||
const timelineInfo = await portfolioCalculator.calculateTimeline(
|
||||
timelineSpecification,
|
||||
format(new Date(), DATE_FORMAT)
|
||||
);
|
||||
|
||||
const timeline = timelineInfo.timelinePeriods;
|
||||
|
||||
const items = timeline
|
||||
.filter((timelineItem) => timelineItem !== null)
|
||||
.map((timelineItem) => ({
|
||||
date: timelineItem.date,
|
||||
value: timelineItem.netPerformance.toNumber()
|
||||
}));
|
||||
|
||||
let lastItem = null;
|
||||
if (timeline.length > 0) {
|
||||
lastItem = timeline[timeline.length - 1];
|
||||
}
|
||||
|
||||
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
|
||||
lastItem?.netPerformance
|
||||
);
|
||||
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
|
||||
lastItem?.netPerformance
|
||||
);
|
||||
if (isAllTimeHigh && isAllTimeLow) {
|
||||
isAllTimeHigh = false;
|
||||
isAllTimeLow = false;
|
||||
}
|
||||
|
||||
portfolioStart = startOfDay(
|
||||
this.getStartDate(
|
||||
aDateRange,
|
||||
parse(transactionPoints[0].date, DATE_FORMAT, new Date())
|
||||
)
|
||||
const items = await portfolioCalculator.getChartData(
|
||||
startDate,
|
||||
endDate,
|
||||
step
|
||||
);
|
||||
|
||||
return {
|
||||
isAllTimeHigh,
|
||||
isAllTimeLow,
|
||||
items: items.filter((item) => {
|
||||
// Filter items of date range
|
||||
return !isAfter(portfolioStart, parseDate(item.date));
|
||||
})
|
||||
items,
|
||||
isAllTimeHigh: false,
|
||||
isAllTimeLow: false
|
||||
};
|
||||
}
|
||||
|
||||
public async getDetails(
|
||||
aImpersonationId: string,
|
||||
aUserId: string,
|
||||
aDateRange: DateRange = 'max',
|
||||
aFilters?: Filter[]
|
||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||
public async getDetails({
|
||||
impersonationId,
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
impersonationId: string;
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
const userCurrency =
|
||||
user.Settings?.currency ??
|
||||
this.request.user?.Settings?.currency ??
|
||||
this.baseCurrency;
|
||||
|
||||
const { orders, portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId,
|
||||
filters: aFilters
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
@ -388,24 +394,33 @@ export class PortfolioService {
|
||||
const portfolioStart = parseDate(
|
||||
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
|
||||
);
|
||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate
|
||||
);
|
||||
|
||||
const cashDetails = await this.accountService.getCashDetails({
|
||||
filters,
|
||||
userId,
|
||||
currency: userCurrency,
|
||||
filters: aFilters
|
||||
currency: userCurrency
|
||||
});
|
||||
|
||||
const holdings: PortfolioDetails['holdings'] = {};
|
||||
const totalInvestment = currentPositions.totalInvestment.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
);
|
||||
const totalValue = currentPositions.currentValue.plus(
|
||||
const totalInvestmentInBaseCurrency = currentPositions.totalInvestment.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
);
|
||||
let filteredValueInBaseCurrency = currentPositions.currentValue;
|
||||
|
||||
if (
|
||||
filters?.length === 0 ||
|
||||
(filters?.length === 1 &&
|
||||
filters[0].type === 'ASSET_CLASS' &&
|
||||
filters[0].id === 'CASH')
|
||||
) {
|
||||
filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
);
|
||||
}
|
||||
|
||||
const dataGatheringItems = currentPositions.positions.map((position) => {
|
||||
return {
|
||||
@ -466,8 +481,12 @@ export class PortfolioService {
|
||||
|
||||
holdings[item.symbol] = {
|
||||
markets,
|
||||
allocationCurrent: value.div(totalValue).toNumber(),
|
||||
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
||||
allocationCurrent: filteredValueInBaseCurrency.eq(0)
|
||||
? 0
|
||||
: value.div(filteredValueInBaseCurrency).toNumber(),
|
||||
allocationInvestment: item.investment
|
||||
.div(totalInvestmentInBaseCurrency)
|
||||
.toNumber(),
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
countries: symbolProfile.countries,
|
||||
@ -478,7 +497,7 @@ export class PortfolioService {
|
||||
item.grossPerformancePercentage?.toNumber() ?? 0,
|
||||
investment: item.investment.toNumber(),
|
||||
marketPrice: item.marketPrice,
|
||||
marketState: dataProviderResponse.marketState,
|
||||
marketState: dataProviderResponse?.marketState ?? 'delayed',
|
||||
name: symbolProfile.name,
|
||||
netPerformance: item.netPerformance?.toNumber() ?? 0,
|
||||
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
|
||||
@ -492,17 +511,17 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
if (
|
||||
aFilters?.length === 0 ||
|
||||
(aFilters?.length === 1 &&
|
||||
aFilters[0].type === 'ASSET_CLASS' &&
|
||||
aFilters[0].id === 'CASH')
|
||||
filters?.length === 0 ||
|
||||
(filters?.length === 1 &&
|
||||
filters[0].type === 'ASSET_CLASS' &&
|
||||
filters[0].id === 'CASH')
|
||||
) {
|
||||
const cashPositions = await this.getCashPositions({
|
||||
cashDetails,
|
||||
emergencyFund,
|
||||
userCurrency,
|
||||
investment: totalInvestment,
|
||||
value: totalValue
|
||||
investment: totalInvestmentInBaseCurrency,
|
||||
value: filteredValueInBaseCurrency
|
||||
});
|
||||
|
||||
for (const symbol of Object.keys(cashPositions)) {
|
||||
@ -511,14 +530,31 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const accounts = await this.getValueOfAccounts({
|
||||
filters,
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId,
|
||||
filters: aFilters
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
|
||||
const summary = await this.getSummary({
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
return {
|
||||
accounts,
|
||||
holdings,
|
||||
summary,
|
||||
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
|
||||
filteredValueInPercentage: summary.netWorth
|
||||
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber()
|
||||
: 0,
|
||||
hasErrors: currentPositions.hasErrors,
|
||||
totalValueInBaseCurrency: summary.netWorth
|
||||
};
|
||||
}
|
||||
|
||||
public async getPosition(
|
||||
@ -526,11 +562,16 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
|
||||
const orders = (
|
||||
await this.orderService.getOrders({ userCurrency, userId })
|
||||
await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
).filter(({ SymbolProfile }) => {
|
||||
return (
|
||||
SymbolProfile.dataSource === aDataSource &&
|
||||
@ -779,7 +820,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.currency,
|
||||
currency: this.request.user.Settings.settings.baseCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
@ -843,11 +884,18 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getPerformance(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
public async getPerformance({
|
||||
dateRange = 'max',
|
||||
impersonationId,
|
||||
userId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
}): Promise<PortfolioPerformanceResponse> {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -855,20 +903,23 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.currency,
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {
|
||||
chart: [],
|
||||
firstOrderDate: undefined,
|
||||
hasErrors: false,
|
||||
performance: {
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentValue: 0
|
||||
currentValue: 0,
|
||||
totalInvestment: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -876,7 +927,7 @@ export class PortfolioService {
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate
|
||||
);
|
||||
@ -884,24 +935,61 @@ export class PortfolioService {
|
||||
const hasErrors = currentPositions.hasErrors;
|
||||
const currentValue = currentPositions.currentValue.toNumber();
|
||||
const currentGrossPerformance = currentPositions.grossPerformance;
|
||||
let currentGrossPerformancePercent =
|
||||
const currentGrossPerformancePercent =
|
||||
currentPositions.grossPerformancePercentage;
|
||||
const currentNetPerformance = currentPositions.netPerformance;
|
||||
let currentNetPerformance = currentPositions.netPerformance;
|
||||
let currentNetPerformancePercent =
|
||||
currentPositions.netPerformancePercentage;
|
||||
const totalInvestment = currentPositions.totalInvestment;
|
||||
|
||||
if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
|
||||
// If algebraic sign is different, harmonize it
|
||||
currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
|
||||
}
|
||||
// if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
|
||||
// // If algebraic sign is different, harmonize it
|
||||
// currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
|
||||
// }
|
||||
|
||||
if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
|
||||
// If algebraic sign is different, harmonize it
|
||||
currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
|
||||
// if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
|
||||
// // If algebraic sign is different, harmonize it
|
||||
// currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
|
||||
// }
|
||||
|
||||
const historicalDataContainer = await this.getChart({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
const itemOfToday = historicalDataContainer.items.find((item) => {
|
||||
return item.date === format(new Date(), DATE_FORMAT);
|
||||
});
|
||||
|
||||
if (itemOfToday) {
|
||||
currentNetPerformance = new Big(itemOfToday.netPerformance);
|
||||
currentNetPerformancePercent = new Big(
|
||||
itemOfToday.netPerformanceInPercentage
|
||||
).div(100);
|
||||
}
|
||||
|
||||
return {
|
||||
chart: historicalDataContainer.items.map(
|
||||
({
|
||||
date,
|
||||
netPerformance,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment,
|
||||
value
|
||||
}) => {
|
||||
return {
|
||||
date,
|
||||
netPerformance,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment,
|
||||
value
|
||||
};
|
||||
}
|
||||
),
|
||||
errors: currentPositions.errors,
|
||||
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
|
||||
hasErrors: currentPositions.hasErrors || hasErrors,
|
||||
performance: {
|
||||
currentValue,
|
||||
@ -909,14 +997,16 @@ export class PortfolioService {
|
||||
currentGrossPerformancePercent:
|
||||
currentGrossPerformancePercent.toNumber(),
|
||||
currentNetPerformance: currentNetPerformance.toNumber(),
|
||||
currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
|
||||
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
|
||||
totalInvestment: totalInvestment.toNumber()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
|
||||
const { orders, portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -930,7 +1020,7 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency,
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
@ -950,7 +1040,7 @@ export class PortfolioService {
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userId,
|
||||
userCurrency: currency
|
||||
userCurrency
|
||||
});
|
||||
return {
|
||||
rules: {
|
||||
@ -969,7 +1059,7 @@ export class PortfolioService {
|
||||
accounts
|
||||
)
|
||||
],
|
||||
{ baseCurrency: currency }
|
||||
<UserSettings>this.request.user.Settings.settings
|
||||
),
|
||||
currencyClusterRisk: await this.rulesService.evaluate(
|
||||
[
|
||||
@ -990,90 +1080,22 @@ export class PortfolioService {
|
||||
currentPositions
|
||||
)
|
||||
],
|
||||
{ baseCurrency: currency }
|
||||
<UserSettings>this.request.user.Settings.settings
|
||||
),
|
||||
fees: await this.rulesService.evaluate(
|
||||
[
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions.totalInvestment.toNumber(),
|
||||
this.getFees(orders).toNumber()
|
||||
this.getFees({ orders, userCurrency }).toNumber()
|
||||
)
|
||||
],
|
||||
{ baseCurrency: currency }
|
||||
<UserSettings>this.request.user.Settings.settings
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
|
||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||
|
||||
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
|
||||
userId,
|
||||
currency: userCurrency
|
||||
});
|
||||
const orders = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
const dividend = this.getDividend(orders).toNumber();
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
const fees = this.getFees(orders).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
const items = this.getItems(orders).toNumber();
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||
|
||||
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
|
||||
const committedFunds = new Big(totalBuy).minus(totalSell);
|
||||
|
||||
const netWorth = new Big(balanceInBaseCurrency)
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.plus(items)
|
||||
.toNumber();
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
|
||||
const annualizedPerformancePercent = new PortfolioCalculator({
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: []
|
||||
})
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent: new Big(
|
||||
performanceInformation.performance.currentNetPerformancePercent
|
||||
)
|
||||
})
|
||||
?.toNumber();
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
annualizedPerformancePercent,
|
||||
cash,
|
||||
dividend,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
items,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
emergencyFund: emergencyFund.toNumber(),
|
||||
ordersCount: orders.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
}).length
|
||||
};
|
||||
}
|
||||
|
||||
private async getCashPositions({
|
||||
cashDetails,
|
||||
emergencyFund,
|
||||
@ -1168,7 +1190,15 @@ export class PortfolioService {
|
||||
return cashPositions;
|
||||
}
|
||||
|
||||
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
private getDividend({
|
||||
date = new Date(0),
|
||||
orders,
|
||||
userCurrency
|
||||
}: {
|
||||
date?: Date;
|
||||
orders: OrderWithAccount[];
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date and type dividend
|
||||
@ -1181,7 +1211,7 @@ export class PortfolioService {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.SymbolProfile.currency,
|
||||
this.request.user.Settings.currency
|
||||
userCurrency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
@ -1190,7 +1220,15 @@ export class PortfolioService {
|
||||
);
|
||||
}
|
||||
|
||||
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
private getFees({
|
||||
date = new Date(0),
|
||||
orders,
|
||||
userCurrency
|
||||
}: {
|
||||
date?: Date;
|
||||
orders: OrderWithAccount[];
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date
|
||||
@ -1200,7 +1238,7 @@ export class PortfolioService {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
this.request.user.Settings.currency
|
||||
userCurrency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
@ -1222,7 +1260,7 @@ export class PortfolioService {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.SymbolProfile.currency,
|
||||
this.request.user.Settings.currency
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
@ -1249,27 +1287,139 @@ export class PortfolioService {
|
||||
return portfolioStart;
|
||||
}
|
||||
|
||||
private async getSummary({
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
impersonationId: string;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<PortfolioSummary> {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
|
||||
const performanceInformation = await this.getPerformance({
|
||||
impersonationId,
|
||||
userId
|
||||
});
|
||||
|
||||
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
|
||||
userId,
|
||||
currency: userCurrency
|
||||
});
|
||||
const orders = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
const excludedActivities = (
|
||||
await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
).filter(({ Account: account }) => {
|
||||
return account?.isExcluded ?? false;
|
||||
});
|
||||
|
||||
const dividend = this.getDividend({ orders, userCurrency }).toNumber();
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
const fees = this.getFees({ orders, userCurrency }).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
const items = this.getItems(orders).toNumber();
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||
|
||||
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
|
||||
const committedFunds = new Big(totalBuy).minus(totalSell);
|
||||
const totalOfExcludedActivities = new Big(
|
||||
this.getTotalByType(excludedActivities, userCurrency, 'BUY')
|
||||
).minus(this.getTotalByType(excludedActivities, userCurrency, 'SELL'));
|
||||
|
||||
const cashDetailsWithExcludedAccounts =
|
||||
await this.accountService.getCashDetails({
|
||||
userId,
|
||||
currency: userCurrency,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
const excludedBalanceInBaseCurrency = new Big(
|
||||
cashDetailsWithExcludedAccounts.balanceInBaseCurrency
|
||||
).minus(balanceInBaseCurrency);
|
||||
|
||||
const excludedAccountsAndActivities = excludedBalanceInBaseCurrency
|
||||
.plus(totalOfExcludedActivities)
|
||||
.toNumber();
|
||||
|
||||
const netWorth = new Big(balanceInBaseCurrency)
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.plus(items)
|
||||
.plus(excludedAccountsAndActivities)
|
||||
.toNumber();
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
|
||||
const annualizedPerformancePercent = new PortfolioCalculator({
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: []
|
||||
})
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent: new Big(
|
||||
performanceInformation.performance.currentNetPerformancePercent
|
||||
)
|
||||
})
|
||||
?.toNumber();
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
annualizedPerformancePercent,
|
||||
cash,
|
||||
dividend,
|
||||
excludedAccountsAndActivities,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
items,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
emergencyFund: emergencyFund.toNumber(),
|
||||
ordersCount: orders.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
}).length
|
||||
};
|
||||
}
|
||||
|
||||
private async getTransactionPoints({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<{
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: OrderWithAccount[];
|
||||
portfolioOrders: PortfolioOrder[];
|
||||
}> {
|
||||
const userCurrency =
|
||||
this.request.user?.Settings?.currency ?? this.baseCurrency;
|
||||
this.request.user?.Settings?.settings.baseCurrency ?? this.baseCurrency;
|
||||
|
||||
const orders = await this.orderService.getOrders({
|
||||
filters,
|
||||
includeDrafts,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts,
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
@ -1321,17 +1471,22 @@ export class PortfolioService {
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
orders: OrderWithAccount[];
|
||||
portfolioItemsNow: { [p: string]: TimelinePosition };
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}) {
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
|
||||
let currentAccounts = [];
|
||||
let currentAccounts: (Account & {
|
||||
Order?: Order[];
|
||||
Platform?: Platform;
|
||||
})[] = [];
|
||||
|
||||
if (filters.length === 0) {
|
||||
currentAccounts = await this.accountService.getAccounts(userId);
|
||||
@ -1351,6 +1506,10 @@ export class PortfolioService {
|
||||
});
|
||||
}
|
||||
|
||||
currentAccounts = currentAccounts.filter((account) => {
|
||||
return withExcludedAccounts || account.isExcluded === false;
|
||||
});
|
||||
|
||||
for (const account of currentAccounts) {
|
||||
const ordersByAccount = orders.filter(({ accountId }) => {
|
||||
return accountId === account.id;
|
||||
@ -1436,4 +1595,12 @@ export class PortfolioService {
|
||||
})
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
|
||||
private getUserCurrency(aUser: UserWithSettings) {
|
||||
return (
|
||||
aUser.Settings?.settings.baseCurrency ??
|
||||
this.request.user?.Settings?.settings.baseCurrency ??
|
||||
this.baseCurrency
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@ -8,7 +9,7 @@ export class RulesService {
|
||||
|
||||
public async evaluate<T extends RuleSettings>(
|
||||
aRules: Rule<T>[],
|
||||
aUserSettings: { baseCurrency: string }
|
||||
aUserSettings: UserSettings
|
||||
) {
|
||||
return aRules
|
||||
.filter((rule) => {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { CacheModule, Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
||||
import * as redisStore from 'cache-manager-redis-store';
|
||||
|
||||
import { RedisCacheService } from './redis-cache.service';
|
||||
@ -9,16 +8,18 @@ import { RedisCacheService } from './redis-cache.service';
|
||||
@Module({
|
||||
imports: [
|
||||
CacheModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configurationService: ConfigurationService) => ({
|
||||
host: configurationService.get('REDIS_HOST'),
|
||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||
password: configurationService.get('REDIS_PASSWORD'),
|
||||
port: configurationService.get('REDIS_PORT'),
|
||||
store: redisStore,
|
||||
ttl: configurationService.get('CACHE_TTL')
|
||||
})
|
||||
imports: [ConfigurationModule],
|
||||
inject: [ConfigurationService],
|
||||
useFactory: async (configurationService: ConfigurationService) => {
|
||||
return <CacheManagerOptions>{
|
||||
host: configurationService.get('REDIS_HOST'),
|
||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||
password: configurationService.get('REDIS_PASSWORD'),
|
||||
port: configurationService.get('REDIS_PORT'),
|
||||
store: redisStore,
|
||||
ttl: configurationService.get('CACHE_TTL')
|
||||
};
|
||||
}
|
||||
}),
|
||||
ConfigurationModule
|
||||
],
|
||||
|
@ -1,7 +0,0 @@
|
||||
import { ViewMode } from '@prisma/client';
|
||||
|
||||
export interface UserSettingsParams {
|
||||
currency?: string;
|
||||
userId: string;
|
||||
viewMode?: ViewMode;
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
export interface UserSettings {
|
||||
emergencyFund?: number;
|
||||
locale?: string;
|
||||
isRestrictedView?: boolean;
|
||||
}
|
@ -1,10 +1,41 @@
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import type {
|
||||
ColorScheme,
|
||||
DateRange,
|
||||
ViewMode
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
baseCurrency?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
benchmark?: string;
|
||||
|
||||
@IsIn(<ColorScheme[]>['DARK', 'LIGHT'])
|
||||
@IsOptional()
|
||||
colorScheme?: ColorScheme;
|
||||
|
||||
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
|
||||
@IsOptional()
|
||||
dateRange?: DateRange;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
emergencyFund?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExperimentalFeatures?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isRestrictedView?: boolean;
|
||||
@ -20,4 +51,8 @@ export class UpdateUserSettingDto {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
savingsRate?: number;
|
||||
|
||||
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
|
||||
@IsOptional()
|
||||
viewMode?: ViewMode;
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
import { ViewMode } from '@prisma/client';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingsDto {
|
||||
@IsString()
|
||||
baseCurrency: string;
|
||||
|
||||
@IsString()
|
||||
viewMode: ViewMode;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
@ -22,12 +22,10 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { size } from 'lodash';
|
||||
|
||||
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 { UpdateUserSettingsDto } from './update-user-settings.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Controller('user')
|
||||
@ -103,6 +101,12 @@ export class UserController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||
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(
|
||||
this.request.user.permissions,
|
||||
permissions.updateUserSettings
|
||||
@ -130,33 +134,4 @@ export class UserController {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -4,19 +4,20 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
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 {
|
||||
getPermissions,
|
||||
hasRole,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
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 { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UserSettings } from './interfaces/user-settings.interface';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@Injectable()
|
||||
@ -43,7 +44,7 @@ export class UserService {
|
||||
include: {
|
||||
User: true
|
||||
},
|
||||
orderBy: { User: { alias: 'asc' } },
|
||||
orderBy: { alias: 'asc' },
|
||||
where: { GranteeUser: { id } }
|
||||
});
|
||||
let tags = await this.tagService.getByUser(id);
|
||||
@ -62,16 +63,14 @@ export class UserService {
|
||||
tags,
|
||||
access: access.map((accessItem) => {
|
||||
return {
|
||||
alias: accessItem.User.alias,
|
||||
alias: accessItem.alias,
|
||||
id: accessItem.id
|
||||
};
|
||||
}),
|
||||
accounts: Account,
|
||||
settings: {
|
||||
...(<UserSettings>Settings.settings),
|
||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
|
||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
||||
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -89,7 +88,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
public isRestrictedView(aUser: UserWithSettings) {
|
||||
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
|
||||
return aUser.Settings.settings.isRestrictedView ?? false;
|
||||
}
|
||||
|
||||
public async user(
|
||||
@ -98,7 +97,6 @@ export class UserService {
|
||||
const {
|
||||
accessToken,
|
||||
Account,
|
||||
alias,
|
||||
authChallenge,
|
||||
createdAt,
|
||||
id,
|
||||
@ -116,7 +114,6 @@ export class UserService {
|
||||
const user: UserWithSettings = {
|
||||
accessToken,
|
||||
Account,
|
||||
alias,
|
||||
authChallenge,
|
||||
createdAt,
|
||||
id,
|
||||
@ -128,21 +125,35 @@ export class UserService {
|
||||
};
|
||||
|
||||
if (user?.Settings) {
|
||||
if (!user.Settings.currency) {
|
||||
// Set default currency if needed
|
||||
user.Settings.currency = UserService.DEFAULT_CURRENCY;
|
||||
if (!user.Settings.settings) {
|
||||
user.Settings.settings = {};
|
||||
}
|
||||
} else if (user) {
|
||||
// Set default settings if needed
|
||||
user.Settings = {
|
||||
currency: UserService.DEFAULT_CURRENCY,
|
||||
settings: null,
|
||||
settings: {},
|
||||
updatedAt: new Date(),
|
||||
userId: user?.id,
|
||||
viewMode: ViewMode.DEFAULT
|
||||
userId: user?.id
|
||||
};
|
||||
}
|
||||
|
||||
// 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')) {
|
||||
user.subscription =
|
||||
this.subscriptionService.getSubscription(Subscription);
|
||||
@ -223,7 +234,9 @@ export class UserService {
|
||||
},
|
||||
Settings: {
|
||||
create: {
|
||||
currency: this.baseCurrency
|
||||
settings: {
|
||||
currency: this.baseCurrency
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -297,7 +310,7 @@ export class UserService {
|
||||
userId: string;
|
||||
userSettings: UserSettings;
|
||||
}) {
|
||||
const settings = userSettings as Prisma.JsonObject;
|
||||
const settings = userSettings as unknown as Prisma.JsonObject;
|
||||
|
||||
await this.prismaService.settings.upsert({
|
||||
create: {
|
||||
@ -319,33 +332,6 @@ export class UserService {
|
||||
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) {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const result = [];
|
||||
|
@ -41,6 +41,14 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
return activity;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.filteredValueInBaseCurrency) {
|
||||
data.filteredValueInBaseCurrency = null;
|
||||
}
|
||||
|
||||
if (data.totalValueInBaseCurrency) {
|
||||
data.totalValueInBaseCurrency = null;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
Injectable,
|
||||
NestInterceptor
|
||||
} from '@nestjs/common';
|
||||
import { isArray } from 'lodash';
|
||||
import { Observable } from 'rxjs';
|
||||
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) {
|
||||
data.dataSource = encodeDataSource(data.dataSource);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
|
@ -1,3 +0,0 @@
|
||||
export interface UserSettings {
|
||||
baseCurrency: string;
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
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 { 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 { RuleInterface } from './interfaces/rule.interface';
|
||||
|
@ -1,9 +1,9 @@
|
||||
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 {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition
|
||||
PortfolioPosition,
|
||||
UserSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,9 +1,9 @@
|
||||
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 {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition
|
||||
PortfolioPosition,
|
||||
UserSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,7 +1,6 @@
|
||||
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 { PortfolioDetails } from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.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 { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.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 { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.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 { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.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 { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
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 { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
9
apps/api/src/services/api/api.module.ts
Normal file
9
apps/api/src/services/api/api.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Module({
|
||||
exports: [ApiService],
|
||||
providers: [ApiService]
|
||||
})
|
||||
export class ApiModule {}
|
42
apps/api/src/services/api/api.service.ts
Normal file
42
apps/api/src/services/api/api.service.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ApiService {
|
||||
public constructor() {}
|
||||
|
||||
public buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
}: {
|
||||
filterByAccounts?: string;
|
||||
filterByAssetClasses?: string;
|
||||
filterByTags?: string;
|
||||
}): Filter[] {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
return [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
@ -38,7 +38,7 @@ export class ConfigurationService {
|
||||
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
PORT: port({ default: 3333 }),
|
||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||
RAPID_API_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: host({ default: 'localhost' }),
|
||||
REDIS_PASSWORD: str({ default: '' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
|
@ -17,7 +17,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
limiter: {
|
||||
duration: ms('5 seconds'),
|
||||
duration: ms('4 seconds'),
|
||||
max: 1
|
||||
},
|
||||
name: DATA_GATHERING_QUEUE
|
||||
|
@ -280,7 +280,7 @@ export class DataGatheringService {
|
||||
return (
|
||||
dataSource !== DataSource.GHOSTFOLIO &&
|
||||
dataSource !== DataSource.MANUAL &&
|
||||
dataSource !== DataSource.RAKUTEN
|
||||
dataSource !== DataSource.RAPID_API
|
||||
);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
|
@ -5,7 +5,7 @@ import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
@ -27,7 +27,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RakutenRapidApiService,
|
||||
RapidApiService,
|
||||
YahooFinanceService,
|
||||
{
|
||||
inject: [
|
||||
@ -36,7 +36,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RakutenRapidApiService,
|
||||
RapidApiService,
|
||||
YahooFinanceService
|
||||
],
|
||||
provide: 'DataProviderInterfaces',
|
||||
@ -46,7 +46,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rakutenRapidApiService,
|
||||
rapidApiService,
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
@ -54,7 +54,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rakutenRapidApiService,
|
||||
rapidApiService,
|
||||
yahooFinanceService
|
||||
]
|
||||
}
|
||||
|
@ -6,7 +6,11 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
extractNumberFromString,
|
||||
getYesterday
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
@ -16,8 +20,6 @@ import { addDays, format, isBefore } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
||||
|
||||
public constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
@ -77,7 +79,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
const html = await get();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const value = this.extractNumberFromString($(selector).text());
|
||||
const value = extractNumberFromString($(selector).text());
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
@ -175,15 +177,4 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
private extractNumberFromString(aString: string): number {
|
||||
try {
|
||||
const [numberString] = aString.match(
|
||||
GhostfolioScraperApiService.NUMERIC_REGEXP
|
||||
);
|
||||
return parseFloat(numberString.trim());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
export interface IRakutenRapidApiResponse {}
|
@ -0,0 +1 @@
|
||||
export interface IRapidApiResponse {}
|
@ -15,14 +15,14 @@ import bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class RakutenRapidApiService implements DataProviderInterface {
|
||||
export class RapidApiService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
|
||||
return !!this.configurationService.get('RAPID_API_API_KEY');
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
return DataSource.RAKUTEN;
|
||||
return DataSource.RAPID_API;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'RakutenRapidApiService');
|
||||
Logger.error(error, 'RapidApiService');
|
||||
}
|
||||
|
||||
return {};
|
||||
@ -155,16 +155,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
{
|
||||
useQueryString: true,
|
||||
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
|
||||
'x-rapidapi-key': this.configurationService.get(
|
||||
'RAKUTEN_RAPID_API_KEY'
|
||||
)
|
||||
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
|
||||
}
|
||||
);
|
||||
|
||||
const { fgi } = await get();
|
||||
return fgi;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'RakutenRapidApiService');
|
||||
Logger.error(error, 'RapidApiService');
|
||||
|
||||
return undefined;
|
||||
}
|
@ -6,6 +6,7 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -57,8 +58,15 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
* DOGEUSD -> DOGE-USD
|
||||
*/
|
||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||
if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) {
|
||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||
if (
|
||||
aSymbol.includes(this.baseCurrency) &&
|
||||
aSymbol.length > this.baseCurrency.length
|
||||
) {
|
||||
if (
|
||||
isCurrency(
|
||||
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
|
||||
)
|
||||
) {
|
||||
return `${aSymbol}=X`;
|
||||
} else if (
|
||||
this.cryptocurrencyService.isCryptocurrency(
|
||||
@ -90,7 +98,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
try {
|
||||
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
||||
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
||||
modules: ['price', 'summaryProfile']
|
||||
modules: ['price', 'summaryProfile', 'topHoldings']
|
||||
});
|
||||
|
||||
const { assetClass, assetSubClass } = this.parseAssetClass(
|
||||
@ -109,7 +117,16 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
});
|
||||
response.symbol = aSymbol;
|
||||
|
||||
if (
|
||||
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
||||
response.sectors = [];
|
||||
|
||||
for (const sectorWeighting of assetProfile.topHoldings
|
||||
?.sectorWeightings ?? []) {
|
||||
for (const [sector, weight] of Object.entries(sectorWeighting)) {
|
||||
response.sectors.push({ weight, name: this.parseSector(sector) });
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
assetSubClass === AssetSubClass.STOCK &&
|
||||
assetProfile.summaryProfile?.country
|
||||
) {
|
||||
@ -183,10 +200,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
for (const historicalItem of historicalResult) {
|
||||
let marketPrice = historicalItem.close;
|
||||
|
||||
if (symbol === 'USDGBp') {
|
||||
if (symbol === `${this.baseCurrency}GBp`) {
|
||||
// Convert GPB to GBp (pence)
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
} else if (symbol === 'USDILA') {
|
||||
} else if (symbol === `${this.baseCurrency}ILA`) {
|
||||
// Convert ILS to ILA
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
}
|
||||
@ -246,9 +263,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
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)
|
||||
response['USDGBp'] = {
|
||||
response[`${this.baseCurrency}GBp`] = {
|
||||
...response[symbol],
|
||||
currency: 'GBp',
|
||||
marketPrice: new Big(response[symbol].marketPrice)
|
||||
@ -256,11 +276,11 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
.toNumber()
|
||||
};
|
||||
} else if (
|
||||
symbol === 'USDILS' &&
|
||||
yahooFinanceSymbols.includes('USDILA=X')
|
||||
symbol === `${this.baseCurrency}ILS` &&
|
||||
yahooFinanceSymbols.includes(`${this.baseCurrency}ILA=X`)
|
||||
) {
|
||||
// Convert ILS to ILA
|
||||
response['USDILA'] = {
|
||||
response[`${this.baseCurrency}ILA`] = {
|
||||
...response[symbol],
|
||||
currency: 'ILA',
|
||||
marketPrice: new Big(response[symbol].marketPrice)
|
||||
@ -270,9 +290,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
}
|
||||
}
|
||||
|
||||
if (yahooFinanceSymbols.includes('USDUSX=X')) {
|
||||
if (yahooFinanceSymbols.includes(`${this.baseCurrency}USX=X`)) {
|
||||
// Convert USD to USX (cent)
|
||||
response['USDUSX'] = {
|
||||
response[`${this.baseCurrency}USX`] = {
|
||||
currency: 'USX',
|
||||
dataSource: this.getName(),
|
||||
marketPrice: new Big(1).mul(100).toNumber(),
|
||||
@ -434,4 +454,46 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
return { assetClass, assetSubClass };
|
||||
}
|
||||
|
||||
private parseSector(aString: string): string {
|
||||
let sector = UNKNOWN_KEY;
|
||||
|
||||
switch (aString) {
|
||||
case 'basic_materials':
|
||||
sector = 'Basic Materials';
|
||||
break;
|
||||
case 'communication_services':
|
||||
sector = 'Communication Services';
|
||||
break;
|
||||
case 'consumer_cyclical':
|
||||
sector = 'Consumer Cyclical';
|
||||
break;
|
||||
case 'consumer_defensive':
|
||||
sector = 'Consumer Staples';
|
||||
break;
|
||||
case 'energy':
|
||||
sector = 'Energy';
|
||||
break;
|
||||
case 'financial_services':
|
||||
sector = 'Financial Services';
|
||||
break;
|
||||
case 'healthcare':
|
||||
sector = 'Healthcare';
|
||||
break;
|
||||
case 'industrials':
|
||||
sector = 'Industrials';
|
||||
break;
|
||||
case 'realestate':
|
||||
sector = 'Real Estate';
|
||||
break;
|
||||
case 'technology':
|
||||
sector = 'Technology';
|
||||
break;
|
||||
case 'utilities':
|
||||
sector = 'Utilities';
|
||||
break;
|
||||
}
|
||||
|
||||
return sector;
|
||||
}
|
||||
}
|
||||
|
@ -99,10 +99,12 @@ export class ExchangeRateDataService {
|
||||
this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice;
|
||||
|
||||
if (!this.exchangeRates[symbol]) {
|
||||
// Not found, calculate indirectly via USD
|
||||
// Not found, calculate indirectly via base currency
|
||||
this.exchangeRates[symbol] =
|
||||
resultExtended[`${currency1}${'USD'}`]?.[date]?.marketPrice *
|
||||
resultExtended[`${'USD'}${currency2}`]?.[date]?.marketPrice;
|
||||
resultExtended[`${currency1}${this.baseCurrency}`]?.[date]
|
||||
?.marketPrice *
|
||||
resultExtended[`${this.baseCurrency}${currency2}`]?.[date]
|
||||
?.marketPrice;
|
||||
|
||||
// Calculate the opposite direction
|
||||
this.exchangeRates[`${currency2}${currency1}`] =
|
||||
@ -126,9 +128,11 @@ export class ExchangeRateDataService {
|
||||
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
|
||||
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
|
||||
} else {
|
||||
// Calculate indirectly via USD
|
||||
const factor1 = this.exchangeRates[`${aFromCurrency}${'USD'}`];
|
||||
const factor2 = this.exchangeRates[`${'USD'}${aToCurrency}`];
|
||||
// Calculate indirectly via base currency
|
||||
const factor1 =
|
||||
this.exchangeRates[`${aFromCurrency}${this.baseCurrency}`];
|
||||
const factor2 =
|
||||
this.exchangeRates[`${this.baseCurrency}${aToCurrency}`];
|
||||
|
||||
factor = factor1 * factor2;
|
||||
|
||||
@ -166,21 +170,6 @@ export class ExchangeRateDataService {
|
||||
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({
|
||||
distinct: ['currency'],
|
||||
|
@ -26,7 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
PORT: number;
|
||||
RAKUTEN_RAPID_API_KEY: string;
|
||||
RAPID_API_API_KEY: string;
|
||||
REDIS_HOST: string;
|
||||
REDIS_PASSWORD: string;
|
||||
REDIS_PORT: number;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class PropertyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
value: string;
|
||||
}
|
||||
|
@ -64,6 +64,23 @@ export class SymbolProfileService {
|
||||
.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
|
||||
*/
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.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 { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
exports: [TwitterBotService],
|
||||
imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule],
|
||||
imports: [BenchmarkModule, ConfigurationModule, SymbolModule],
|
||||
providers: [TwitterBotService]
|
||||
})
|
||||
export class TwitterBotModule {}
|
||||
|
@ -1,9 +1,7 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
PROPERTY_BENCHMARKS,
|
||||
ghostfolioFearAndGreedIndexDataSource,
|
||||
ghostfolioFearAndGreedIndexSymbol
|
||||
} from '@ghostfolio/common/config';
|
||||
@ -11,7 +9,6 @@ import {
|
||||
resolveFearAndGreedIndex,
|
||||
resolveMarketCondition
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { isWeekend } from 'date-fns';
|
||||
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
|
||||
@ -23,7 +20,6 @@ export class TwitterBotService {
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly symbolService: SymbolService
|
||||
) {
|
||||
this.twitterClient = new TwitterApi({
|
||||
@ -57,13 +53,15 @@ export class TwitterBotService {
|
||||
symbolItem.marketPrice
|
||||
);
|
||||
|
||||
let status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)`;
|
||||
let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${
|
||||
symbolItem.marketPrice
|
||||
}/100)`;
|
||||
|
||||
const benchmarkListing = await this.getBenchmarkListing(3);
|
||||
|
||||
if (benchmarkListing?.length > 1) {
|
||||
status += '\n\n';
|
||||
status += '±% from ATH\n';
|
||||
status += '± from ATH in %\n';
|
||||
status += benchmarkListing;
|
||||
}
|
||||
|
||||
@ -82,14 +80,9 @@ export class TwitterBotService {
|
||||
}
|
||||
|
||||
private async getBenchmarkListing(aMax: number) {
|
||||
const benchmarkAssets: UniqueAsset[] =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as UniqueAsset[]) ?? [];
|
||||
|
||||
const benchmarks = await this.benchmarkService.getBenchmarks(
|
||||
benchmarkAssets
|
||||
);
|
||||
const benchmarks = await this.benchmarkService.getBenchmarks({
|
||||
useCache: false
|
||||
});
|
||||
|
||||
const benchmarkListing: string[] = [];
|
||||
|
||||
|
22
apps/client-e2e/project.json
Normal file
22
apps/client-e2e/project.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/client-e2e/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"e2e": {
|
||||
"executor": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/client-e2e/cypress.json",
|
||||
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
|
||||
"devServerTarget": "client:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "client:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'client',
|
||||
|
||||
|
201
apps/client/project.json
Normal file
201
apps/client/project.json
Normal file
@ -0,0 +1,201 @@
|
||||
{
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"generators": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"sourceRoot": "apps/client/src",
|
||||
"prefix": "gf",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/client",
|
||||
"index": "apps/client/src/index.html",
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "assetlinks.json",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../.well-known"
|
||||
},
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/ionicons/dist/ionicons",
|
||||
"output": "./../ionicons"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.js",
|
||||
"input": "node_modules/ionicons/dist/",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../assets/"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
},
|
||||
"development-en": {
|
||||
"baseHref": "/en/",
|
||||
"localize": ["en"]
|
||||
},
|
||||
"development-es": {
|
||||
"baseHref": "/es/",
|
||||
"localize": ["es"]
|
||||
},
|
||||
"development-it": {
|
||||
"baseHref": "/it/",
|
||||
"localize": ["it"]
|
||||
},
|
||||
"development-nl": {
|
||||
"baseHref": "/nl/",
|
||||
"localize": ["nl"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/client/src/environments/environment.ts",
|
||||
"with": "apps/client/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "10kb"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"proxyConfig": "apps/client/proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"browserTarget": "client:build:development-de"
|
||||
},
|
||||
"development-en": {
|
||||
"browserTarget": "client:build:development-en"
|
||||
},
|
||||
"development-es": {
|
||||
"browserTarget": "client:build:development-es"
|
||||
},
|
||||
"development-it": {
|
||||
"browserTarget": "client:build:development-it"
|
||||
},
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": [
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.it.xlf",
|
||||
"messages.nl.xlf"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/client/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/client"]
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
},
|
||||
"es": {
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
},
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
},
|
||||
"tags": []
|
||||
}
|
@ -2,5 +2,13 @@
|
||||
"/api": {
|
||||
"target": "http://localhost:3333",
|
||||
"secure": false
|
||||
},
|
||||
"/assets": {
|
||||
"target": "http://localhost:3333",
|
||||
"secure": false
|
||||
},
|
||||
"/ionicons": {
|
||||
"target": "http://localhost:3333",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
|
@ -95,6 +95,13 @@ const routes: Routes = [
|
||||
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
|
||||
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/10/hacktoberfest-2022',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
|
||||
).then((m) => m.Hacktoberfest2022PageModule)
|
||||
},
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
} from '@ghostfolio/common/config';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { ColorScheme } from '@ghostfolio/common/types';
|
||||
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
@ -77,6 +78,8 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
permissions.createUserAccount
|
||||
);
|
||||
|
||||
this.initializeTheme(this.user?.settings.colorScheme);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
@ -97,13 +100,17 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private initializeTheme() {
|
||||
this.materialCssVarsService.setDarkTheme(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
private initializeTheme(userPreferredColorScheme?: ColorScheme) {
|
||||
const isDarkTheme = userPreferredColorScheme
|
||||
? userPreferredColorScheme === 'DARK'
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
this.materialCssVarsService.setDarkTheme(isDarkTheme);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
|
||||
this.materialCssVarsService.setDarkTheme(event.matches);
|
||||
if (!this.user?.settings.colorScheme) {
|
||||
this.materialCssVarsService.setDarkTheme(event.matches);
|
||||
}
|
||||
});
|
||||
|
||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
||||
|
@ -1,8 +1,15 @@
|
||||
<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>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.granteeAlias }}
|
||||
{{ element.grantee }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
@ -33,7 +33,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.displayedColumns = ['granteeAlias', 'type', 'details'];
|
||||
this.displayedColumns = ['alias', 'grantee', 'type', 'details'];
|
||||
|
||||
if (this.showActions) {
|
||||
this.displayedColumns.push('actions');
|
||||
|
@ -61,7 +61,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
|
||||
this.accountType = accountType;
|
||||
this.name = name;
|
||||
this.platformName = Platform?.name;
|
||||
this.platformName = Platform?.name ?? '-';
|
||||
this.valueInBaseCurrency = valueInBaseCurrency;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
@ -21,10 +21,12 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value size="medium" [value]="accountType">Account Type</gf-value>
|
||||
<gf-value i18n size="medium" [value]="accountType"
|
||||
>Account Type</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value size="medium" [value]="platformName">Platform</gf-value>
|
||||
<gf-value i18n size="medium" [value]="platformName">Platform</gf-value>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -2,7 +2,10 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<form class="align-items-center d-flex" [formGroup]="filterForm">
|
||||
<mat-form-field appearance="outline" class="flex-grow-1">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline flex-grow-1 mr-2 without-hint"
|
||||
>
|
||||
<mat-select formControlName="status">
|
||||
<mat-option></mat-option>
|
||||
<mat-option
|
||||
@ -13,7 +16,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button
|
||||
class="ml-1"
|
||||
class="mt-1"
|
||||
color="warn"
|
||||
mat-flat-button
|
||||
(click)="onDeleteJobs()"
|
||||
|
@ -2,6 +2,7 @@
|
||||
<gf-line-chart
|
||||
class="mb-4"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isAnimated]="true"
|
||||
[locale]="locale"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
|
@ -14,8 +14,7 @@ import {
|
||||
getDateFormatString,
|
||||
getLocale
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import {
|
||||
addDays,
|
||||
|
@ -3,17 +3,27 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
OnInit,
|
||||
ViewChild
|
||||
} 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 { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||
import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||
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 { 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({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@ -22,11 +32,46 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './admin-market-data.html'
|
||||
})
|
||||
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 currentSymbol: string;
|
||||
public dataSource: MatTableDataSource<AdminMarketDataItem> =
|
||||
new MatTableDataSource();
|
||||
public defaultDateFormat: string;
|
||||
public marketData: AdminMarketDataItem[] = [];
|
||||
public marketDataDetails: MarketData[] = [];
|
||||
public deviceType: string;
|
||||
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;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -35,8 +80,28 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
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
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
@ -51,7 +116,31 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -75,28 +164,23 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||
if (withRefresh) {
|
||||
this.fetchAdminMarketData();
|
||||
this.fetchAdminMarketDataBySymbol({
|
||||
dataSource: this.currentDataSource,
|
||||
symbol: this.currentSymbol
|
||||
});
|
||||
}
|
||||
}
|
||||
public onOpenAssetProfileDialog({
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol
|
||||
}: UniqueAsset & { dateOfFirstActivity: string }) {
|
||||
try {
|
||||
dateOfFirstActivity = format(parseISO(dateOfFirstActivity), DATE_FORMAT);
|
||||
} catch {}
|
||||
|
||||
public setCurrentProfile({ dataSource, symbol }: UniqueAsset) {
|
||||
this.marketDataDetails = [];
|
||||
|
||||
if (this.currentSymbol === symbol) {
|
||||
this.currentDataSource = undefined;
|
||||
this.currentSymbol = '';
|
||||
} else {
|
||||
this.currentDataSource = dataSource;
|
||||
this.currentSymbol = symbol;
|
||||
|
||||
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
|
||||
}
|
||||
this.router.navigate([], {
|
||||
queryParams: {
|
||||
dateOfFirstActivity,
|
||||
dataSource,
|
||||
symbol,
|
||||
assetProfileDialog: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
@ -104,25 +188,40 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private fetchAdminMarketData() {
|
||||
this.dataService
|
||||
.fetchAdminMarketData()
|
||||
private openAssetProfileDialog({
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
dateOfFirstActivity: string;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketData }) => {
|
||||
this.marketData = marketData;
|
||||
.subscribe((user) => {
|
||||
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) {
|
||||
this.adminService
|
||||
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketData }) => {
|
||||
this.marketDataDetails = marketData;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,76 +1,147 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<table class="gf-table w-100">
|
||||
<thead>
|
||||
<tr class="mat-header-row">
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
|
||||
<th class="mat-header-cell px-1 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ng-container *ngFor="let item of marketData; let i = index">
|
||||
<tr
|
||||
class="cursor-pointer mat-row"
|
||||
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })"
|
||||
<gf-activities-filter
|
||||
[allFilters]="allFilters"
|
||||
[isLoading]="isLoading"
|
||||
[placeholder]="placeholder"
|
||||
(valueChanged)="filters$.next($event)"
|
||||
></gf-activities-filter>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
matSort
|
||||
matSortActive="symbol"
|
||||
matSortDirection="asc"
|
||||
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>
|
||||
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
{{ (item.date | date: defaultDateFormat) ?? '' }}
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="onGatherSymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
||||
>
|
||||
<ng-container i18n>Gather Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
||||
>
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="item.activityCount !== 0"
|
||||
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
|
||||
>
|
||||
<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>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
>
|
||||
<ng-container i18n>Gather Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="onGatherProfileDataBySymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
>
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.activityCount !== 0"
|
||||
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
>
|
||||
<ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="cursor-pointer"
|
||||
mat-row
|
||||
(click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,17 +2,23 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
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 { GfAssetProfileDialogModule } from './asset-profile-dialog/assset-profile-dialog.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AdminMarketDataComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfAdminMarketDataDetailModule,
|
||||
GfActivitiesFilterModule,
|
||||
GfAssetProfileDialogModule,
|
||||
MatButtonModule,
|
||||
MatMenuModule
|
||||
MatMenuModule,
|
||||
MatSortModule,
|
||||
MatTableModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -0,0 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-dialog-content {
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
@ -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>
|
@ -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 {}
|
@ -0,0 +1,9 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface AssetProfileDialogParams {
|
||||
dateOfFirstActivity: string;
|
||||
dataSource: DataSource;
|
||||
deviceType: string;
|
||||
locale: string;
|
||||
symbol: string;
|
||||
}
|
@ -99,7 +99,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
...this.coupons,
|
||||
{ code: this.generateCouponCode(16), duration: this.couponDuration }
|
||||
];
|
||||
this.putCoupons(coupons);
|
||||
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
||||
}
|
||||
|
||||
public onAddCurrency() {
|
||||
@ -107,7 +107,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
|
||||
if (currency) {
|
||||
const currencies = uniq([...this.customCurrencies, currency]);
|
||||
this.putCurrencies(currencies);
|
||||
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,7 +124,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
const coupons = this.coupons.filter((coupon) => {
|
||||
return coupon.code !== aCouponCode;
|
||||
});
|
||||
this.putCoupons(coupons);
|
||||
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,12 +137,12 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
const currencies = this.customCurrencies.filter((currency) => {
|
||||
return currency !== aCurrency;
|
||||
});
|
||||
this.putCurrencies(currencies);
|
||||
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
|
||||
}
|
||||
}
|
||||
|
||||
public onDeleteSystemMessage() {
|
||||
this.putSystemMessage('');
|
||||
this.putAdminSetting({ key: PROPERTY_SYSTEM_MESSAGE, value: undefined });
|
||||
}
|
||||
|
||||
public onFlushCache() {
|
||||
@ -192,14 +192,20 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
|
||||
this.setReadOnlyMode(aEvent.checked);
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_IS_READ_ONLY_MODE,
|
||||
value: aEvent.checked ? true : undefined
|
||||
});
|
||||
}
|
||||
|
||||
public onSetSystemMessage() {
|
||||
const systemMessage = prompt($localize`Please set your system message:`);
|
||||
|
||||
if (systemMessage) {
|
||||
this.putSystemMessage(systemMessage);
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_SYSTEM_MESSAGE,
|
||||
value: systemMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -236,49 +242,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
return couponCode;
|
||||
}
|
||||
|
||||
private putCoupons(aCoupons: Coupon[]) {
|
||||
private putAdminSetting({ key, value }: { key: string; value: any }) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_COUPONS, {
|
||||
value: JSON.stringify(aCoupons)
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
private putCurrencies(aCurrencies: string[]) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_CURRENCIES, {
|
||||
value: JSON.stringify(aCurrencies)
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
private putSystemMessage(aSystemMessage: string) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_SYSTEM_MESSAGE, {
|
||||
value: aSystemMessage
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
private setReadOnlyMode(aValue: boolean) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_IS_READ_ONLY_MODE, {
|
||||
value: aValue ? 'true' : ''
|
||||
.putAdminSetting(key, {
|
||||
value: value ? JSON.stringify(value) : undefined
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
|
@ -5,15 +5,26 @@
|
||||
<mat-card-content>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Count</div>
|
||||
<div class="w-50">{{ userCount }}</div>
|
||||
<div class="w-50">
|
||||
<gf-value
|
||||
precision="0"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="userCount"
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>Activity Count</div>
|
||||
<div class="w-50">
|
||||
<ng-container *ngIf="transactionCount">
|
||||
{{ transactionCount }} ({{ transactionCount / userCount | number
|
||||
: '1.2-2' }} <span i18n>per User</span>)
|
||||
</ng-container>
|
||||
<gf-value
|
||||
precision="0"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="transactionCount"
|
||||
></gf-value>
|
||||
<div *ngIf="transactionCount && userCount">
|
||||
{{ transactionCount / userCount | number : '1.2-2' }}
|
||||
<span i18n>per User</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
@ -108,10 +119,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-start d-flex my-3">
|
||||
<div class="w-50" i18n>Benchmarks</div>
|
||||
<div class="w-50">
|
||||
<table>
|
||||
<tr *ngFor="let benchmark of info?.benchmarks">
|
||||
<td class="pl-1">{{ benchmark.symbol }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
||||
<div class="w-50" i18n>System Message</div>
|
||||
<div class="w-50">
|
||||
<div *ngIf="info.systemMessage">
|
||||
<div *ngIf="info?.systemMessage">
|
||||
<span>{{ info.systemMessage }}</span>
|
||||
<button
|
||||
class="mini-icon mx-1 no-min-width px-2"
|
||||
@ -122,7 +143,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
*ngIf="!info.systemMessage"
|
||||
*ngIf="!info?.systemMessage"
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onSetSystemMessage()"
|
||||
@ -162,8 +183,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<form #couponForm="ngForm">
|
||||
<mat-form-field appearance="outline" class="mr-2">
|
||||
<form #couponForm="ngForm" class="align-items-center d-flex">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline mr-2 without-hint"
|
||||
>
|
||||
<mat-select
|
||||
name="duration"
|
||||
[value]="couponDuration"
|
||||
@ -176,6 +200,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button
|
||||
class="mt-1"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
(click)="onAddCoupon()"
|
||||
|
@ -14,8 +14,8 @@ import { AdminOverviewComponent } from './admin-overview.component';
|
||||
declarations: [AdminOverviewComponent],
|
||||
exports: [],
|
||||
imports: [
|
||||
FormsModule,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user