Compare commits
127 Commits
Author | SHA1 | Date | |
---|---|---|---|
93454c6c15 | |||
fccbd76993 | |||
922876a893 | |||
654446f068 | |||
947460abdd | |||
c5635b0050 | |||
8a3a6308a3 | |||
290a07fe79 | |||
4c907d56f0 | |||
56b437ca74 | |||
e23ff33e6f | |||
f2d206262e | |||
1ed5690b33 | |||
4451514ec5 | |||
8f73f85276 | |||
9a5d7b664b | |||
7d2d1d971a | |||
d111493eed | |||
e975f92a96 | |||
739cb4242d | |||
a37eebc9f1 | |||
e92730879e | |||
464973f9b0 | |||
f6228c099f | |||
a57fdfb2bb | |||
24716f0561 | |||
3453100afd | |||
84de2c0c68 | |||
1b7b082003 | |||
1928c2c2cc | |||
52e7a7886d | |||
36298b217e | |||
9bce57894e | |||
9d6bb325cd | |||
a5f833c612 | |||
732b14c6ab | |||
b74a042da8 | |||
d55c052f57 | |||
864f585efa | |||
6d56146054 | |||
2c9f29a3c6 | |||
9bef2e960c | |||
17b8c41673 | |||
f0afbd7346 | |||
5dc7429f6a | |||
7b39b32293 | |||
e5b5a9e7e9 | |||
1f3511368a | |||
b37df2c84f | |||
f92ba54060 | |||
a3bbd4030e | |||
4b30da2d92 | |||
93d082afbb | |||
0c85380dbf | |||
fb576376dc | |||
ff111d4c6c | |||
bc6e9a8b68 | |||
bd1963ec26 | |||
a0bec9e97f | |||
c45df20d88 | |||
fa1d669633 | |||
1009b462e9 | |||
b404858904 | |||
7ec033577f | |||
c8ca82b803 | |||
5db2faa17d | |||
1605fb8d48 | |||
b6a7804a26 | |||
de31381fd9 | |||
0d92b8d8bb | |||
7c6ff776d9 | |||
e37a34ed6c | |||
c4d9c00f92 | |||
3af8be89e3 | |||
0f1db71604 | |||
fce9e7fb0c | |||
6301c0c21c | |||
30bb484d5a | |||
f88ee5e5a0 | |||
73b5030972 | |||
a69a3442ab | |||
d4dff744b5 | |||
62c93ad99d | |||
1e42d6bffa | |||
002ac29f2f | |||
20ccf389e9 | |||
a2f99ed4d2 | |||
cc6320acfd | |||
261a0fb0b9 | |||
cfc05cce41 | |||
1f15b70134 | |||
a5b49b286d | |||
f3333f24da | |||
cad8f0d0e2 | |||
edd3e75730 | |||
ab68c2c69a | |||
cbb95f21a3 | |||
74d3954335 | |||
92449b0369 | |||
65276483e0 | |||
dde0d1e465 | |||
3ad802c6f5 | |||
b81377a682 | |||
545180b88f | |||
a9819b9e25 | |||
897e941e7a | |||
aef840c2cc | |||
80d0638922 | |||
494ba36d44 | |||
dab9154092 | |||
cd4a85abbf | |||
e7977a9fbb | |||
684c1e55b0 | |||
1ffa831c5c | |||
40eed0016c | |||
b58631083b | |||
e0c0425d21 | |||
bf2de5d572 | |||
2b4a1dc480 | |||
ce022c024f | |||
0f4bf529d8 | |||
dad6bf7095 | |||
86ca9eaae6 | |||
9d9b805b0e | |||
851401be1e | |||
85052bc9bc | |||
bff09f529d |
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 18
|
||||
- 16
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@ -41,7 +41,7 @@ jobs:
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.output.labels }}
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,6 +25,7 @@
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
.env
|
||||
.env.prod
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
@ -38,4 +39,4 @@ yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
|
@ -1,10 +1,7 @@
|
||||
module.exports = {
|
||||
stories: [],
|
||||
addons: ['@storybook/addon-essentials']
|
||||
// uncomment the property below if you want to apply some webpack config globally
|
||||
// webpackFinal: async (config, { configType }) => {
|
||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||
|
||||
// // Return the altered config
|
||||
// return config;
|
||||
// },
|
||||
|
297
CHANGELOG.md
297
CHANGELOG.md
@ -5,6 +5,295 @@ 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.256.0 - 2023-04-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _Yahoo Finance_ data enhancer for countries, sectors and urls
|
||||
|
||||
### Changed
|
||||
|
||||
- Enabled the configuration to immediately remove queue jobs on complete
|
||||
- Refactored the implementation of removing queue jobs
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the unique job ids of the gather asset profile process
|
||||
- Fixed the style of the button to fetch the current market price
|
||||
|
||||
## 1.255.0 - 2023-04-15
|
||||
|
||||
### Added
|
||||
|
||||
- Made the system message expandable
|
||||
|
||||
### Changed
|
||||
|
||||
- Skipped creating queue jobs for asset profiles with `MANUAL` data source not having a scraper configuration
|
||||
- Reduced the execution interval of the data gathering to every hour
|
||||
- Upgraded `prisma` from version `4.11.0` to `4.12.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the style of the system message
|
||||
|
||||
## 1.254.0 - 2023-04-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the queue jobs implementation by adding in bulk
|
||||
- Improved the queue jobs implementation by introducing unique job ids
|
||||
- Reverted the execution interval of the data gathering from every 12 hours to every 4 hours
|
||||
|
||||
## 1.253.0 - 2023-04-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Reduced the execution interval of the data gathering to every 12 hours
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the background color of dialogs in dark mode
|
||||
|
||||
## 1.252.2 - 2023-04-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Deprecated the `auth` endpoint of the login with _Security Token_ (`GET`)
|
||||
|
||||
## 1.252.1 - 2023-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the slide toggles to checkboxes on the account page
|
||||
- Changed the slide toggles to checkboxes in the admin control panel
|
||||
- Decreased the density of the theme
|
||||
- Migrated the style of various components to `@angular/material` `15` (mdc)
|
||||
- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6`
|
||||
- Upgraded `bull` from version `4.10.2` to `4.10.4`
|
||||
|
||||
## 1.251.0 - 2023-04-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the activities import for `csv` files exported by _Interactive Brokers_
|
||||
- Improved the rendering of the chart ticks (`0.5K` → `500`)
|
||||
- Increased the historical market data gathering of currency pairs to 10+ years
|
||||
- Improved the content of the Frequently Asked Questions (FAQ) page
|
||||
- Improved the content of the pricing page
|
||||
- Changed the `auth` endpoint of the login with _Security Token_ from `GET` to `POST`
|
||||
- Changed the `auth` endpoint of the _Internet Identity_ login provider from `GET` to `POST`
|
||||
- Migrated the style of the `libs` components to `@angular/material` `15` (mdc)
|
||||
- `ActivitiesFilterComponent`
|
||||
- `ActivitiesTableComponent`
|
||||
- `BenchmarkComponent`
|
||||
- `HoldingsTableComponent`
|
||||
- Upgraded `angular` from version `15.1.5` to `15.2.5`
|
||||
- Upgraded `Nx` from version `15.7.2` to `15.9.2`
|
||||
|
||||
## 1.250.0 - 2023-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for multiple subscription offers
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the portfolio evolution chart (ignore first item)
|
||||
- Improved the accounts import by handling the platform
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with more than 50 activities in the activities import (`dryRun`)
|
||||
|
||||
## 1.249.0 - 2023-03-27
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the testimonial section on the landing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the loading state of the value component on the allocations page
|
||||
- Improved the value component by always showing the label (also while loading)
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the algebraic sign in the value component
|
||||
|
||||
## 1.248.0 - 2023-03-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added a blog post: _Ghostfolio reaches 1’000 Stars on GitHub_
|
||||
- Added a breadcrumb navigation to the blog post pages
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored the calculation of the chart
|
||||
- Hid the platform selector if no platforms are available in the create or update account dialog
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.5.0` to `2.6.0`
|
||||
|
||||
## 1.247.0 - 2023-03-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added the asset and asset sub class to the search functionality
|
||||
- Added the subscription expiration date to the users table of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the URL of the Ghostfolio Slack channel
|
||||
- Upgraded `prisma` from version `4.10.1` to `4.11.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the total amount calculation in the portfolio evolution chart
|
||||
|
||||
## 1.246.0 - 2023-03-18
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for asset and asset sub class to the `EOD_HISTORICAL_DATA` data source type
|
||||
- Added `isin` to the asset profile model
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the _Trackinsight_ data enhancer for asset profile data by `isin`
|
||||
- Improved the language localization for _Gather Data_
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the border color in the _FIRE_ calculator (dark mode)
|
||||
|
||||
## 1.245.0 - 2023-03-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added the search functionality for the `EOD_HISTORICAL_DATA` data source type
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the _FIRE_ calculator
|
||||
- Improved the exchange rate service for a specific date used in activities with a manual currency
|
||||
- Upgraded `ngx-device-detector` from version `3.0.0` to `5.0.1`
|
||||
|
||||
## 1.244.0 - 2023-03-09
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the _FIRE_ calculator by a retirement date setting
|
||||
|
||||
## 1.243.0 - 2023-03-08
|
||||
|
||||
### Added
|
||||
|
||||
- Added `COINGECKO` as a default to `DATA_SOURCES`
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the validation of the manual currency for the activity fee and unit price
|
||||
- Harmonized the axis style of charts
|
||||
- Made setting `NODE_ENV: production` optional (to avoid `ENOENT: no such file or directory` errors on startup)
|
||||
- Removed the environment variable `ENABLE_FEATURE_CUSTOM_SYMBOLS`
|
||||
|
||||
## 1.242.0 - 2023-03-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified the database seeding
|
||||
- Upgraded `ngx-skeleton-loader` from version `5.0.0` to `7.0.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Downgraded `Node.js` from version `18` to `16` (Dockerfile) to resolve `SIGSEGV` (segmentation fault) during the `prisma` database migrations (see https://github.com/prisma/prisma/issues/10649)
|
||||
|
||||
## 1.241.0 - 2023-03-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Filtered activities with type `ITEM` from search results
|
||||
- Considered the user's language in the _Stripe_ checkout
|
||||
- Upgraded the _Stripe_ dependencies
|
||||
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
|
||||
|
||||
## 1.240.0 - 2023-02-26
|
||||
|
||||
### Added
|
||||
|
||||
- Supported a manual currency for the activity unit price
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the feature graphic of the _Ghostfolio meets Umbrel_ blog post
|
||||
|
||||
## 1.239.0 - 2023-02-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added a blog post: _Ghostfolio meets Umbrel_
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the dependency `rimraf`
|
||||
|
||||
## 1.238.0 - 2023-02-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added `COINGECKO` as a new data source type
|
||||
- Added support for data provider information to the position detail dialog
|
||||
- Added the configuration to publish a `linux/arm/v7` docker image
|
||||
- Added _Reddit_ to the _As seen in_ section on the landing page
|
||||
- Added _Umbrel_ to the _As seen in_ section on the landing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the example environment variable file from `.env` to `.env.example`
|
||||
- Upgraded `zone.js` from version `0.11.8` to `0.12.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `RangeError: Maximum call stack size exceeded` for values of type `Big` in the value redaction interceptor for the impersonation mode
|
||||
- Reset the letter spacing in buttons
|
||||
|
||||
### Todo
|
||||
|
||||
- Ensure that you still have a `.env` file in your project
|
||||
|
||||
## 1.237.0 - 2023-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added the support details to the pricing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the file size limit for the activities import
|
||||
- Improved the style of the search results for symbols
|
||||
- Migrated the style of `GfHeaderModule` to `@angular/material` `15` (mdc)
|
||||
- Upgraded `angular` from version `15.1.2` to `15.1.5`
|
||||
- Upgraded `Nx` from version `15.6.3` to `15.7.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with exact matches in the activities table filter (`VT` vs. `VTI`)
|
||||
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
|
||||
|
||||
## 1.236.0 - 2023-02-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Beautified the ETF names in the asset profile
|
||||
- Removed the data source type `GHOSTFOLIO`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
|
||||
- Fixed the buying power calculation if no emergency fund is set but an activity is tagged as _Emergency Fund_
|
||||
- Fixed the url on logout during the local development
|
||||
|
||||
## 1.235.0 - 2023-02-16
|
||||
|
||||
### Changed
|
||||
@ -51,7 +340,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Added support to export accounts
|
||||
- Added suport to import accounts
|
||||
- Added support to import accounts
|
||||
|
||||
### Changed
|
||||
|
||||
@ -725,7 +1014,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Added the alias to the `Access` database schema
|
||||
- Added support for translated time distances
|
||||
- Added a _GitHub Action_ to create an `arm64` docker image
|
||||
- Added a _GitHub Action_ to create an `linux/arm64` docker image
|
||||
|
||||
### Changed
|
||||
|
||||
@ -1338,7 +1627,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Beautified the ETF names in the symbol profile
|
||||
- Beautified the ETF names in the asset profile
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -1763,7 +2052,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Extended the historical data view in the admin control panel
|
||||
- Upgraded _Stripe_ dependencies
|
||||
- Upgraded the _Stripe_ dependencies
|
||||
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
||||
|
||||
### Fixed
|
||||
|
25
DEVELOPMENT.md
Normal file
25
DEVELOPMENT.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Ghostfolio Development Guide
|
||||
|
||||
## Git
|
||||
|
||||
### Rebase
|
||||
|
||||
`git rebase -i --autosquash main`
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Nx
|
||||
|
||||
#### Upgrade
|
||||
|
||||
1. Run `yarn nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||
1. Run `yarn nx migrate --run-migrations`
|
||||
|
||||
### Prisma
|
||||
|
||||
#### Create schema migration (local)
|
||||
|
||||
Run `yarn prisma migrate dev --name added_job_title`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:18-slim
|
||||
FROM node:16-slim
|
||||
RUN apt update && apt install -y \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
51
README.md
51
README.md
@ -75,7 +75,7 @@ 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`.
|
||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@ -85,20 +85,19 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application.<br />`AUD` \| `CAD` \| `CNY` \| `EUR` \| `GBP` \| `JPY` \| `RUB` \| `USD`<br />Caution: Only set if you intend to track cryptocurrencies in a non-`USD` currency. This cannot be changed later! |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -106,7 +105,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
- Basic knowledge of Docker
|
||||
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- Local copy of this Git repository (clone)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
|
||||
#### a. Run environment
|
||||
|
||||
@ -125,13 +125,10 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
```
|
||||
|
||||
#### Fetch Historical Data
|
||||
|
||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
#### Setup
|
||||
|
||||
1. Open http://localhost:3333 in your browser
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
#### Upgrade Version
|
||||
|
||||
@ -150,18 +147,18 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- A local copy of this Git repository (clone)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
|
||||
### 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. Run `yarn database:setup` to initialize the database schema
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Open http://localhost:4200/en in your browser
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
### Start Server
|
||||
|
||||
@ -203,7 +200,9 @@ 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>`.
|
||||
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
||||
|
||||
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||
|
||||
### Import Activities
|
||||
|
||||
|
@ -2,13 +2,14 @@
|
||||
export default {
|
||||
displayName: 'api',
|
||||
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||
}
|
||||
},
|
||||
globals: {},
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': 'ts-jest'
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||
}
|
||||
]
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
@ -83,7 +84,7 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||
): Promise<Accounts> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
@ -101,7 +102,7 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountById(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Param('id') id: string
|
||||
): Promise<AccountWithValue> {
|
||||
const impersonationUserId =
|
||||
|
@ -100,16 +100,21 @@ export class AdminController {
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: `${dataSource}-${symbol}`
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
this.dataGatheringService.gatherMax();
|
||||
}
|
||||
@ -131,16 +136,21 @@ export class AdminController {
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: `${dataSource}-${symbol}`
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Post('gather/profile-data/:dataSource/:symbol')
|
||||
@ -161,14 +171,17 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
await this.dataGatheringService.addJobToQueue({
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: `${dataSource}-${symbol}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol')
|
||||
|
@ -100,6 +100,7 @@ export class AdminService {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
assetClass: 'CASH',
|
||||
countriesCount: 0,
|
||||
sectorsCount: 0
|
||||
};
|
||||
@ -186,8 +187,11 @@ export class AdminService {
|
||||
]);
|
||||
|
||||
return {
|
||||
assetProfile,
|
||||
marketData
|
||||
marketData,
|
||||
assetProfile: assetProfile ?? {
|
||||
symbol,
|
||||
currency: '-'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -231,12 +235,27 @@ export class AdminService {
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy: {
|
||||
let orderBy: any = {
|
||||
createdAt: 'desc'
|
||||
};
|
||||
let where;
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
orderBy = {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
},
|
||||
};
|
||||
where = {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Account: true, Order: true }
|
||||
@ -252,19 +271,16 @@ export class AdminService {
|
||||
id: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30,
|
||||
where: {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
}
|
||||
take: 30
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||
const engagement = Analytics
|
||||
? Analytics.activityCount / daysSinceRegistration
|
||||
: undefined;
|
||||
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
@ -278,8 +294,8 @@ export class AdminService {
|
||||
id,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
country: Analytics.country,
|
||||
lastActivity: Analytics.updatedAt,
|
||||
country: Analytics?.country,
|
||||
lastActivity: Analytics?.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
};
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
} from '@ghostfolio/common/config';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JobStatus, Queue } from 'bull';
|
||||
|
||||
@Injectable()
|
||||
@ -23,14 +23,11 @@ export class QueueService {
|
||||
}: {
|
||||
status?: JobStatus[];
|
||||
}) {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
await job.remove();
|
||||
} catch (error) {
|
||||
Logger.warn(error, 'QueueService');
|
||||
}
|
||||
for (const statusItem of status) {
|
||||
await this.dataGatheringQueue.clean(
|
||||
300,
|
||||
statusItem === 'waiting' ? 'wait' : statusItem
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,18 +41,23 @@ export class QueueService {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||
|
||||
const jobsWithState = await Promise.all(
|
||||
jobs.slice(0, limit).map(async (job) => {
|
||||
return {
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
data: job.data,
|
||||
finishedOn: job.finishedOn,
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
stacktrace: job.stacktrace,
|
||||
state: await job.getState(),
|
||||
timestamp: job.timestamp
|
||||
};
|
||||
})
|
||||
jobs
|
||||
.filter((job) => {
|
||||
return job;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map(async (job) => {
|
||||
return {
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
data: job.data,
|
||||
finishedOn: job.finishedOn,
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
stacktrace: job.stacktrace,
|
||||
state: await job.getState(),
|
||||
timestamp: job.timestamp
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -33,8 +33,11 @@ export class AuthController {
|
||||
private readonly webAuthService: WebAuthService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('anonymous/:accessToken')
|
||||
public async accessTokenLogin(
|
||||
public async accessTokenLoginGet(
|
||||
@Param('accessToken') accessToken: string
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
@ -50,6 +53,23 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@Post('anonymous')
|
||||
public async accessTokenLogin(
|
||||
@Body() body: { accessToken: string }
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateAnonymousLogin(
|
||||
body.accessToken
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('google')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
public googleLogin() {
|
||||
@ -81,13 +101,13 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@Get('internet-identity/:principalId')
|
||||
@Post('internet-identity')
|
||||
public async internetIdentityLogin(
|
||||
@Param('principalId') principalId: string
|
||||
@Body() body: { principalId: string }
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||
principalId
|
||||
body.principalId
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
|
@ -1,33 +1,46 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
public constructor(
|
||||
readonly configurationService: ConfigurationService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
passReqToCallback: true,
|
||||
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
||||
});
|
||||
}
|
||||
|
||||
public async validate({ id }: { id: string }) {
|
||||
public async validate(request: Request, { id }: { id: string }) {
|
||||
try {
|
||||
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
|
||||
const user = await this.userService.user({ id });
|
||||
|
||||
if (user) {
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { User: { connect: { id: user.id } } },
|
||||
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
||||
where: { userId: user.id }
|
||||
});
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
const country =
|
||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { country, User: { connect: { id: user.id } } },
|
||||
update: {
|
||||
country,
|
||||
activityCount: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
},
|
||||
where: { userId: user.id }
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
} else {
|
||||
|
@ -1,6 +1,13 @@
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { ExchangeRateService } from './exchange-rate.service';
|
||||
|
||||
@ -18,9 +25,18 @@ export class ExchangeRateController {
|
||||
): Promise<IDataProviderHistoricalResponse> {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return this.exchangeRateService.getExchangeRate({
|
||||
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (exchangeRate) {
|
||||
return { marketPrice: exchangeRate };
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@ -14,16 +13,14 @@ export class ExchangeRateService {
|
||||
}: {
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<IDataProviderHistoricalResponse> {
|
||||
}): Promise<number> {
|
||||
const [currency1, currency2] = symbol.split('-');
|
||||
|
||||
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
return this.exchangeRateDataService.toCurrencyAtDate(
|
||||
1,
|
||||
currency1,
|
||||
currency2,
|
||||
date
|
||||
);
|
||||
|
||||
return { marketPrice };
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { format } from 'date-fns';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@ -18,18 +18,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public indexHtmlPt = '';
|
||||
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'),
|
||||
@ -90,12 +82,24 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
||||
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
||||
title = `Ghostfolio meets Umbrel - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
|
||||
title = `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`;
|
||||
}
|
||||
|
||||
if (
|
||||
request.path.startsWith('/api/') ||
|
||||
this.isFileRequest(request.url) ||
|
||||
!this.isProduction
|
||||
!environment.production
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
|
@ -13,6 +13,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
import { PlatformModule } from '@ghostfolio/api/services/platform/platform.module';
|
||||
|
||||
@Module({
|
||||
controllers: [ImportController],
|
||||
@ -24,6 +25,7 @@ import { ImportService } from './import.service';
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
OrderModule,
|
||||
PlatformModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
|
@ -6,6 +6,7 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PlatformService } from '@ghostfolio/api/services/platform/platform.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
@ -14,7 +15,7 @@ import {
|
||||
OrderWithAccount
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import { Prisma, SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@ -26,6 +27,7 @@ export class ImportService {
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly platformService: PlatformService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
@ -118,15 +120,18 @@ export class ImportService {
|
||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||
|
||||
if (!isDryRun && accountsDto?.length) {
|
||||
const existingAccounts = await this.accountService.accounts({
|
||||
where: {
|
||||
id: {
|
||||
in: accountsDto.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||
this.accountService.accounts({
|
||||
where: {
|
||||
id: {
|
||||
in: accountsDto.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}),
|
||||
this.platformService.get()
|
||||
]);
|
||||
|
||||
for (const account of accountsDto) {
|
||||
// Check if there is any existing account with the same ID
|
||||
@ -146,19 +151,24 @@ export class ImportService {
|
||||
delete account.id;
|
||||
}
|
||||
|
||||
const newAccountObject = {
|
||||
let accountObject: Prisma.AccountCreateInput = {
|
||||
...account,
|
||||
User: { connect: { id: userId } }
|
||||
};
|
||||
|
||||
if (platformId) {
|
||||
Object.assign(newAccountObject, {
|
||||
if (
|
||||
existingPlatforms.some(({ id }) => {
|
||||
return id === platformId;
|
||||
})
|
||||
) {
|
||||
accountObject = {
|
||||
...accountObject,
|
||||
Platform: { connect: { id: platformId } }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const newAccount = await this.accountService.createAccount(
|
||||
newAccountObject,
|
||||
accountObject,
|
||||
userId
|
||||
);
|
||||
|
||||
@ -254,6 +264,7 @@ export class ImportService {
|
||||
countries: null,
|
||||
createdAt: undefined,
|
||||
id: undefined,
|
||||
isin: null,
|
||||
name: null,
|
||||
scraperConfiguration: null,
|
||||
sectors: null,
|
||||
|
@ -6,8 +6,8 @@ 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 {
|
||||
DEMO_USER_ID,
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||
PROPERTY_DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
@ -22,6 +22,7 @@ import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bent from 'bent';
|
||||
@ -59,9 +60,7 @@ export class InfoService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
);
|
||||
@ -120,7 +119,7 @@ export class InfoService {
|
||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
demoAuthToken: await this.getDemoAuthToken(),
|
||||
statistics: await this.getStatistics(),
|
||||
subscriptions: await this.getSubscriptions(),
|
||||
tags: await this.tagService.get()
|
||||
@ -248,10 +247,18 @@ export class InfoService {
|
||||
)) as string;
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: DEMO_USER_ID
|
||||
});
|
||||
private async getDemoAuthToken() {
|
||||
const demoUserId = (await this.propertyService.getByKey(
|
||||
PROPERTY_DEMO_USER_ID
|
||||
)) as string;
|
||||
|
||||
if (demoUserId) {
|
||||
return this.jwtService.sign({
|
||||
id: demoUserId
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getStatistics() {
|
||||
@ -298,19 +305,17 @@ export class InfoService {
|
||||
return statistics;
|
||||
}
|
||||
|
||||
private async getSubscriptions(): Promise<Subscription[]> {
|
||||
private async getSubscriptions(): Promise<{
|
||||
[offer in SubscriptionOffer]: Subscription;
|
||||
}> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
})) ?? { value: '{}' };
|
||||
|
||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||
|
||||
return subscriptions;
|
||||
return JSON.parse(stripeConfig.value);
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
|
||||
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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
@ -66,7 +67,7 @@ export class OrderController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
|
@ -110,19 +110,19 @@ export class OrderService {
|
||||
dataSource,
|
||||
symbol: id
|
||||
};
|
||||
} else {
|
||||
data.SymbolProfile.connectOrCreate.create.symbol =
|
||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
await this.dataGatheringService.addJobToQueue({
|
||||
data: {
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
|
||||
}
|
||||
});
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
@ -48,8 +49,11 @@ export const CurrentRateServiceMock = {
|
||||
getValues: ({
|
||||
dataGatheringItems,
|
||||
dateQuery
|
||||
}: GetValuesParams): Promise<GetValueObject[]> => {
|
||||
const result: GetValueObject[] = [];
|
||||
}: GetValuesParams): Promise<{
|
||||
dataProviderInfos: DataProviderInfo[];
|
||||
values: GetValueObject[];
|
||||
}> => {
|
||||
const values: GetValueObject[] = [];
|
||||
if (dateQuery.lt) {
|
||||
for (
|
||||
let date = resetHours(dateQuery.gte);
|
||||
@ -57,7 +61,7 @@ export const CurrentRateServiceMock = {
|
||||
date = addDays(date, 1)
|
||||
) {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
values.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
@ -70,7 +74,7 @@ export const CurrentRateServiceMock = {
|
||||
} else {
|
||||
for (const date of dateQuery.in) {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
values.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
@ -81,6 +85,6 @@ export const CurrentRateServiceMock = {
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve(result);
|
||||
return Promise.resolve({ values, dataProviderInfos: [] });
|
||||
}
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
@ -103,17 +104,23 @@ describe('CurrentRateService', () => {
|
||||
},
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject<GetValueObject[]>([
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1847.839966,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]);
|
||||
).toMatchObject<{
|
||||
dataProviderInfos: DataProviderInfo[];
|
||||
values: GetValueObject[];
|
||||
}>({
|
||||
dataProviderInfos: [],
|
||||
values: [
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1847.839966,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
@ -22,7 +23,11 @@ export class CurrentRateService {
|
||||
dataGatheringItems,
|
||||
dateQuery,
|
||||
userCurrency
|
||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||
}: GetValuesParams): Promise<{
|
||||
dataProviderInfos: DataProviderInfo[];
|
||||
values: GetValueObject[];
|
||||
}> {
|
||||
const dataProviderInfos: DataProviderInfo[] = [];
|
||||
const includeToday =
|
||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||
@ -38,6 +43,14 @@ export class CurrentRateService {
|
||||
.then((dataResultProvider) => {
|
||||
const result: GetValueObject[] = [];
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
if (
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||
) {
|
||||
dataProviderInfos.push(
|
||||
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
|
||||
);
|
||||
}
|
||||
|
||||
result.push({
|
||||
date: today,
|
||||
marketPriceInBaseCurrency:
|
||||
@ -81,7 +94,10 @@ export class CurrentRateService {
|
||||
})
|
||||
);
|
||||
|
||||
return flatten(await Promise.all(promises));
|
||||
return {
|
||||
dataProviderInfos,
|
||||
values: flatten(await Promise.all(promises))
|
||||
};
|
||||
}
|
||||
|
||||
private containsToday(dates: Date[]): boolean {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
DataProviderInfo,
|
||||
EnhancedSymbolProfile,
|
||||
HistoricalDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -7,6 +8,7 @@ import { Tag } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
dataProviderInfo: DataProviderInfo;
|
||||
dividendInBaseCurrency: number;
|
||||
feeInBaseCurrency: number;
|
||||
firstBuyDate: string;
|
||||
|
@ -86,7 +86,7 @@ describe('PortfolioCalculator', () => {
|
||||
netPerformanceInPercentage: 13.100263852242744,
|
||||
netPerformance: 19.86,
|
||||
totalInvestment: 0,
|
||||
value: 19.86
|
||||
value: 0
|
||||
});
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
ResponseError,
|
||||
TimelinePosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { GroupBy } from '@ghostfolio/common/types';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
@ -45,6 +49,7 @@ export class PortfolioCalculator {
|
||||
|
||||
private currency: string;
|
||||
private currentRateService: CurrentRateService;
|
||||
private dataProviderInfos: DataProviderInfo[];
|
||||
private orders: PortfolioOrder[];
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
@ -177,10 +182,10 @@ export class PortfolioCalculator {
|
||||
return isBefore(parseDate(transactionPoint.date), end);
|
||||
}) ?? [];
|
||||
|
||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
const dates: Date[] = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||
|
||||
let day = start;
|
||||
|
||||
@ -202,14 +207,17 @@ export class PortfolioCalculator {
|
||||
symbols[item.symbol] = true;
|
||||
}
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
const { dataProviderInfos, values: marketSymbols } =
|
||||
await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
this.dataProviderInfos = dataProviderInfos;
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
@ -227,87 +235,100 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
const netPerformanceValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
const valuesByDate: {
|
||||
[date: string]: {
|
||||
maxTotalInvestmentValue: Big;
|
||||
totalCurrentValue: Big;
|
||||
totalInvestmentValue: Big;
|
||||
totalNetPerformanceValue: Big;
|
||||
};
|
||||
} = {};
|
||||
|
||||
const investmentValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
const valuesBySymbol: {
|
||||
[symbol: string]: {
|
||||
currentValues: { [date: string]: Big };
|
||||
investmentValues: { [date: string]: Big };
|
||||
maxInvestmentValues: { [date: string]: Big };
|
||||
netPerformanceValues: { [date: string]: Big };
|
||||
};
|
||||
} = {};
|
||||
|
||||
const maxInvestmentValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
} = {};
|
||||
|
||||
const totalNetPerformanceValues: { [date: string]: Big } = {};
|
||||
const totalInvestmentValues: { [date: string]: Big } = {};
|
||||
const maxTotalInvestmentValues: { [date: string]: Big } = {};
|
||||
|
||||
for (const symbol of Object.keys(symbols)) {
|
||||
const { investmentValues, maxInvestmentValues, netPerformanceValues } =
|
||||
this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step,
|
||||
symbol,
|
||||
isChartMode: true
|
||||
});
|
||||
const {
|
||||
currentValues,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformanceValues
|
||||
} = this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step,
|
||||
symbol,
|
||||
isChartMode: true
|
||||
});
|
||||
|
||||
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
||||
investmentValuesBySymbol[symbol] = investmentValues;
|
||||
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
|
||||
valuesBySymbol[symbol] = {
|
||||
currentValues,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformanceValues
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
for (const symbol of Object.keys(valuesBySymbol)) {
|
||||
const symbolValues = valuesBySymbol[symbol];
|
||||
|
||||
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
|
||||
dateString
|
||||
].add(netPerformanceValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
const currentValue =
|
||||
symbolValues.currentValues?.[dateString] ?? new Big(0);
|
||||
const investmentValue =
|
||||
symbolValues.investmentValues?.[dateString] ?? new Big(0);
|
||||
const maxInvestmentValue =
|
||||
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
|
||||
const netPerformanceValue =
|
||||
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
||||
|
||||
totalInvestmentValues[dateString] =
|
||||
totalInvestmentValues[dateString] ?? new Big(0);
|
||||
|
||||
maxTotalInvestmentValues[dateString] =
|
||||
maxTotalInvestmentValues[dateString] ?? new Big(0);
|
||||
|
||||
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalInvestmentValues[dateString] = totalInvestmentValues[
|
||||
dateString
|
||||
].add(investmentValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
|
||||
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
|
||||
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
|
||||
dateString
|
||||
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
valuesByDate[dateString] = {
|
||||
totalCurrentValue: (
|
||||
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||
).add(currentValue),
|
||||
totalInvestmentValue: (
|
||||
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
|
||||
).add(investmentValue),
|
||||
maxTotalInvestmentValue: (
|
||||
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
|
||||
).add(maxInvestmentValue),
|
||||
totalNetPerformanceValue: (
|
||||
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
|
||||
).add(netPerformanceValue)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
||||
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
|
||||
return Object.entries(valuesByDate).map(([date, values]) => {
|
||||
const {
|
||||
maxTotalInvestmentValue,
|
||||
totalCurrentValue,
|
||||
totalInvestmentValue,
|
||||
totalNetPerformanceValue
|
||||
} = values;
|
||||
|
||||
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
|
||||
? 0
|
||||
: totalNetPerformanceValues[date]
|
||||
.div(maxTotalInvestmentValues[date])
|
||||
: totalNetPerformanceValue
|
||||
.div(maxTotalInvestmentValue)
|
||||
.mul(100)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
||||
totalInvestment: totalInvestmentValues[date].toNumber(),
|
||||
value: totalInvestmentValues[date]
|
||||
.plus(totalNetPerformanceValues[date])
|
||||
.toNumber()
|
||||
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||
totalInvestment: totalInvestmentValue.toNumber(),
|
||||
value: totalCurrentValue.toNumber()
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -368,14 +389,17 @@ export class PortfolioCalculator {
|
||||
|
||||
dates.push(resetHours(end));
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
const { dataProviderInfos, values: marketSymbols } =
|
||||
await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
this.dataProviderInfos = dataProviderInfos;
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
@ -463,6 +487,10 @@ export class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
public getDataProviderInfos() {
|
||||
return this.dataProviderInfos;
|
||||
}
|
||||
|
||||
public getInvestments(): { date: string; investment: Big }[] {
|
||||
if (this.transactionPoints.length === 0) {
|
||||
return [];
|
||||
@ -694,7 +722,7 @@ export class PortfolioCalculator {
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.warn(
|
||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
||||
`Missing historical market data for symbol ${currentPosition.symbol}`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
hasErrors = true;
|
||||
@ -748,7 +776,7 @@ export class PortfolioCalculator {
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (dataGatheringItems.length > 0) {
|
||||
try {
|
||||
marketSymbols = await this.currentRateService.getValues({
|
||||
const { values } = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
@ -757,6 +785,7 @@ export class PortfolioCalculator {
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
marketSymbols = values;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Failed to fetch info for date ${startDate} with exception`,
|
||||
@ -890,12 +919,16 @@ export class PortfolioCalculator {
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
currentValues: {},
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
hasErrors: false,
|
||||
initialValue: new Big(0),
|
||||
investmentValues: {},
|
||||
maxInvestmentValues: {},
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
netPerformanceValues: {}
|
||||
};
|
||||
}
|
||||
|
||||
@ -930,6 +963,7 @@ export class PortfolioCalculator {
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
const currentValues: { [date: string]: Big } = {};
|
||||
const investmentValues: { [date: string]: Big } = {};
|
||||
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
@ -1148,6 +1182,7 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
if (isChartMode && i > indexOfStartOrder) {
|
||||
currentValues[order.date] = valueOfInvestment;
|
||||
netPerformanceValues[order.date] = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
@ -1245,15 +1280,16 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
return {
|
||||
initialValue,
|
||||
currentValues,
|
||||
grossPerformancePercentage,
|
||||
initialValue,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformancePercentage,
|
||||
netPerformanceValues,
|
||||
grossPerformance: totalGrossPerformance,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance,
|
||||
grossPerformance: totalGrossPerformance
|
||||
netPerformance: totalNetPerformance
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
|
||||
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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
@ -65,7 +66,7 @@ export class PortfolioController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getDetails(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@ -189,7 +190,7 @@ export class PortfolioController {
|
||||
@Get('dividends')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getDividends(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@ -239,7 +240,7 @@ export class PortfolioController {
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@ -291,7 +292,7 @@ export class PortfolioController {
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@ -360,7 +361,7 @@ export class PortfolioController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@ -451,7 +452,7 @@ export class PortfolioController {
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
@ -474,7 +475,7 @@ export class PortfolioController {
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId: string
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||
): Promise<PortfolioReport> {
|
||||
const report = await this.portfolioService.getReport(impersonationId);
|
||||
|
||||
|
@ -37,8 +37,7 @@ import {
|
||||
PortfolioSummary,
|
||||
Position,
|
||||
TimelinePosition,
|
||||
UserSettings,
|
||||
UserWithSettings
|
||||
UserSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
@ -47,7 +46,8 @@ import type {
|
||||
GroupBy,
|
||||
Market,
|
||||
OrderWithAccount,
|
||||
RequestWithUser
|
||||
RequestWithUser,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
@ -678,6 +678,7 @@ export class PortfolioService {
|
||||
return {
|
||||
tags,
|
||||
averagePrice: undefined,
|
||||
dataProviderInfo: undefined,
|
||||
dividendInBaseCurrency: undefined,
|
||||
feeInBaseCurrency: undefined,
|
||||
firstBuyDate: undefined,
|
||||
@ -849,6 +850,7 @@ export class PortfolioService {
|
||||
tags,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
|
||||
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee.toNumber(),
|
||||
@ -911,6 +913,7 @@ export class PortfolioService {
|
||||
SymbolProfile,
|
||||
tags,
|
||||
averagePrice: 0,
|
||||
dataProviderInfo: undefined,
|
||||
dividendInBaseCurrency: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
firstBuyDate: undefined,
|
||||
@ -1550,7 +1553,10 @@ export class PortfolioService {
|
||||
userCurrency
|
||||
}).toNumber();
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
Math.max(
|
||||
emergencyFundPositionsValueInBaseCurrency,
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
)
|
||||
);
|
||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||
const firstOrderDate = activities[0]?.date;
|
||||
|
@ -117,7 +117,7 @@ export class SubscriptionController {
|
||||
return await this.subscriptionService.createCheckoutSession({
|
||||
couponId,
|
||||
priceId,
|
||||
userId: this.request.user.id
|
||||
user: this.request.user
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error, 'SubscriptionController');
|
||||
|
@ -5,7 +5,8 @@ import {
|
||||
PROPERTY_STRIPE_CONFIG
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Subscription } from '@prisma/client';
|
||||
import { addMilliseconds, isBefore } from 'date-fns';
|
||||
@ -23,7 +24,7 @@ export class SubscriptionService {
|
||||
this.stripe = new Stripe(
|
||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||
{
|
||||
apiVersion: '2020-08-27'
|
||||
apiVersion: '2022-11-15'
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -31,17 +32,17 @@ export class SubscriptionService {
|
||||
public async createCheckoutSession({
|
||||
couponId,
|
||||
priceId,
|
||||
userId
|
||||
user
|
||||
}: {
|
||||
couponId?: string;
|
||||
priceId: string;
|
||||
userId: string;
|
||||
user: UserWithSettings;
|
||||
}) {
|
||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||
cancel_url: `${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/account`,
|
||||
client_reference_id: userId,
|
||||
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
|
||||
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
|
||||
}/account`,
|
||||
client_reference_id: user.id,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
@ -116,17 +117,15 @@ export class SubscriptionService {
|
||||
userId: session.client_reference_id
|
||||
});
|
||||
|
||||
await this.stripe.customers.update(session.customer as string, {
|
||||
description: session.client_reference_id
|
||||
});
|
||||
|
||||
return session.client_reference_id;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'SubscriptionService');
|
||||
}
|
||||
}
|
||||
|
||||
public getSubscription(aSubscriptions: Subscription[]) {
|
||||
public getSubscription(
|
||||
aSubscriptions: Subscription[]
|
||||
): UserWithSettings['subscription'] {
|
||||
if (aSubscriptions.length > 0) {
|
||||
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||
@ -134,12 +133,14 @@ export class SubscriptionService {
|
||||
|
||||
return {
|
||||
expiresAt: latestSubscription.expiresAt,
|
||||
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
|
||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||
? SubscriptionType.Premium
|
||||
: SubscriptionType.Basic
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
offer: 'default',
|
||||
type: SubscriptionType.Basic
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
|
||||
export interface LookupItem {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
name: string;
|
||||
|
@ -1,15 +1,18 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
@ -21,7 +24,10 @@ import { SymbolService } from './symbol.service';
|
||||
|
||||
@Controller('symbol')
|
||||
export class SymbolController {
|
||||
public constructor(private readonly symbolService: SymbolService) {}
|
||||
public constructor(
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly symbolService: SymbolService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Must be before /:symbol
|
||||
@ -33,7 +39,10 @@ export class SymbolController {
|
||||
@Query() { query = '' }
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
try {
|
||||
return this.symbolService.lookup(query.toLowerCase());
|
||||
return this.symbolService.lookup({
|
||||
query: query.toLowerCase(),
|
||||
user: this.request.user
|
||||
});
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
@ -79,15 +80,24 @@ export class SymbolService {
|
||||
};
|
||||
}
|
||||
|
||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
public async lookup({
|
||||
query,
|
||||
user
|
||||
}: {
|
||||
query: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const results: { items: LookupItem[] } = { items: [] };
|
||||
|
||||
if (!aQuery) {
|
||||
if (!query) {
|
||||
return results;
|
||||
}
|
||||
|
||||
try {
|
||||
const { items } = await this.dataProviderService.search(aQuery);
|
||||
const { items } = await this.dataProviderService.search({
|
||||
query,
|
||||
user
|
||||
});
|
||||
results.items = items;
|
||||
return results;
|
||||
} catch (error) {
|
||||
|
@ -1,7 +0,0 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
country?: string;
|
||||
}
|
@ -5,6 +5,7 @@ import type {
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO8601,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -12,6 +13,10 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingDto {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
annualInterestRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
baseCurrency?: string;
|
||||
@ -48,6 +53,14 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
locale?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
projectedTotalAmount?: number;
|
||||
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
retirementDate?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
savingsRate?: number;
|
||||
|
@ -22,7 +22,6 @@ import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { size } from 'lodash';
|
||||
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||
import { UserService } from './user.service';
|
||||
@ -66,7 +65,7 @@ export class UserController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
public async signupUser(@Body() data: CreateUserDto): Promise<UserItem> {
|
||||
public async signupUser(): Promise<UserItem> {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
@ -80,7 +79,6 @@ export class UserController {
|
||||
const hasAdmin = await this.userService.hasAdmin();
|
||||
|
||||
const { accessToken, id, role } = await this.userService.createUser({
|
||||
country: data.country,
|
||||
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
|
||||
});
|
||||
|
||||
|
@ -4,22 +4,17 @@ 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,
|
||||
UserSettings,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasRole,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Role, User } from '@prisma/client';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@Injectable()
|
||||
@ -234,9 +229,10 @@ export class UserService {
|
||||
}
|
||||
|
||||
public async createUser({
|
||||
country,
|
||||
data
|
||||
}: CreateUserDto & { data: Prisma.UserCreateInput }): Promise<User> {
|
||||
}: {
|
||||
data: Prisma.UserCreateInput;
|
||||
}): Promise<User> {
|
||||
if (!data?.provider) {
|
||||
data.provider = 'ANONYMOUS';
|
||||
}
|
||||
@ -264,7 +260,6 @@ export class UserService {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
await this.prismaService.analytics.create({
|
||||
data: {
|
||||
country,
|
||||
User: { connect: { id: user.id } }
|
||||
}
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import Big from 'big.js';
|
||||
import { cloneDeep, isArray, isObject } from 'lodash';
|
||||
|
||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||
@ -59,7 +60,10 @@ export function redactAttributes({
|
||||
return redactAttributes({ options, object: currentObject });
|
||||
}
|
||||
);
|
||||
} else if (isObject(redactedObject[property])) {
|
||||
} else if (
|
||||
isObject(redactedObject[property]) &&
|
||||
!(redactedObject[property] instanceof Big)
|
||||
) {
|
||||
// Recursively call the function on the nested object
|
||||
redactedObject[property] = redactAttributes({
|
||||
options,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
@ -22,7 +23,8 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
return next.handle().pipe(
|
||||
map((data: any) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
||||
const hasImpersonationId =
|
||||
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
|
||||
|
||||
if (
|
||||
hasImpersonationId ||
|
||||
|
@ -24,7 +24,7 @@ export class TransformDataSourceInRequestInterceptor<T>
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest();
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (request.body.dataSource) {
|
||||
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
||||
}
|
||||
|
@ -26,9 +26,7 @@ export class TransformDataSourceInResponseInterceptor<T>
|
||||
): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
map((data: any) => {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
data = redactAttributes({
|
||||
options: [
|
||||
{
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import * as bodyParser from 'body-parser';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
@ -9,15 +10,10 @@ async function bootstrap() {
|
||||
const configApp = await NestFactory.create(AppModule);
|
||||
const configService = configApp.get<ConfigService>(ConfigService);
|
||||
|
||||
const NODE_ENV =
|
||||
configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||
'development';
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger:
|
||||
NODE_ENV === 'production'
|
||||
? ['error', 'log', 'warn']
|
||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||
logger: environment.production
|
||||
? ['error', 'log', 'warn']
|
||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||
});
|
||||
app.enableCors();
|
||||
app.enableVersioning({
|
||||
@ -33,6 +29,9 @@ async function bootstrap() {
|
||||
})
|
||||
);
|
||||
|
||||
// Support 10mb csv/json files for importing activities
|
||||
app.use(bodyParser.json({ limit: '10mb' }));
|
||||
|
||||
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||
const PORT = configService.get<number>('PORT') || 3333;
|
||||
await app.listen(PORT, HOST, () => {
|
||||
|
@ -19,10 +19,9 @@ export class ConfigurationService {
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCES: json({
|
||||
default: [DataSource.MANUAL, DataSource.YAHOO]
|
||||
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
|
||||
}),
|
||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||
|
@ -19,8 +19,8 @@ export class CronService {
|
||||
private readonly twitterBotService: TwitterBotService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_4_HOURS)
|
||||
public async runEveryFourHours() {
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
public async runEveryHour() {
|
||||
await this.dataGatheringService.gather7Days();
|
||||
}
|
||||
|
||||
@ -38,15 +38,20 @@ export class CronService {
|
||||
public async runEverySundayAtTwelvePm() {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: `${dataSource}-${symbol}`
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2,8 +2,7 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.se
|
||||
import {
|
||||
DATA_GATHERING_QUEUE,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||
QUEUE_JOB_STATUS_LIST
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
@ -11,7 +10,8 @@ import { InjectQueue } from '@nestjs/bull';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { JobOptions, Queue } from 'bull';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import { format, min, subDays, subYears } from 'date-fns';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||
@ -34,17 +34,22 @@ export class DataGatheringService {
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async addJobToQueue(name: string, data: any, options?: JobOptions) {
|
||||
const hasJob = await this.hasJob(name, data);
|
||||
public async addJobToQueue({
|
||||
data,
|
||||
name,
|
||||
opts
|
||||
}: {
|
||||
data: any;
|
||||
name: string;
|
||||
opts?: JobOptions;
|
||||
}) {
|
||||
return this.dataGatheringQueue.add(name, data, opts);
|
||||
}
|
||||
|
||||
if (hasJob) {
|
||||
Logger.log(
|
||||
`Job ${name} with data ${JSON.stringify(data)} already exists.`,
|
||||
'DataGatheringService'
|
||||
);
|
||||
} else {
|
||||
return this.dataGatheringQueue.add(name, data, options);
|
||||
}
|
||||
public async addJobsToQueue(
|
||||
jobs: { data: any; name: string; opts?: JobOptions }[]
|
||||
) {
|
||||
return this.dataGatheringQueue.addBulk(jobs);
|
||||
}
|
||||
|
||||
public async gather7Days() {
|
||||
@ -152,10 +157,11 @@ export class DataGatheringService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
url
|
||||
} = assetProfiles[symbol];
|
||||
} = assetProfile;
|
||||
|
||||
try {
|
||||
await this.prismaService.symbolProfile.upsert({
|
||||
@ -165,6 +171,7 @@ export class DataGatheringService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
symbol,
|
||||
@ -175,6 +182,7 @@ export class DataGatheringService {
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
url
|
||||
@ -206,68 +214,22 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||
if (dataSource === 'MANUAL') {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.addJobToQueue(
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
},
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
const startDate =
|
||||
(
|
||||
await this.prismaService.order.findFirst({
|
||||
orderBy: [{ date: 'asc' }]
|
||||
})
|
||||
)?.date ?? new Date();
|
||||
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
await this.addJobsToQueue(
|
||||
aSymbolsWithStartDate.map(({ dataSource, date, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
const symbolProfilesToGather = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
data: {
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
dataSource: {
|
||||
not: 'MANUAL'
|
||||
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
opts: {
|
||||
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||
jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}`
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: symbolProfile.Order?.[0]?.date ?? startDate
|
||||
};
|
||||
});
|
||||
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
);
|
||||
}
|
||||
|
||||
public async getUniqueAssets(): Promise<UniqueAsset[]> {
|
||||
@ -299,17 +261,12 @@ export class DataGatheringService {
|
||||
dataSource: true,
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
dataSource: {
|
||||
not: 'MANUAL'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only consider symbols with incomplete market data for the last
|
||||
// 7 days
|
||||
const symbolsNotToGather = (
|
||||
const symbolsWithCompleteMarketData = (
|
||||
await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['symbol'],
|
||||
@ -327,8 +284,14 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
const symbolProfilesToGather = symbolProfiles
|
||||
.filter(({ symbol }) => {
|
||||
return !symbolsNotToGather.includes(symbol);
|
||||
.filter(({ dataSource, scraperConfiguration, symbol }) => {
|
||||
const manualDataSourceWithScraperConfiguration =
|
||||
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
|
||||
|
||||
return (
|
||||
!symbolsWithCompleteMarketData.includes(symbol) &&
|
||||
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
|
||||
);
|
||||
})
|
||||
.map((symbolProfile) => {
|
||||
return {
|
||||
@ -340,7 +303,7 @@ export class DataGatheringService {
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ symbol }) => {
|
||||
return !symbolsNotToGather.includes(symbol);
|
||||
return !symbolsWithCompleteMarketData.includes(symbol);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
@ -353,17 +316,56 @@ export class DataGatheringService {
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
private async hasJob(name: string, data: any) {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(
|
||||
QUEUE_JOB_STATUS_LIST.filter((status) => {
|
||||
return status !== 'completed';
|
||||
})
|
||||
);
|
||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
const startDate =
|
||||
(
|
||||
await this.prismaService.order.findFirst({
|
||||
orderBy: [{ date: 'asc' }]
|
||||
})
|
||||
)?.date ?? new Date();
|
||||
|
||||
return jobs.some((job) => {
|
||||
return (
|
||||
job.name === name && JSON.stringify(job.data) === JSON.stringify(data)
|
||||
);
|
||||
});
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: min([startDate, subYears(new Date(), 10)])
|
||||
};
|
||||
});
|
||||
|
||||
const symbolProfilesToGather = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
)
|
||||
.filter((symbolProfile) => {
|
||||
const manualDataSourceWithScraperConfiguration =
|
||||
symbolProfile.dataSource === 'MANUAL' &&
|
||||
!isEmpty(symbolProfile.scraperConfiguration);
|
||||
|
||||
return (
|
||||
symbolProfile.dataSource !== 'MANUAL' ||
|
||||
manualDataSourceWithScraperConfiguration
|
||||
);
|
||||
})
|
||||
.map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: symbolProfile.Order?.[0]?.date ?? startDate
|
||||
};
|
||||
});
|
||||
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
}
|
||||
|
@ -33,7 +33,8 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
dataSource: this.getName()
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,198 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import { format, fromUnixTime, getUnixTime } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class CoinGeckoService implements DataProviderInterface {
|
||||
private baseCurrency: string;
|
||||
private readonly URL = 'https://api.coingecko.com/api/v3';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
const response: Partial<SymbolProfile> = {
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||
currency: this.baseCurrency,
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
|
||||
try {
|
||||
const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200);
|
||||
const { name } = await get();
|
||||
|
||||
response.name = name;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'CoinGeckoService');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
try {
|
||||
const get = bent(
|
||||
`${
|
||||
this.URL
|
||||
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime(
|
||||
from
|
||||
)}&to=${getUnixTime(to)}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const { prices } = await get();
|
||||
|
||||
const result: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {
|
||||
[aSymbol]: {}
|
||||
};
|
||||
|
||||
for (const [timestamp, marketPrice] of prices) {
|
||||
result[aSymbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = {
|
||||
marketPrice
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||
from,
|
||||
DATE_FORMAT
|
||||
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public getMaxNumberOfSymbolsPerRequest() {
|
||||
return 50;
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
return DataSource.COINGECKO;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const results: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/simple/price?ids=${aSymbols.join(
|
||||
','
|
||||
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const response = await get();
|
||||
|
||||
for (const symbol in response) {
|
||||
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
|
||||
results[symbol] = {
|
||||
currency: this.baseCurrency,
|
||||
dataProviderInfo: this.getDataProviderInfo(),
|
||||
dataSource: DataSource.COINGECKO,
|
||||
marketPrice: response[symbol][this.baseCurrency.toLowerCase()],
|
||||
marketState: 'open'
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'CoinGeckoService');
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
let items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/search?query=${aQuery}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const { coins } = await get();
|
||||
|
||||
items = coins.map(({ id: symbol, name }) => {
|
||||
return {
|
||||
name,
|
||||
symbol,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||
currency: this.baseCurrency,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error, 'CoinGeckoService');
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
private getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
name: 'CoinGecko',
|
||||
url: 'https://coingecko.com'
|
||||
};
|
||||
}
|
||||
}
|
@ -1,15 +1,27 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
||||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
exports: ['DataEnhancers', TrackinsightDataEnhancerService],
|
||||
exports: [
|
||||
'DataEnhancers',
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceDataEnhancerService
|
||||
],
|
||||
imports: [ConfigurationModule, CryptocurrencyModule],
|
||||
providers: [
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceDataEnhancerService,
|
||||
{
|
||||
inject: [TrackinsightDataEnhancerService],
|
||||
inject: [
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceDataEnhancerService
|
||||
],
|
||||
provide: 'DataEnhancers',
|
||||
useFactory: (trackinsight) => [trackinsight]
|
||||
},
|
||||
TrackinsightDataEnhancerService
|
||||
useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance]
|
||||
}
|
||||
]
|
||||
})
|
||||
export class DataEnhancerModule {}
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
|
||||
const getJSON = bent('json');
|
||||
|
||||
@Injectable()
|
||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
||||
private static baseUrl = 'https://data.trackinsight.com';
|
||||
private static countries = require('countries-list/dist/countries.json');
|
||||
private static countriesMapping = {
|
||||
'Russian Federation': 'Russia'
|
||||
@ -32,17 +34,29 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
return response;
|
||||
}
|
||||
|
||||
const result = await getJSON(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
|
||||
const profile = await getJSON(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json`
|
||||
).catch(() => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const isin = profile.isin?.split(';')?.[0];
|
||||
|
||||
if (isin) {
|
||||
response.isin = isin;
|
||||
}
|
||||
|
||||
const holdings = await getJSON(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
|
||||
).catch(() => {
|
||||
return getJSON(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/${
|
||||
symbol.split('.')[0]
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
|
||||
symbol.split('.')?.[0]
|
||||
}.json`
|
||||
);
|
||||
});
|
||||
|
||||
if (result.weight < 0.95) {
|
||||
if (holdings?.weight < 0.95) {
|
||||
// Skip if data is inaccurate
|
||||
return response;
|
||||
}
|
||||
@ -52,7 +66,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
(response.countries as unknown as Country[]).length === 0
|
||||
) {
|
||||
response.countries = [];
|
||||
for (const [name, value] of Object.entries<any>(result.countries)) {
|
||||
for (const [name, value] of Object.entries<any>(
|
||||
holdings?.countries ?? {}
|
||||
)) {
|
||||
let countryCode: string;
|
||||
|
||||
for (const [key, country] of Object.entries<any>(
|
||||
@ -80,7 +96,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
(response.sectors as unknown as Sector[]).length === 0
|
||||
) {
|
||||
response.sectors = [];
|
||||
for (const [name, value] of Object.entries<any>(result.sectors)) {
|
||||
for (const [name, value] of Object.entries<any>(
|
||||
holdings?.sectors ?? {}
|
||||
)) {
|
||||
response.sectors.push({
|
||||
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
||||
weight: value.weight
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
|
||||
import { YahooFinanceService } from './yahoo-finance.service';
|
||||
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/cryptocurrency/cryptocurrency.service',
|
||||
@ -25,16 +25,16 @@ jest.mock(
|
||||
}
|
||||
);
|
||||
|
||||
describe('YahooFinanceService', () => {
|
||||
describe('YahooFinanceDataEnhancerService', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let cryptocurrencyService: CryptocurrencyService;
|
||||
let yahooFinanceService: YahooFinanceService;
|
||||
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
|
||||
|
||||
beforeAll(async () => {
|
||||
configurationService = new ConfigurationService();
|
||||
cryptocurrencyService = new CryptocurrencyService();
|
||||
|
||||
yahooFinanceService = new YahooFinanceService(
|
||||
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
|
||||
configurationService,
|
||||
cryptocurrencyService
|
||||
);
|
||||
@ -42,25 +42,37 @@ describe('YahooFinanceService', () => {
|
||||
|
||||
it('convertFromYahooFinanceSymbol', async () => {
|
||||
expect(
|
||||
await yahooFinanceService.convertFromYahooFinanceSymbol('BRK-B')
|
||||
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
|
||||
'BRK-B'
|
||||
)
|
||||
).toEqual('BRK-B');
|
||||
expect(
|
||||
await yahooFinanceService.convertFromYahooFinanceSymbol('BTC-USD')
|
||||
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
|
||||
'BTC-USD'
|
||||
)
|
||||
).toEqual('BTCUSD');
|
||||
expect(
|
||||
await yahooFinanceService.convertFromYahooFinanceSymbol('EURUSD=X')
|
||||
await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
|
||||
'EURUSD=X'
|
||||
)
|
||||
).toEqual('EURUSD');
|
||||
});
|
||||
|
||||
it('convertToYahooFinanceSymbol', async () => {
|
||||
expect(
|
||||
await yahooFinanceService.convertToYahooFinanceSymbol('BTCUSD')
|
||||
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
|
||||
'BTCUSD'
|
||||
)
|
||||
).toEqual('BTC-USD');
|
||||
expect(
|
||||
await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD')
|
||||
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
|
||||
'DOGEUSD'
|
||||
)
|
||||
).toEqual('DOGE-USD');
|
||||
expect(
|
||||
await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF')
|
||||
await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
|
||||
'USDCHF'
|
||||
)
|
||||
).toEqual('USDCHF=X');
|
||||
});
|
||||
});
|
@ -0,0 +1,325 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { countries } from 'countries-list';
|
||||
import yahooFinance from 'yahoo-finance2';
|
||||
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
||||
|
||||
@Injectable()
|
||||
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly cryptocurrencyService: CryptocurrencyService
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||
let symbol = aYahooFinanceSymbol.replace(
|
||||
new RegExp(`-${this.baseCurrency}$`),
|
||||
this.baseCurrency
|
||||
);
|
||||
|
||||
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
|
||||
symbol = `${this.baseCurrency}${symbol}`;
|
||||
}
|
||||
|
||||
return symbol.replace('=X', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a symbol to a Yahoo Finance symbol
|
||||
*
|
||||
* Currency: USDCHF -> USDCHF=X
|
||||
* Cryptocurrency: BTCUSD -> BTC-USD
|
||||
* DOGEUSD -> DOGE-USD
|
||||
*/
|
||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||
if (
|
||||
aSymbol.includes(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(
|
||||
aSymbol.replace(
|
||||
new RegExp(`-${this.baseCurrency}$`),
|
||||
this.baseCurrency
|
||||
)
|
||||
)
|
||||
) {
|
||||
// Add a dash before the last three characters
|
||||
// BTCUSD -> BTC-USD
|
||||
// DOGEUSD -> DOGE-USD
|
||||
// SOL1USD -> SOL1-USD
|
||||
return aSymbol.replace(
|
||||
new RegExp(`-?${this.baseCurrency}$`),
|
||||
`-${this.baseCurrency}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return aSymbol;
|
||||
}
|
||||
|
||||
public async enhance({
|
||||
response,
|
||||
symbol
|
||||
}: {
|
||||
response: Partial<SymbolProfile>;
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
if (response.dataSource !== 'YAHOO' && !response.isin) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
let yahooSymbol: string;
|
||||
|
||||
if (response.dataSource === 'YAHOO') {
|
||||
yahooSymbol = symbol;
|
||||
} else {
|
||||
const { quotes } = await yahooFinance.search(response.isin);
|
||||
yahooSymbol = quotes[0].symbol;
|
||||
}
|
||||
|
||||
const { countries, sectors, url } = await this.getAssetProfile(
|
||||
yahooSymbol
|
||||
);
|
||||
|
||||
if (countries) {
|
||||
response.countries = countries;
|
||||
}
|
||||
|
||||
if (sectors) {
|
||||
response.sectors = sectors;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
response.url = url;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'YahooFinanceDataEnhancerService');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public formatName({
|
||||
longName,
|
||||
quoteType,
|
||||
shortName,
|
||||
symbol
|
||||
}: {
|
||||
longName: Price['longName'];
|
||||
quoteType: Price['quoteType'];
|
||||
shortName: Price['shortName'];
|
||||
symbol: Price['symbol'];
|
||||
}) {
|
||||
let name = longName;
|
||||
|
||||
if (name) {
|
||||
name = name.replace('Amundi Index Solutions - ', '');
|
||||
name = name.replace('iShares ETF (CH) - ', '');
|
||||
name = name.replace('iShares III Public Limited Company - ', '');
|
||||
name = name.replace('iShares V PLC - ', '');
|
||||
name = name.replace('iShares VI Public Limited Company - ', '');
|
||||
name = name.replace('iShares VII PLC - ', '');
|
||||
name = name.replace('Multi Units Luxembourg - ', '');
|
||||
name = name.replace('VanEck ETFs N.V. - ', '');
|
||||
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
|
||||
name = name.replace('Vanguard Funds Public Limited Company - ', '');
|
||||
name = name.replace('Vanguard Index Funds - ', '');
|
||||
name = name.replace('Xtrackers (IE) Plc - ', '');
|
||||
}
|
||||
|
||||
if (quoteType === 'FUTURE') {
|
||||
// "Gold Jun 22" -> "Gold"
|
||||
name = shortName?.slice(0, -7);
|
||||
}
|
||||
|
||||
return name || shortName || symbol;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
const response: Partial<SymbolProfile> = {};
|
||||
|
||||
try {
|
||||
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
||||
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
||||
modules: ['price', 'summaryProfile', 'topHoldings']
|
||||
});
|
||||
|
||||
const { assetClass, assetSubClass } = this.parseAssetClass({
|
||||
quoteType: assetProfile.price.quoteType,
|
||||
shortName: assetProfile.price.shortName
|
||||
});
|
||||
|
||||
response.assetClass = assetClass;
|
||||
response.assetSubClass = assetSubClass;
|
||||
response.currency = assetProfile.price.currency;
|
||||
response.dataSource = this.getName();
|
||||
response.name = this.formatName({
|
||||
longName: assetProfile.price.longName,
|
||||
quoteType: assetProfile.price.quoteType,
|
||||
shortName: assetProfile.price.shortName,
|
||||
symbol: assetProfile.price.symbol
|
||||
});
|
||||
response.symbol = aSymbol;
|
||||
|
||||
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
|
||||
) {
|
||||
// Add country if asset is stock and country available
|
||||
|
||||
try {
|
||||
const [code] = Object.entries(countries).find(([, country]) => {
|
||||
return country.name === assetProfile.summaryProfile?.country;
|
||||
});
|
||||
|
||||
if (code) {
|
||||
response.countries = [{ code, weight: 1 }];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (assetProfile.summaryProfile?.sector) {
|
||||
response.sectors = [
|
||||
{ name: assetProfile.summaryProfile?.sector, weight: 1 }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const url = assetProfile.summaryProfile?.website;
|
||||
if (url) {
|
||||
response.url = url;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'YahooFinanceService');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return DataSource.YAHOO;
|
||||
}
|
||||
|
||||
public parseAssetClass({
|
||||
quoteType,
|
||||
shortName
|
||||
}: {
|
||||
quoteType: string;
|
||||
shortName: string;
|
||||
}): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
} {
|
||||
let assetClass: AssetClass;
|
||||
let assetSubClass: AssetSubClass;
|
||||
|
||||
switch (quoteType?.toLowerCase()) {
|
||||
case 'cryptocurrency':
|
||||
assetClass = AssetClass.CASH;
|
||||
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||
break;
|
||||
case 'equity':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
assetSubClass = AssetSubClass.STOCK;
|
||||
break;
|
||||
case 'etf':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
assetSubClass = AssetSubClass.ETF;
|
||||
break;
|
||||
case 'future':
|
||||
assetClass = AssetClass.COMMODITY;
|
||||
assetSubClass = AssetSubClass.COMMODITY;
|
||||
|
||||
if (
|
||||
shortName?.toLowerCase()?.startsWith('gold') ||
|
||||
shortName?.toLowerCase()?.startsWith('palladium') ||
|
||||
shortName?.toLowerCase()?.startsWith('platinum') ||
|
||||
shortName?.toLowerCase()?.startsWith('silver')
|
||||
) {
|
||||
assetSubClass = AssetSubClass.PRECIOUS_METAL;
|
||||
}
|
||||
|
||||
break;
|
||||
case 'mutualfund':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
assetSubClass = AssetSubClass.MUTUALFUND;
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
|
||||
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.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';
|
||||
@ -10,17 +11,21 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DataEnhancerModule } from './data-enhancer/data-enhancer.module';
|
||||
import { YahooFinanceDataEnhancerService } from './data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||
import { DataProviderService } from './data-provider.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
CryptocurrencyModule,
|
||||
DataEnhancerModule,
|
||||
PrismaModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CoinGeckoService,
|
||||
DataProviderService,
|
||||
EodHistoricalDataService,
|
||||
GoogleSheetsService,
|
||||
@ -30,6 +35,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
{
|
||||
inject: [
|
||||
AlphaVantageService,
|
||||
CoinGeckoService,
|
||||
EodHistoricalDataService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
@ -39,6 +45,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
provide: 'DataProviderInterfaces',
|
||||
useFactory: (
|
||||
alphaVantageService,
|
||||
coinGeckoService,
|
||||
eodHistoricalDataService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
@ -46,13 +53,15 @@ import { DataProviderService } from './data-provider.service';
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
coinGeckoService,
|
||||
eodHistoricalDataService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rapidApiService,
|
||||
yahooFinanceService
|
||||
]
|
||||
}
|
||||
},
|
||||
YahooFinanceDataEnhancerService
|
||||
],
|
||||
exports: [DataProviderService, YahooFinanceService]
|
||||
})
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||
@ -260,26 +261,51 @@ export class DataProviderService {
|
||||
return response;
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search({
|
||||
query,
|
||||
user
|
||||
}: {
|
||||
query: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||
let lookupItems: LookupItem[] = [];
|
||||
|
||||
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
||||
promises.push(
|
||||
this.getDataProvider(DataSource[dataSource]).search(aQuery)
|
||||
);
|
||||
if (query?.length < 2) {
|
||||
return { items: lookupItems };
|
||||
}
|
||||
|
||||
let dataSources = this.configurationService.get('DATA_SOURCES');
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
user.subscription.type === 'Basic'
|
||||
) {
|
||||
dataSources = dataSources.filter((dataSource) => {
|
||||
return !this.isPremiumDataSource(DataSource[dataSource]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const dataSource of dataSources) {
|
||||
promises.push(this.getDataProvider(DataSource[dataSource]).search(query));
|
||||
}
|
||||
|
||||
const searchResults = await Promise.all(promises);
|
||||
|
||||
searchResults.forEach((searchResult) => {
|
||||
lookupItems = lookupItems.concat(searchResult.items);
|
||||
searchResults.forEach(({ items }) => {
|
||||
if (items?.length > 0) {
|
||||
lookupItems = lookupItems.concat(items);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredItems = lookupItems.filter((lookupItem) => {
|
||||
// Only allow symbols with supported currency
|
||||
return lookupItem.currency ? true : false;
|
||||
});
|
||||
const filteredItems = lookupItems
|
||||
.filter((lookupItem) => {
|
||||
// Only allow symbols with supported currency
|
||||
return lookupItem.currency ? true : false;
|
||||
})
|
||||
.sort(({ name: name1 }, { name: name2 }) => {
|
||||
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
|
||||
});
|
||||
|
||||
return {
|
||||
items: filteredItems
|
||||
@ -295,4 +321,9 @@ export class DataProviderService {
|
||||
|
||||
throw new Error('No data provider has been found.');
|
||||
}
|
||||
|
||||
private isPremiumDataSource(aDataSource: DataSource) {
|
||||
const premiumDataSources: DataSource[] = [DataSource.EOD_HISTORICAL_DATA];
|
||||
return premiumDataSources.includes(aDataSource);
|
||||
}
|
||||
}
|
||||
|
@ -5,13 +5,17 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import { format } from 'date-fns';
|
||||
import { format, isToday } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class EodHistoricalDataService implements DataProviderInterface {
|
||||
@ -19,8 +23,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
private readonly URL = 'https://eodhistoricaldata.com/api';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
|
||||
}
|
||||
@ -32,8 +35,16 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
const [searchResult] = await this.getSearchResult(aSymbol);
|
||||
|
||||
return {
|
||||
dataSource: this.getName()
|
||||
assetClass: searchResult?.assetClass,
|
||||
assetSubClass: searchResult?.assetSubClass,
|
||||
currency: searchResult?.currency,
|
||||
dataSource: this.getName(),
|
||||
isin: searchResult?.isin,
|
||||
name: searchResult?.name,
|
||||
symbol: aSymbol
|
||||
};
|
||||
}
|
||||
|
||||
@ -122,32 +133,30 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
200
|
||||
);
|
||||
|
||||
const [response, symbolProfiles] = await Promise.all([
|
||||
const [realTimeResponse, searchResponse] = await Promise.all([
|
||||
get(),
|
||||
this.symbolProfileService.getSymbolProfiles(
|
||||
aSymbols.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
dataSource: DataSource.EOD_HISTORICAL_DATA
|
||||
};
|
||||
})
|
||||
)
|
||||
this.search(aSymbols[0])
|
||||
]);
|
||||
|
||||
const quotes = aSymbols.length === 1 ? [response] : response;
|
||||
const quotes =
|
||||
aSymbols.length === 1 ? [realTimeResponse] : realTimeResponse;
|
||||
|
||||
return quotes.reduce((result, item, index, array) => {
|
||||
result[item.code] = {
|
||||
currency: symbolProfiles.find((symbolProfile) => {
|
||||
return symbolProfile.symbol === item.code;
|
||||
})?.currency,
|
||||
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
||||
marketPrice: item.close,
|
||||
marketState: 'delayed'
|
||||
};
|
||||
return quotes.reduce(
|
||||
(
|
||||
result: { [symbol: string]: IDataProviderResponse },
|
||||
{ close, code, timestamp }
|
||||
) => {
|
||||
result[code] = {
|
||||
currency: searchResponse?.items[0]?.currency,
|
||||
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
||||
marketPrice: close,
|
||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||
};
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
return result;
|
||||
},
|
||||
{}
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'EodHistoricalDataService');
|
||||
}
|
||||
@ -156,6 +165,117 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
const searchResult = await this.getSearchResult(aQuery);
|
||||
|
||||
return {
|
||||
items: searchResult
|
||||
.filter(({ symbol }) => {
|
||||
return !symbol.toLowerCase().endsWith('forex');
|
||||
})
|
||||
.map(
|
||||
({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
symbol
|
||||
}) => {
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
symbol
|
||||
};
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
private async getSearchResult(aQuery: string): Promise<
|
||||
(LookupItem & {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
isin: string;
|
||||
})[]
|
||||
> {
|
||||
let searchResult = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const response = await get();
|
||||
|
||||
searchResult = response.map(
|
||||
({
|
||||
Code,
|
||||
Currency: currency,
|
||||
Exchange,
|
||||
ISIN: isin,
|
||||
Name: name,
|
||||
Type
|
||||
}) => {
|
||||
const { assetClass, assetSubClass } = this.parseAssetClass({
|
||||
Exchange,
|
||||
Type
|
||||
});
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency,
|
||||
isin,
|
||||
name,
|
||||
dataSource: this.getName(),
|
||||
symbol: `${Code}.${Exchange}`
|
||||
};
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'EodHistoricalDataService');
|
||||
}
|
||||
|
||||
return searchResult;
|
||||
}
|
||||
|
||||
private parseAssetClass({
|
||||
Exchange,
|
||||
Type
|
||||
}: {
|
||||
Exchange: string;
|
||||
Type: string;
|
||||
}): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
} {
|
||||
let assetClass: AssetClass;
|
||||
let assetSubClass: AssetSubClass;
|
||||
|
||||
switch (Type?.toLowerCase()) {
|
||||
case 'common stock':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
assetSubClass = AssetSubClass.STOCK;
|
||||
break;
|
||||
case 'currency':
|
||||
assetClass = AssetClass.CASH;
|
||||
|
||||
if (Exchange?.toLowerCase() === 'cc') {
|
||||
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||
}
|
||||
|
||||
break;
|
||||
case 'etf':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
assetSubClass = AssetSubClass.ETF;
|
||||
break;
|
||||
}
|
||||
|
||||
return { assetClass, assetSubClass };
|
||||
}
|
||||
}
|
||||
|
@ -30,7 +30,8 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
dataSource: this.getName()
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
}
|
||||
|
||||
@ -145,6 +146,8 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
|
@ -16,6 +16,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { addDays, format, isBefore } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
@ -33,7 +34,8 @@ export class ManualService implements DataProviderInterface {
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
dataSource: this.getName()
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
}
|
||||
|
||||
@ -162,8 +164,10 @@ export class ManualService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const items = await this.prismaService.symbolProfile.findMany({
|
||||
let items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
@ -189,6 +193,11 @@ export class ManualService implements DataProviderInterface {
|
||||
}
|
||||
});
|
||||
|
||||
items = items.filter(({ symbol }) => {
|
||||
// Remove UUID symbols (activities of type ITEM)
|
||||
return !isUUID(symbol);
|
||||
});
|
||||
|
||||
return { items };
|
||||
}
|
||||
}
|
||||
|
@ -27,7 +27,8 @@ export class RapidApiService implements DataProviderInterface {
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
dataSource: this.getName()
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,26 +1,19 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
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 { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { countries } from 'countries-list';
|
||||
import { addDays, format, isSameDay } from 'date-fns';
|
||||
import yahooFinance from 'yahoo-finance2';
|
||||
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
||||
|
||||
@Injectable()
|
||||
export class YahooFinanceService implements DataProviderInterface {
|
||||
@ -28,7 +21,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly cryptocurrencyService: CryptocurrencyService
|
||||
private readonly cryptocurrencyService: CryptocurrencyService,
|
||||
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
@ -37,127 +31,20 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||
let symbol = aYahooFinanceSymbol.replace(
|
||||
new RegExp(`-${this.baseCurrency}$`),
|
||||
this.baseCurrency
|
||||
);
|
||||
|
||||
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
|
||||
symbol = `${this.baseCurrency}${symbol}`;
|
||||
}
|
||||
|
||||
return symbol.replace('=X', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a symbol to a Yahoo Finance symbol
|
||||
*
|
||||
* Currency: USDCHF -> USDCHF=X
|
||||
* Cryptocurrency: BTCUSD -> BTC-USD
|
||||
* DOGEUSD -> DOGE-USD
|
||||
*/
|
||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||
if (
|
||||
aSymbol.includes(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(
|
||||
aSymbol.replace(
|
||||
new RegExp(`-${this.baseCurrency}$`),
|
||||
this.baseCurrency
|
||||
)
|
||||
)
|
||||
) {
|
||||
// Add a dash before the last three characters
|
||||
// BTCUSD -> BTC-USD
|
||||
// DOGEUSD -> DOGE-USD
|
||||
// SOL1USD -> SOL1-USD
|
||||
return aSymbol.replace(
|
||||
new RegExp(`-?${this.baseCurrency}$`),
|
||||
`-${this.baseCurrency}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return aSymbol;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
const response: Partial<SymbolProfile> = {};
|
||||
const { assetClass, assetSubClass, currency, name } =
|
||||
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
|
||||
|
||||
try {
|
||||
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
||||
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
||||
modules: ['price', 'summaryProfile', 'topHoldings']
|
||||
});
|
||||
|
||||
const { assetClass, assetSubClass } = this.parseAssetClass(
|
||||
assetProfile.price
|
||||
);
|
||||
|
||||
response.assetClass = assetClass;
|
||||
response.assetSubClass = assetSubClass;
|
||||
response.currency = assetProfile.price.currency;
|
||||
response.dataSource = this.getName();
|
||||
response.name = this.formatName({
|
||||
longName: assetProfile.price.longName,
|
||||
quoteType: assetProfile.price.quoteType,
|
||||
shortName: assetProfile.price.shortName,
|
||||
symbol: assetProfile.price.symbol
|
||||
});
|
||||
response.symbol = aSymbol;
|
||||
|
||||
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
|
||||
) {
|
||||
// Add country if asset is stock and country available
|
||||
|
||||
try {
|
||||
const [code] = Object.entries(countries).find(([, country]) => {
|
||||
return country.name === assetProfile.summaryProfile?.country;
|
||||
});
|
||||
|
||||
if (code) {
|
||||
response.countries = [{ code, weight: 1 }];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (assetProfile.summaryProfile?.sector) {
|
||||
response.sectors = [
|
||||
{ name: assetProfile.summaryProfile?.sector, weight: 1 }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const url = assetProfile.summaryProfile?.website;
|
||||
if (url) {
|
||||
response.url = url;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'YahooFinanceService');
|
||||
}
|
||||
|
||||
return response;
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency,
|
||||
name,
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
@ -177,7 +64,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
try {
|
||||
const historicalResult = await yahooFinance.historical(
|
||||
this.convertToYahooFinanceSymbol(symbol),
|
||||
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
|
||||
symbol
|
||||
),
|
||||
{
|
||||
events: 'dividends',
|
||||
interval: granularity === 'month' ? '1mo' : '1d',
|
||||
@ -227,7 +116,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
try {
|
||||
const historicalResult = await yahooFinance.historical(
|
||||
this.convertToYahooFinanceSymbol(aSymbol),
|
||||
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
|
||||
aSymbol
|
||||
),
|
||||
{
|
||||
interval: '1d',
|
||||
period1: format(from, DATE_FORMAT),
|
||||
@ -277,7 +168,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return {};
|
||||
}
|
||||
const yahooFinanceSymbols = aSymbols.map((symbol) =>
|
||||
this.convertToYahooFinanceSymbol(symbol)
|
||||
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
|
||||
);
|
||||
|
||||
try {
|
||||
@ -287,7 +178,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
for (const quote of quotes) {
|
||||
// Convert symbols back
|
||||
const symbol = this.convertFromYahooFinanceSymbol(quote.symbol);
|
||||
const symbol =
|
||||
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
|
||||
quote.symbol
|
||||
);
|
||||
|
||||
response[symbol] = {
|
||||
currency: quote.currency,
|
||||
@ -404,15 +298,24 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return currentQuote.symbol === marketDataItem.symbol;
|
||||
});
|
||||
|
||||
const symbol = this.convertFromYahooFinanceSymbol(
|
||||
marketDataItem.symbol
|
||||
);
|
||||
const symbol =
|
||||
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
|
||||
marketDataItem.symbol
|
||||
);
|
||||
|
||||
const { assetClass, assetSubClass } =
|
||||
this.yahooFinanceDataEnhancerService.parseAssetClass({
|
||||
quoteType: quote.quoteType,
|
||||
shortName: quote.shortname
|
||||
});
|
||||
|
||||
items.push({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
symbol,
|
||||
currency: marketDataItem.currency,
|
||||
dataSource: this.getName(),
|
||||
name: this.formatName({
|
||||
name: this.yahooFinanceDataEnhancerService.formatName({
|
||||
longName: quote.longname,
|
||||
quoteType: quote.quoteType,
|
||||
shortName: quote.shortname,
|
||||
@ -427,40 +330,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return { items };
|
||||
}
|
||||
|
||||
private formatName({
|
||||
longName,
|
||||
quoteType,
|
||||
shortName,
|
||||
symbol
|
||||
}: {
|
||||
longName: Price['longName'];
|
||||
quoteType: Price['quoteType'];
|
||||
shortName: Price['shortName'];
|
||||
symbol: Price['symbol'];
|
||||
}) {
|
||||
let name = longName;
|
||||
|
||||
if (name) {
|
||||
name = name.replace('iShares ETF (CH) - ', '');
|
||||
name = name.replace('iShares III Public Limited Company - ', '');
|
||||
name = name.replace('iShares VI Public Limited Company - ', '');
|
||||
name = name.replace('iShares VII PLC - ', '');
|
||||
name = name.replace('Multi Units Luxembourg - ', '');
|
||||
name = name.replace('VanEck ETFs N.V. - ', '');
|
||||
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
|
||||
name = name.replace('Vanguard Funds Public Limited Company - ', '');
|
||||
name = name.replace('Vanguard Index Funds - ', '');
|
||||
name = name.replace('Xtrackers (IE) Plc - ', '');
|
||||
}
|
||||
|
||||
if (quoteType === 'FUTURE') {
|
||||
// "Gold Jun 22" -> "Gold"
|
||||
name = shortName?.slice(0, -6);
|
||||
}
|
||||
|
||||
return name || shortName || symbol;
|
||||
}
|
||||
|
||||
private getConvertedValue({
|
||||
symbol,
|
||||
value
|
||||
@ -481,89 +350,4 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private parseAssetClass(aPrice: Price): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
} {
|
||||
let assetClass: AssetClass;
|
||||
let assetSubClass: AssetSubClass;
|
||||
|
||||
switch (aPrice?.quoteType?.toLowerCase()) {
|
||||
case 'cryptocurrency':
|
||||
assetClass = AssetClass.CASH;
|
||||
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||
break;
|
||||
case 'equity':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
assetSubClass = AssetSubClass.STOCK;
|
||||
break;
|
||||
case 'etf':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
assetSubClass = AssetSubClass.ETF;
|
||||
break;
|
||||
case 'future':
|
||||
assetClass = AssetClass.COMMODITY;
|
||||
assetSubClass = AssetSubClass.COMMODITY;
|
||||
|
||||
if (
|
||||
aPrice?.shortName?.toLowerCase()?.startsWith('gold') ||
|
||||
aPrice?.shortName?.toLowerCase()?.startsWith('palladium') ||
|
||||
aPrice?.shortName?.toLowerCase()?.startsWith('platinum') ||
|
||||
aPrice?.shortName?.toLowerCase()?.startsWith('silver')
|
||||
) {
|
||||
assetSubClass = AssetSubClass.PRECIOUS_METAL;
|
||||
}
|
||||
|
||||
break;
|
||||
case 'mutualfund':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
assetSubClass = AssetSubClass.MUTUALFUND;
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -168,7 +168,7 @@ export class ExchangeRateDataService {
|
||||
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
|
||||
}
|
||||
|
||||
let factor = 1;
|
||||
let factor: number;
|
||||
|
||||
if (aFromCurrency !== aToCurrency) {
|
||||
const dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||
@ -183,9 +183,29 @@ export class ExchangeRateDataService {
|
||||
if (marketData?.marketPrice) {
|
||||
factor = marketData?.marketPrice;
|
||||
} else {
|
||||
// TODO: Get from data provider service or calculate indirectly via base currency
|
||||
// and market data
|
||||
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
|
||||
// Calculate indirectly via base currency
|
||||
try {
|
||||
const [
|
||||
{ marketPrice: marketPriceBaseCurrencyFromCurrency },
|
||||
{ marketPrice: marketPriceBaseCurrencyToCurrency }
|
||||
] = await Promise.all([
|
||||
this.marketDataService.get({
|
||||
dataSource,
|
||||
date: aDate,
|
||||
symbol: `${this.baseCurrency}${aFromCurrency}`
|
||||
}),
|
||||
this.marketDataService.get({
|
||||
dataSource,
|
||||
date: aDate,
|
||||
symbol: `${this.baseCurrency}${aToCurrency}`
|
||||
})
|
||||
]);
|
||||
|
||||
// Calculate the opposite direction
|
||||
factor =
|
||||
(1 / marketPriceBaseCurrencyFromCurrency) *
|
||||
marketPriceBaseCurrencyToCurrency;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -193,12 +213,15 @@ export class ExchangeRateDataService {
|
||||
return factor * aValue;
|
||||
}
|
||||
|
||||
// Fallback with error, if currencies are not available
|
||||
Logger.error(
|
||||
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`,
|
||||
`No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format(
|
||||
aDate,
|
||||
DATE_FORMAT
|
||||
)}`,
|
||||
'ExchangeRateDataService'
|
||||
);
|
||||
return aValue;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async prepareCurrencies(): Promise<string[]> {
|
||||
|
@ -8,7 +8,6 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
DATA_SOURCE_PRIMARY: string;
|
||||
DATA_SOURCES: string[];
|
||||
ENABLE_FEATURE_BLOG: boolean;
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { MarketState } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Account,
|
||||
@ -28,6 +28,7 @@ export interface IDataProviderHistoricalResponse {
|
||||
|
||||
export interface IDataProviderResponse {
|
||||
currency: string;
|
||||
dataProviderInfo?: DataProviderInfo;
|
||||
dataSource: DataSource;
|
||||
marketPrice: number;
|
||||
marketState: MarketState;
|
||||
|
11
apps/api/src/services/platform/platform.module.ts
Normal file
11
apps/api/src/services/platform/platform.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Module({
|
||||
exports: [PlatformService],
|
||||
imports: [PrismaModule],
|
||||
providers: [PlatformService]
|
||||
})
|
||||
export class PlatformModule {}
|
11
apps/api/src/services/platform/platform.service.ts
Normal file
11
apps/api/src/services/platform/platform.service.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async get() {
|
||||
return this.prismaService.platform.findMany();
|
||||
}
|
||||
}
|
@ -3,12 +3,7 @@ export default {
|
||||
displayName: 'client',
|
||||
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$'
|
||||
}
|
||||
},
|
||||
globals: {},
|
||||
coverageDirectory: '../../coverage/apps/client',
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
@ -16,7 +11,13 @@ export default {
|
||||
'jest-preset-angular/build/serializers/html-comment'
|
||||
],
|
||||
transform: {
|
||||
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
|
||||
'^.+.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$'
|
||||
}
|
||||
]
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
|
||||
preset: '../../jest.preset.js'
|
||||
|
@ -123,6 +123,20 @@ const routes: Routes = [
|
||||
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
|
||||
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2023/02/ghostfolio-meets-umbrel',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
|
||||
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2023/03/ghostfolio-reaches-1000-stars-on-github',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.module'
|
||||
).then((m) => m.ThousandStarsOnGitHubPageModule)
|
||||
},
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
|
@ -31,7 +31,8 @@
|
||||
>
|
||||
<div
|
||||
*ngIf="!canCreateAccount && info?.systemMessage && user"
|
||||
class="d-inline-block info-message px-3 py-2"
|
||||
class="cursor-pointer d-inline-block info-message px-3 py-2 text-truncate"
|
||||
(click)="onShowSystemMessage()"
|
||||
>
|
||||
{{ info.systemMessage }}
|
||||
</div>
|
||||
|
@ -13,9 +13,10 @@
|
||||
margin-top: -0.5rem;
|
||||
|
||||
.info-message {
|
||||
background-color: rgba(0, 0, 0, $alpha-hover);
|
||||
background-color: rgba(var(--palette-foreground-text), 0.05);
|
||||
border-radius: 2rem;
|
||||
font-size: 80%;
|
||||
max-width: 100%;
|
||||
|
||||
.a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
@ -30,3 +31,13 @@
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
main {
|
||||
.info-message-container {
|
||||
.info-message {
|
||||
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -100,11 +100,15 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.tokenStorageService.signOut();
|
||||
}
|
||||
|
||||
public onShowSystemMessage() {
|
||||
alert(this.info.systemMessage);
|
||||
}
|
||||
|
||||
public onSignOut() {
|
||||
this.tokenStorageService.signOut();
|
||||
this.userService.remove();
|
||||
|
||||
document.location.href = '/';
|
||||
document.location.href = `/${document.documentElement.lang}`;
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -7,10 +7,10 @@ import {
|
||||
MAT_DATE_LOCALE,
|
||||
MatNativeDateModule
|
||||
} from '@angular/material/core';
|
||||
import { MatLegacyAutocompleteModule as MatAutocompleteModule } from '@angular/material/legacy-autocomplete';
|
||||
import { MatLegacyChipsModule as MatChipsModule } from '@angular/material/legacy-chips';
|
||||
import { MatLegacySnackBarModule as MatSnackBarModule } from '@angular/material/legacy-snack-bar';
|
||||
import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
|
||||
import { AccessTableComponent } from './access-table.component';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-dialog-content {
|
||||
.mat-mdc-dialog-content {
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
|
@ -6,10 +6,7 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {
|
||||
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
|
||||
MatLegacyDialogRef as MatDialogRef
|
||||
} from '@angular/material/legacy-dialog';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
|
@ -3,17 +3,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-table {
|
||||
td {
|
||||
&.mat-footer-cell {
|
||||
border-top: 1px solid
|
||||
rgba(
|
||||
var(--palette-foreground-divider),
|
||||
var(--palette-foreground-divider-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-table {
|
||||
th {
|
||||
::ng-deep {
|
||||
.mat-sort-header-container {
|
||||
@ -23,16 +13,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.mat-table {
|
||||
td {
|
||||
&.mat-footer-cell {
|
||||
border-top-color: rgba(
|
||||
var(--palette-foreground-divider-dark),
|
||||
var(--palette-foreground-divider-dark-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { Router } from '@angular/router';
|
||||
import { Account as AccountModel } from '@prisma/client';
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||
|
@ -2,10 +2,7 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<form class="align-items-center d-flex" [formGroup]="filterForm">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline without-hint w-100"
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-select formControlName="status">
|
||||
<mat-option></mat-option>
|
||||
<mat-option
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
|
||||
import { AdminJobsComponent } from './admin-jobs.component';
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
<div>
|
||||
<gf-line-chart
|
||||
class="mb-4"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isAnimated]="true"
|
||||
[locale]="locale"
|
||||
@ -28,7 +29,7 @@
|
||||
}"
|
||||
[title]="
|
||||
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
||||
| date: defaultDateFormat) ?? ''
|
||||
| date : defaultDateFormat) ?? ''
|
||||
"
|
||||
(click)="
|
||||
onOpenMarketDataDetail({
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
|
@ -6,10 +6,7 @@ import {
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||
import {
|
||||
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
|
||||
MatLegacyDialogRef as MatDialogRef
|
||||
} from '@angular/material/legacy-dialog';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
<form class="d-flex flex-column h-100">
|
||||
<h1 i18n mat-dialog-title>Details for {{ data.symbol }}</h1>
|
||||
<div class="flex-grow-1 pt-3" mat-dialog-content>
|
||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||
<div class="mb-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Date</mat-label>
|
||||
@ -33,7 +33,7 @@
|
||||
<span class="ml-2" matTextSuffix>{{ data.currency }}</span>
|
||||
</mat-form-field>
|
||||
<button
|
||||
class="apply-current-market-price ml-2 no-min-width"
|
||||
class="ml-2 mt-1 no-min-width"
|
||||
mat-button
|
||||
title="Fetch market price"
|
||||
(click)="onFetchSymbolForDate()"
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
|
||||
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
||||
|
||||
|
@ -1,13 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-dialog-content {
|
||||
.mat-mdc-dialog-content {
|
||||
max-height: unset;
|
||||
|
||||
.mat-mdc-button {
|
||||
&.apply-current-market-price {
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,8 +6,8 @@ import {
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
|
||||
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
|
@ -152,7 +152,7 @@
|
||||
mat-menu-item
|
||||
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
>
|
||||
<ng-container i18n>Gather Data</ng-container>
|
||||
<ng-container i18n>Gather Historical Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-dialog-content {
|
||||
.mat-mdc-dialog-content {
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
|
@ -7,13 +7,11 @@ import {
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import {
|
||||
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
|
||||
MatLegacyDialogRef as MatDialogRef
|
||||
} from '@angular/material/legacy-dialog';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import {
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -33,7 +31,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
||||
})
|
||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public assetClass: string;
|
||||
public assetProfile: EnhancedSymbolProfile;
|
||||
public assetProfile: AdminMarketDataDetails['assetProfile'];
|
||||
public assetProfileForm = this.formBuilder.group({
|
||||
comment: '',
|
||||
symbolMapping: ''
|
||||
|
@ -27,7 +27,7 @@
|
||||
[disabled]="assetProfileForm.dirty"
|
||||
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})"
|
||||
>
|
||||
<ng-container i18n>Gather Data</ng-container>
|
||||
<ng-container i18n>Gather Historical Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
|
@ -2,10 +2,10 @@ import { TextFieldModule } from '@angular/cdk/text-field';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
||||
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatLegacySlideToggleChange as MatSlideToggleChange } from '@angular/material/legacy-slide-toggle';
|
||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -166,14 +166,14 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
|
||||
public onReadOnlyModeChange(aEvent: MatCheckboxChange) {
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_IS_READ_ONLY_MODE,
|
||||
value: aEvent.checked ? true : undefined
|
||||
});
|
||||
}
|
||||
|
||||
public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
|
||||
public onEnableUserSignupModeChange(aEvent: MatCheckboxChange) {
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||
value: aEvent.checked ? undefined : false
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="mb-5 row">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Count</div>
|
||||
@ -51,7 +51,7 @@
|
||||
<td>
|
||||
<button
|
||||
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
||||
class="mini-icon mx-1 no-min-width px-2"
|
||||
class="h-100 mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
(click)="onDeleteCurrency(exchangeRate.label2)"
|
||||
>
|
||||
@ -101,21 +101,21 @@
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Signup</div>
|
||||
<div class="w-50">
|
||||
<mat-slide-toggle
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
[checked]="info.globalPermissions.includes(permissions.createUserAccount)"
|
||||
(change)="onEnableUserSignupModeChange($event)"
|
||||
></mat-slide-toggle>
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
||||
<div class="w-50" i18n>Read-only Mode</div>
|
||||
<div class="w-50">
|
||||
<mat-slide-toggle
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
[checked]="info?.isReadOnlyMode"
|
||||
(change)="onReadOnlyModeChange($event)"
|
||||
></mat-slide-toggle>
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
||||
@ -124,7 +124,7 @@
|
||||
<div *ngIf="info?.systemMessage">
|
||||
<span>{{ info.systemMessage }}</span>
|
||||
<button
|
||||
class="mini-icon mx-1 no-min-width px-2"
|
||||
class="h-100 mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
(click)="onDeleteSystemMessage()"
|
||||
>
|
||||
@ -159,7 +159,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="mini-icon mx-1 no-min-width px-2"
|
||||
class="h-100 mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
(click)="onDeleteCoupon(coupon.code)"
|
||||
>
|
||||
@ -172,7 +172,7 @@
|
||||
<form #couponForm="ngForm" class="align-items-center d-flex">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline mr-2 without-hint"
|
||||
class="mr-2 without-hint"
|
||||
>
|
||||
<mat-select
|
||||
name="duration"
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
|
||||
import { MatLegacySlideToggleModule as MatSlideToggleModule } from '@angular/material/legacy-slide-toggle';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
@ -18,9 +18,9 @@ import { AdminOverviewComponent } from './admin-overview.component';
|
||||
FormsModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatCardModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggleModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: [CacheService],
|
||||
|
@ -3,26 +3,8 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-button {
|
||||
&.mini-icon {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-flat-button {
|
||||
::ng-deep {
|
||||
.mat-button-wrapper {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subscription {
|
||||
.mat-form-field {
|
||||
.mat-mdc-form-field {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user