Compare commits
84 Commits
Author | SHA1 | Date | |
---|---|---|---|
420f331be9 | |||
e0068c4d5d | |||
85661884a6 | |||
8f6203d296 | |||
2fa723dc3c | |||
a500fb72c5 | |||
02db0db733 | |||
c87b08ca8b | |||
fcc2ab1a48 | |||
7efda2f890 | |||
3794a61d2d | |||
c1d1ea9dde | |||
0d676a46c8 | |||
97db144e01 | |||
cec55127c8 | |||
f3f359bcfb | |||
601e6f4147 | |||
e228b4925c | |||
62e3ffe413 | |||
6af885fde0 | |||
dd15bba359 | |||
43fca7ff43 | |||
faa6af5694 | |||
d2ea7a0bfb | |||
3f6319e00b | |||
5601299648 | |||
6060c7cfe0 | |||
ba78c2783d | |||
48eee5f865 | |||
f4a8acdb46 | |||
1d6ba22598 | |||
e38be8d710 | |||
da5be3fb57 | |||
b5317a7f95 | |||
43afb16808 | |||
d5c56fb16c | |||
b94c1f280b | |||
acc59866a3 | |||
c9fc3e402d | |||
6c1317f978 | |||
89be438e66 | |||
9d6214e93a | |||
0640b24290 | |||
6eb9d9d973 | |||
9ecc3176a5 | |||
96434c5a54 | |||
4063c62a17 | |||
890c5b986c | |||
423bd92b89 | |||
5dc331e386 | |||
744dc51dcd | |||
b0c53d050a | |||
830569b38e | |||
35b4aef06f | |||
bc2fd9c970 | |||
c42a8aebed | |||
fad1adb91b | |||
9cd37f8de0 | |||
d49b90d7a5 | |||
130a9ea062 | |||
ffc6309850 | |||
976cc7f243 | |||
7067aca04b | |||
1c9805bb96 | |||
8227a2d91a | |||
194aee97db | |||
0f77169952 | |||
0f8dc62c53 | |||
554136cdcd | |||
83b5cfff1f | |||
dcec3accf0 | |||
f08b0b570b | |||
8386fec98a | |||
4d3dff3e5b | |||
76890e63fa | |||
4fb2aebf4f | |||
ed5cd3b978 | |||
469c1936b4 | |||
8b3cc5c11a | |||
ee086638f3 | |||
58d1abbd38 | |||
ba979cbae2 | |||
8cda43bb63 | |||
c4499df74c |
12
.github/workflows/build-code.yml
vendored
12
.github/workflows/build-code.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 18
|
||||
- 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@ -24,16 +24,16 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
cache: 'yarn'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
run: yarn format:check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Execute tests
|
||||
run: yarn test
|
||||
run: npm test
|
||||
|
||||
- name: Build application
|
||||
run: yarn build:production
|
||||
run: npm run build:production
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,8 +5,8 @@
|
||||
/tmp
|
||||
|
||||
# dependencies
|
||||
/.yarn
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
@ -34,10 +34,8 @@
|
||||
/coverage
|
||||
/dist
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
testem.log
|
||||
/typings
|
||||
yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
|
151
CHANGELOG.md
151
CHANGELOG.md
@ -5,6 +5,155 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 2.101.0 - 2024-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities
|
||||
|
||||
## 2.100.0 - 2024-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to manage tags of holdings in the holding detail dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
|
||||
- Persisted the view mode of the holdings tab on the home page (experimental)
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
|
||||
## 2.99.0 - 2024-07-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the usage of `yarn` to `npm`
|
||||
- Upgraded `storybook` from version `7.0.9` to `8.2.5`
|
||||
- Downgraded `marked` from version `13.0.0` to `12.0.2`
|
||||
|
||||
## 2.98.0 - 2024-07-27
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for Catalan (`ca`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the account selector of the create or update activity dialog
|
||||
- Improved the handling of the numerical precision in the value component
|
||||
- Skipped derived currencies in the get quotes functionality of the data provider service
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `angular` from version `18.0.4` to `18.1.1`
|
||||
- Upgraded `Nx` from version `19.4.3` to `19.5.1`
|
||||
- Upgraded `prisma` from version `5.16.1` to `5.17.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the dividend import from a data provider for holdings without an account
|
||||
- Fixed an issue in the public page related to a non-existent access
|
||||
|
||||
## 2.97.0 - 2024-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added _selfh.st_ to the _As seen in_ section on the landing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the numerical precision in the holding detail dialog
|
||||
- Improved the handling of the numerical precision in the value component
|
||||
- Optimized the 7d data gathering by prioritizing the currencies
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `Node.js` from version `18` to `20` (`Dockerfile`)
|
||||
- Upgraded `Nx` from version `19.4.0` to `19.4.3`
|
||||
- Upgraded `prettier` from version `3.3.1` to `3.3.3`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the table sorting of the holdings tab on the home page
|
||||
|
||||
## 2.96.0 - 2024-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the chart of the holdings tab on the home page (experimental)
|
||||
- Separated the icon purposes in the `site.webmanifest`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the portfolio summary with the currency conversion of fees
|
||||
- Fixed an issue in the the search for a holding
|
||||
- Removed the show condition of the experimental features setting in the user settings
|
||||
|
||||
## 2.95.0 - 2024-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added a chart to the holdings tab of the home page (experimental)
|
||||
|
||||
## 2.94.0 - 2024-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a pagination issue in the activities endpoint by adding `id` as a secondary sort criterion to `date` to ensure consistent ordering
|
||||
|
||||
## 2.93.0 - 2024-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _Crypto Coins Heatmap_ to the resources section
|
||||
- Added the _Stock Heatmap_ to the resources section
|
||||
- Extended the content of the _Self-Hosting_ section by the platforms concept on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the allocations by ETF holding on the allocations page for the impersonation mode (experimental)
|
||||
- Improved the detection of REST APIs (`JSON`) used via the scraper configuration
|
||||
- Improved the usability to delete an asset profile of type currency in the historical market data table and the asset profile details dialog of the admin control
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Refactored the thresholds of the rules in the _X-ray_ section
|
||||
- Removed the obsolete `version` from the `docker-compose` files
|
||||
- Upgraded `Nx` from version `19.2.2` to `19.4.0`
|
||||
|
||||
## 2.92.0 - 2024-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for bulk deletion of asset profiles from the market data table in the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Added support for derived currencies in the currency validation
|
||||
- Added support for automatic deletion of unused asset profiles when deleting activities
|
||||
- Improved the caching of the benchmarks in the markets overview (only cache if needed)
|
||||
- Upgraded `prisma` from version `5.15.0` to `5.16.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the all time high in the benchmarks of the markets overview
|
||||
|
||||
## 2.91.0 - 2024-06-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added a benchmarks preset to the historical market data table of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `angular` from version `18.0.2` to `18.0.4`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the dialog position (center) on mobile
|
||||
- Fixed the horizontal overflow in the historical market data table of the admin control panel
|
||||
- Changed the mechanism of the `INTRADAY` data gathering to persist data only if the market state is `OPEN`
|
||||
- Fixed the creation of activities with `MANUAL` data source (with no historical market data)
|
||||
|
||||
## 2.90.0 - 2024-06-22
|
||||
|
||||
### Added
|
||||
@ -4731,7 +4880,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Added the attribute `precision` in the value component
|
||||
- Added the attribute `precision` to the value component
|
||||
|
||||
### Fixed
|
||||
|
||||
|
@ -10,7 +10,7 @@ Remove permission in `UserService` using `without()`
|
||||
|
||||
### Frontend
|
||||
|
||||
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||
|
||||
## Git
|
||||
|
||||
@ -30,26 +30,26 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||
|
||||
#### 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` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
||||
1. Run `npx nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `npm install`
|
||||
1. Run `npx nx migrate --run-migrations`
|
||||
|
||||
### Prisma
|
||||
|
||||
#### Access database via GUI
|
||||
|
||||
Run `yarn database:gui`
|
||||
Run `npm run database:gui`
|
||||
|
||||
https://www.prisma.io/studio
|
||||
|
||||
#### Synchronize schema with database for prototyping
|
||||
|
||||
Run `yarn database:push`
|
||||
Run `npm run database:push`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||
|
||||
#### Create schema migration
|
||||
|
||||
Run `yarn prisma migrate dev --name added_job_title`
|
||||
Run `npm run prisma migrate dev --name added_job_title`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
||||
|
27
Dockerfile
27
Dockerfile
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -8,18 +8,17 @@ WORKDIR /ghostfolio
|
||||
COPY ./CHANGELOG.md CHANGELOG.md
|
||||
COPY ./LICENSE LICENSE
|
||||
COPY ./package.json package.json
|
||||
COPY ./yarn.lock yarn.lock
|
||||
COPY ./.yarnrc .yarnrc
|
||||
COPY ./package-lock.json package-lock.json
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssl \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN yarn install
|
||||
RUN npm install
|
||||
|
||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
@ -33,34 +32,36 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
||||
COPY ./libs libs
|
||||
COPY ./apps apps
|
||||
|
||||
RUN yarn build:production
|
||||
RUN npm run build:production
|
||||
|
||||
# Prepare the dist image with additional node_modules
|
||||
WORKDIR /ghostfolio/dist/apps/api
|
||||
# package.json was generated by the build process, however the original
|
||||
# yarn.lock needs to be used to ensure the same versions
|
||||
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||
# package-lock.json needs to be used to ensure the same versions
|
||||
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
||||
|
||||
RUN yarn
|
||||
RUN npm install
|
||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||
|
||||
# Overwrite the generated package.json with the original one to ensure having
|
||||
# all the scripts
|
||||
COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
RUN npm run database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:18-slim
|
||||
|
||||
FROM node:20-slim
|
||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||
curl \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||
RUN chown -R node:node /ghostfolio
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE ${PORT:-3333}
|
||||
USER node
|
||||
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||
|
77
README.md
77
README.md
@ -7,7 +7,7 @@
|
||||
**Open Source Wealth Management Software**
|
||||
|
||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
|
||||
|
||||
[](https://www.buymeacoffee.com/ghostfolio)
|
||||
[](#contributing)
|
||||
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
@ -87,21 +87,21 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
| Name | Type | Default Value | Description |
|
||||
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | string (`optional`) | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
|
||||
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | string | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | number | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | `string` (optional) | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
|
||||
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -149,16 +149,15 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `npm install`
|
||||
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema
|
||||
1. Run `npm run database:setup` to initialize the database schema
|
||||
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Open https://localhost:4200/en in your browser
|
||||
@ -168,31 +167,31 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
|
||||
|
||||
#### Debug
|
||||
|
||||
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Serve
|
||||
|
||||
Run `yarn start:server`
|
||||
Run `npm run start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
Run `yarn start:client` and open https://localhost:4200/en in your browser
|
||||
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
Run `yarn start:storybook`
|
||||
Run `npm run start:storybook`
|
||||
|
||||
### Migrate Database
|
||||
|
||||
With the following command you can keep your database schema in sync:
|
||||
|
||||
```bash
|
||||
yarn database:push
|
||||
npm run database:push
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run `yarn test`
|
||||
Run `npm test`
|
||||
|
||||
## Public API
|
||||
|
||||
@ -234,17 +233,17 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
|
||||
| accountId | string (`optional`) | Id of the account |
|
||||
| comment | string (`optional`) | Comment of the activity |
|
||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| date | string | Date in the format `ISO-8601` |
|
||||
| fee | number | Fee of the activity |
|
||||
| quantity | number | Quantity of the activity |
|
||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||
| unitPrice | number | Price per unit of the activity |
|
||||
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
|
||||
| `accountId` | `string` (optional) | Id of the account |
|
||||
| `comment` | `string` (optional) | Comment of the activity |
|
||||
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| `date` | `string` | Date in the format `ISO-8601` |
|
||||
| `fee` | `number` | Fee of the activity |
|
||||
| `quantity` | `number` | Quantity of the activity |
|
||||
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
|
||||
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||
| `unitPrice` | `number` | Price per unit of the activity |
|
||||
|
||||
#### Response
|
||||
|
||||
@ -275,7 +274,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||
|
||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||
|
||||
|
@ -174,8 +174,8 @@ export class AccountService {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
} = groupBy(filters, ({ type }) => {
|
||||
return type;
|
||||
});
|
||||
|
||||
if (filtersByAccount?.length > 0) {
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -20,7 +21,7 @@ export class CreateAccountDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -20,7 +21,7 @@ export class UpdateAccountDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
|
@ -81,10 +81,11 @@ export class AdminController {
|
||||
@Post('gather/max')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherMax(): Promise<void> {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
@ -107,10 +108,11 @@ export class AdminController {
|
||||
@Post('gather/profile-data')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
@ -20,6 +21,7 @@ import { QueueModule } from './queue/queue.module';
|
||||
@Module({
|
||||
imports: [
|
||||
ApiModule,
|
||||
BenchmarkModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
@ -26,12 +27,13 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
Property,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
@ -41,6 +43,7 @@ import { groupBy } from 'lodash';
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
@ -151,7 +154,16 @@ export class AdminService {
|
||||
[{ symbol: 'asc' }];
|
||||
const where: Prisma.SymbolProfileWhereInput = {};
|
||||
|
||||
if (presetId === 'CURRENCIES') {
|
||||
if (presetId === 'BENCHMARKS') {
|
||||
const benchmarkAssetProfiles =
|
||||
await this.benchmarkService.getBenchmarkAssetProfiles();
|
||||
|
||||
where.id = {
|
||||
in: benchmarkAssetProfiles.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
};
|
||||
} else if (presetId === 'CURRENCIES') {
|
||||
return this.getMarketDataForCurrencies();
|
||||
} else if (
|
||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||
@ -201,8 +213,11 @@ export class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
const extendedPrismaClient = this.getExtendedPrismaClient();
|
||||
|
||||
try {
|
||||
let [assetProfiles, count] = await Promise.all([
|
||||
this.prismaService.symbolProfile.findMany({
|
||||
extendedPrismaClient.symbolProfile.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
@ -218,6 +233,7 @@ export class AdminService {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
id: true,
|
||||
isUsedByUsersWithSubscription: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
@ -232,8 +248,9 @@ export class AdminService {
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
|
||||
let marketData: AdminMarketDataItem[] = assetProfiles.map(
|
||||
({
|
||||
let marketData: AdminMarketDataItem[] = await Promise.all(
|
||||
assetProfiles.map(
|
||||
async ({
|
||||
_count,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
@ -242,12 +259,15 @@ export class AdminService {
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
isUsedByUsersWithSubscription,
|
||||
name,
|
||||
Order,
|
||||
sectors,
|
||||
symbol
|
||||
}) => {
|
||||
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||
const countriesCount = countries
|
||||
? Object.keys(countries).length
|
||||
: 0;
|
||||
const marketDataItemCount =
|
||||
marketDataItems.find((marketDataItem) => {
|
||||
return (
|
||||
@ -270,9 +290,11 @@ export class AdminService {
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: _count.Order,
|
||||
date: Order?.[0]?.date
|
||||
date: Order?.[0]?.date,
|
||||
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (presetId) {
|
||||
@ -293,6 +315,11 @@ export class AdminService {
|
||||
count,
|
||||
marketData
|
||||
};
|
||||
} finally {
|
||||
await extendedPrismaClient.$disconnect();
|
||||
|
||||
Logger.debug('Disconnect extended prisma client', 'AdminService');
|
||||
}
|
||||
}
|
||||
|
||||
public async getMarketDataBySymbol({
|
||||
@ -420,6 +447,52 @@ export class AdminService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private getExtendedPrismaClient() {
|
||||
Logger.debug('Connect extended prisma client', 'AdminService');
|
||||
|
||||
const symbolProfileExtension = Prisma.defineExtension((client) => {
|
||||
return client.$extends({
|
||||
result: {
|
||||
symbolProfile: {
|
||||
isUsedByUsersWithSubscription: {
|
||||
compute: async ({ id }) => {
|
||||
const { _count } =
|
||||
await this.prismaService.symbolProfile.findUnique({
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
Order: {
|
||||
where: {
|
||||
User: {
|
||||
Subscription: {
|
||||
some: {
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
|
||||
return _count.Order > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return new PrismaClient().$extends(symbolProfileExtension);
|
||||
}
|
||||
|
||||
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -26,7 +27,7 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
countries?: Prisma.InputJsonArray;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
|
@ -135,7 +135,7 @@ export class BenchmarkService {
|
||||
Promise.all(promisesAllTimeHighs),
|
||||
Promise.all(promisesBenchmarkTrends)
|
||||
]);
|
||||
let storeInCache = true;
|
||||
let storeInCache = useCache;
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
const { marketPrice } =
|
||||
@ -161,7 +161,10 @@ export class BenchmarkService {
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
date: allTimeHigh?.date,
|
||||
performancePercent: performancePercentFromAllTimeHigh
|
||||
performancePercent:
|
||||
performancePercentFromAllTimeHigh >= 0
|
||||
? 0
|
||||
: performancePercentFromAllTimeHigh
|
||||
}
|
||||
},
|
||||
symbol: benchmarkAssetProfiles[index].symbol,
|
||||
@ -419,7 +422,7 @@ export class BenchmarkService {
|
||||
private getMarketCondition(
|
||||
aPerformanceInPercent: number
|
||||
): Benchmark['marketCondition'] {
|
||||
if (aPerformanceInPercent === 0) {
|
||||
if (aPerformanceInPercent >= 0) {
|
||||
return 'ALL_TIME_HIGH';
|
||||
} else if (aPerformanceInPercent <= -0.2) {
|
||||
return 'BEAR_MARKET';
|
||||
|
@ -72,8 +72,12 @@ export class ImportService {
|
||||
})
|
||||
]);
|
||||
|
||||
const accounts = orders.map((order) => {
|
||||
return order.Account;
|
||||
const accounts = orders
|
||||
.filter(({ Account }) => {
|
||||
return !!Account;
|
||||
})
|
||||
.map(({ Account }) => {
|
||||
return Account;
|
||||
});
|
||||
|
||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
|
@ -7,7 +7,6 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
||||
|
||||
import {
|
||||
@ -12,7 +13,6 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -42,10 +42,10 @@ export class CreateOrderDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
@IsOptional()
|
||||
customCurrency?: string;
|
||||
|
||||
|
@ -66,7 +66,6 @@ export class OrderController {
|
||||
|
||||
return this.orderService.deleteOrders({
|
||||
filters,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
@ -46,6 +46,39 @@ export class OrderService {
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async assignTags({
|
||||
dataSource,
|
||||
symbol,
|
||||
tags,
|
||||
userId
|
||||
}: { tags: Tag[]; userId: string } & UniqueAsset) {
|
||||
const orders = await this.prismaService.order.findMany({
|
||||
where: {
|
||||
userId,
|
||||
SymbolProfile: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
orders.map(({ id }) =>
|
||||
this.prismaService.order.update({
|
||||
data: {
|
||||
tags: {
|
||||
// The set operation replaces all existing connections with the provided ones
|
||||
set: tags.map(({ id }) => {
|
||||
return { id };
|
||||
})
|
||||
}
|
||||
},
|
||||
where: { id }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async createOrder(
|
||||
data: Prisma.OrderCreateInput & {
|
||||
accountId?: string;
|
||||
@ -184,7 +217,15 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
|
||||
const [symbolProfile] =
|
||||
await this.symbolProfileService.getSymbolProfilesByIds([
|
||||
order.symbolProfileId
|
||||
]);
|
||||
|
||||
if (
|
||||
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) ||
|
||||
symbolProfile.activitiesCount === 0
|
||||
) {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
@ -200,18 +241,16 @@ export class OrderService {
|
||||
|
||||
public async deleteOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<number> {
|
||||
const { activities } = await this.getOrders({
|
||||
filters,
|
||||
userId,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userCurrency: undefined,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
@ -225,6 +264,19 @@ export class OrderService {
|
||||
}
|
||||
});
|
||||
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByIds(
|
||||
activities.map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
})
|
||||
);
|
||||
|
||||
for (const { activitiesCount, id } of symbolProfiles) {
|
||||
if (activitiesCount === 0) {
|
||||
await this.symbolProfileService.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({ userId })
|
||||
@ -272,7 +324,8 @@ export class OrderService {
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<Activities> {
|
||||
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
||||
{ date: 'asc' }
|
||||
{ date: 'asc' },
|
||||
{ id: 'asc' }
|
||||
];
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
@ -292,10 +345,14 @@ export class OrderService {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
} = groupBy(filters, ({ type }) => {
|
||||
return type;
|
||||
});
|
||||
|
||||
const searchQuery = filters?.find(({ type }) => {
|
||||
return type === 'SEARCH_QUERY';
|
||||
})?.id;
|
||||
|
||||
if (filtersByAccount?.length > 0) {
|
||||
where.accountId = {
|
||||
in: filtersByAccount.map(({ id }) => {
|
||||
@ -337,6 +394,30 @@ export class OrderService {
|
||||
};
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
|
||||
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||
];
|
||||
|
||||
if (where.SymbolProfile) {
|
||||
where.SymbolProfile = {
|
||||
AND: [
|
||||
where.SymbolProfile,
|
||||
{
|
||||
OR: searchQueryWhereInput
|
||||
}
|
||||
]
|
||||
};
|
||||
} else {
|
||||
where.SymbolProfile = {
|
||||
OR: searchQueryWhereInput
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (filtersByTag?.length > 0) {
|
||||
where.tags = {
|
||||
some: {
|
||||
@ -348,7 +429,7 @@ export class OrderService {
|
||||
}
|
||||
|
||||
if (sortColumn) {
|
||||
orderBy = [{ [sortColumn]: sortDirection }];
|
||||
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
|
||||
}
|
||||
|
||||
if (types) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
||||
|
||||
import {
|
||||
@ -11,7 +12,6 @@ import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -41,10 +41,10 @@ export class UpdateOrderDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
@IsOptional()
|
||||
customCurrency?: string;
|
||||
|
||||
|
@ -300,6 +300,12 @@ export abstract class PortfolioCalculator {
|
||||
const errors: ResponseError['errors'] = [];
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const feeInBaseCurrency = item.fee.mul(
|
||||
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
|
||||
lastTransactionPoint.date
|
||||
]
|
||||
);
|
||||
|
||||
const marketPriceInBaseCurrency = (
|
||||
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
|
||||
).mul(
|
||||
@ -340,10 +346,11 @@ export abstract class PortfolioCalculator {
|
||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||
|
||||
positions.push({
|
||||
dividend: totalDividend,
|
||||
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
||||
feeInBaseCurrency,
|
||||
timeWeightedInvestment,
|
||||
timeWeightedInvestmentWithCurrencyEffect,
|
||||
dividend: totalDividend,
|
||||
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
||||
averagePrice: item.averagePrice,
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
|
@ -168,6 +168,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('3.2'),
|
||||
feeInBaseCurrency: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||
|
@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('3.2'),
|
||||
feeInBaseCurrency: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
|
@ -138,6 +138,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1.55'),
|
||||
feeInBaseCurrency: new Big('1.55'),
|
||||
firstBuyDate: '2021-11-30',
|
||||
grossPerformance: new Big('24.6'),
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||
|
@ -166,6 +166,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
feeInBaseCurrency: new Big('0'),
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
|
@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('49'),
|
||||
feeInBaseCurrency: new Big('49'),
|
||||
firstBuyDate: '2021-09-01',
|
||||
grossPerformance: null,
|
||||
grossPerformancePercentage: null,
|
||||
|
@ -151,6 +151,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1'),
|
||||
feeInBaseCurrency: new Big('0.9238'),
|
||||
firstBuyDate: '2023-01-03',
|
||||
grossPerformance: new Big('27.33'),
|
||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||
@ -177,7 +178,7 @@ describe('PortfolioCalculator', () => {
|
||||
valueInBaseCurrency: new Big('103.10483')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('1'),
|
||||
totalFeesWithCurrencyEffect: new Big('0.9238'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('89.12'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||
|
@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
feeInBaseCurrency: new Big('0'),
|
||||
firstBuyDate: '2022-01-01',
|
||||
grossPerformance: null,
|
||||
grossPerformancePercentage: null,
|
||||
|
@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('4.25'),
|
||||
feeInBaseCurrency: new Big('4.25'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||
|
@ -183,6 +183,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
feeInBaseCurrency: new Big('0'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
|
@ -34,9 +34,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.fee) {
|
||||
if (currentPosition.feeInBaseCurrency) {
|
||||
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
|
||||
currentPosition.fee
|
||||
currentPosition.feeInBaseCurrency
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
@ -29,7 +30,8 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
hasReadRestrictedAccessPermission,
|
||||
isRestrictedView
|
||||
isRestrictedView,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
DateRange,
|
||||
@ -38,12 +40,14 @@ import type {
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@ -51,12 +55,13 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
|
||||
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
@ -496,9 +501,6 @@ export class PortfolioController {
|
||||
@Param('accessId') accessId
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
const access = await this.accessService.access({ id: accessId });
|
||||
const user = await this.userService.user({
|
||||
id: access.userId
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
throw new HttpException(
|
||||
@ -508,6 +510,11 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
|
||||
const user = await this.userService.user({
|
||||
id: access.userId
|
||||
});
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
@ -564,25 +571,25 @@ export class PortfolioController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPosition(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<PortfolioHoldingDetail> {
|
||||
const position = await this.portfolioService.getPosition(
|
||||
const holding = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
if (position) {
|
||||
return position;
|
||||
}
|
||||
|
||||
if (!holding) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return holding;
|
||||
}
|
||||
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getReport(
|
||||
@ -603,4 +610,36 @@ export class PortfolioController {
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateOrder)
|
||||
@Put('position/:dataSource/:symbol/tags')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateHoldingTags(
|
||||
@Body() data: UpdateHoldingTagsDto,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
const holding = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
if (!holding) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
await this.portfolioService.updateTags({
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol,
|
||||
tags: data.tags,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,78 +0,0 @@
|
||||
import { Big } from 'big.js';
|
||||
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
let portfolioService: PortfolioService;
|
||||
|
||||
beforeAll(async () => {
|
||||
portfolioService = new PortfolioService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('annualized performance percentage', () => {
|
||||
it('Get annualized performance', async () => {
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||
netPerformancePercentage: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 0,
|
||||
netPerformancePercentage: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
/**
|
||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||
*/
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 65, // < 1 year
|
||||
netPerformancePercentage: new Big(0.1025)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.729705);
|
||||
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 365, // 1 year
|
||||
netPerformancePercentage: new Big(0.05)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.05);
|
||||
|
||||
/**
|
||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||
*/
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 575, // > 1 year
|
||||
netPerformancePercentage: new Big(0.2374)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.145);
|
||||
});
|
||||
});
|
||||
});
|
@ -18,6 +18,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
EMERGENCY_FUND_TAG_ID,
|
||||
@ -58,7 +59,8 @@ import {
|
||||
DataSource,
|
||||
Order,
|
||||
Platform,
|
||||
Prisma
|
||||
Prisma,
|
||||
Tag
|
||||
} from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import {
|
||||
@ -70,7 +72,7 @@ import {
|
||||
parseISO,
|
||||
set
|
||||
} from 'date-fns';
|
||||
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash';
|
||||
import { isEmpty, uniq, uniqBy } from 'lodash';
|
||||
|
||||
import { PortfolioCalculator } from './calculator/portfolio-calculator';
|
||||
import {
|
||||
@ -206,24 +208,6 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage
|
||||
}: {
|
||||
daysInMarket: number;
|
||||
netPerformancePercentage: Big;
|
||||
}): Big {
|
||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||
|
||||
return new Big(
|
||||
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
|
||||
).minus(1);
|
||||
}
|
||||
|
||||
return new Big(0);
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
activities,
|
||||
groupBy
|
||||
@ -499,7 +483,17 @@ export class PortfolioService {
|
||||
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
|
||||
grossPerformanceWithCurrencyEffect:
|
||||
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
|
||||
holdings: assetProfile.holdings,
|
||||
holdings: assetProfile.holdings.map(
|
||||
({ allocationInPercentage, name }) => {
|
||||
return {
|
||||
allocationInPercentage,
|
||||
name,
|
||||
valueInBaseCurrency: valueInBaseCurrency
|
||||
.mul(allocationInPercentage)
|
||||
.toNumber()
|
||||
};
|
||||
}
|
||||
),
|
||||
investment: investment.toNumber(),
|
||||
marketState: dataProviderResponse?.marketState ?? 'delayed',
|
||||
name: assetProfile.name,
|
||||
@ -703,7 +697,7 @@ export class PortfolioService {
|
||||
return Account;
|
||||
});
|
||||
|
||||
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
|
||||
const dividendYieldPercent = getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||
netPerformancePercentage: timeWeightedInvestment.eq(0)
|
||||
? new Big(0)
|
||||
@ -711,7 +705,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const dividendYieldPercentWithCurrencyEffect =
|
||||
this.getAnnualizedPerformancePercent({
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
|
||||
0
|
||||
@ -1311,6 +1305,24 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async updateTags({
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol,
|
||||
tags,
|
||||
userId
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
impersonationId: string;
|
||||
symbol: string;
|
||||
tags: Tag[];
|
||||
userId: string;
|
||||
}) {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
|
||||
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
|
||||
}
|
||||
|
||||
private async getCashPositions({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
@ -1714,13 +1726,13 @@ export class PortfolioService {
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
|
||||
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
|
||||
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage: new Big(netPerformancePercentage)
|
||||
})?.toNumber();
|
||||
|
||||
const annualizedPerformancePercentWithCurrencyEffect =
|
||||
this.getAnnualizedPerformancePercent({
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage: new Big(
|
||||
netPerformancePercentageWithCurrencyEffect
|
||||
|
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Tag } from '@prisma/client';
|
||||
import { IsArray } from 'class-validator';
|
||||
|
||||
export class UpdateHoldingTagsDto {
|
||||
@IsArray()
|
||||
tags: Tag[];
|
||||
}
|
@ -1,13 +1,14 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
import type {
|
||||
ColorScheme,
|
||||
DateRange,
|
||||
HoldingsViewMode,
|
||||
ViewMode
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
@ -21,7 +22,7 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
annualInterestRate?: number;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
@IsOptional()
|
||||
baseCurrency?: string;
|
||||
|
||||
@ -66,6 +67,10 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
'filters.tags'?: string[];
|
||||
|
||||
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
|
||||
@IsOptional()
|
||||
holdingsViewMode?: HoldingsViewMode;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExperimentalFeatures?: boolean;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
@ -19,6 +20,7 @@ import { UserService } from './user.service';
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
}),
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
SubscriptionModule,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
@ -40,6 +41,7 @@ export class UserService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
@ -188,7 +190,7 @@ export class UserService {
|
||||
(user.Settings.settings as UserSettings).dateRange =
|
||||
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
|
||||
? 'max'
|
||||
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
|
||||
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
|
||||
|
||||
// Set default value for view mode
|
||||
if (!(user.Settings.settings as UserSettings).viewMode) {
|
||||
@ -235,11 +237,15 @@ export class UserService {
|
||||
|
||||
currentPermissions = without(
|
||||
currentPermissions,
|
||||
permissions.accessHoldingsChart,
|
||||
permissions.createAccess
|
||||
);
|
||||
|
||||
// Reset benchmark
|
||||
user.Settings.settings.benchmark = undefined;
|
||||
|
||||
// Reset holdings view mode
|
||||
user.Settings.settings.holdingsViewMode = undefined;
|
||||
} else if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
|
||||
@ -398,8 +404,8 @@ export class UserService {
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await this.prismaService.order.deleteMany({
|
||||
where: { userId: where.id }
|
||||
await this.orderService.deleteOrders({
|
||||
userId: where.id
|
||||
});
|
||||
} catch {}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,12 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/ca</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
|
@ -55,10 +55,10 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
|
||||
|
||||
if (maxInvestmentRatio > ruleSettings.threshold) {
|
||||
if (maxInvestmentRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `Over ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your current investment is at ${maxItem.name} (${(
|
||||
maxInvestmentRatio * 100
|
||||
).toPrecision(3)}%)`,
|
||||
@ -70,7 +70,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
evaluation: `The major part of your current investment is at ${
|
||||
maxItem.name
|
||||
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}%`,
|
||||
value: true
|
||||
};
|
||||
@ -80,12 +80,12 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
thresholdMax: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMax: number;
|
||||
}
|
||||
|
@ -41,10 +41,10 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
const maxValueRatio = maxItem?.value / totalValue || 0;
|
||||
|
||||
if (maxValueRatio > ruleSettings.threshold) {
|
||||
if (maxValueRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `Over ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your current investment is in ${maxItem.groupKey} (${(
|
||||
maxValueRatio * 100
|
||||
).toPrecision(3)}%)`,
|
||||
@ -56,7 +56,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
evaluation: `The major part of your current investment is in ${
|
||||
maxItem?.groupKey ?? ruleSettings.baseCurrency
|
||||
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}%`,
|
||||
value: true
|
||||
};
|
||||
@ -66,12 +66,12 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
thresholdMax: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMax: number;
|
||||
}
|
||||
|
@ -19,16 +19,16 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
if (this.emergencyFund > ruleSettings.threshold) {
|
||||
if (this.emergencyFund < ruleSettings.thresholdMin) {
|
||||
return {
|
||||
evaluation: 'An emergency fund has been set up',
|
||||
value: true
|
||||
evaluation: 'No emergency fund has been set up',
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: 'No emergency fund has been set up',
|
||||
value: false
|
||||
evaluation: 'An emergency fund has been set up',
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
@ -36,12 +36,12 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0
|
||||
thresholdMin: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMin: number;
|
||||
}
|
||||
|
@ -26,10 +26,10 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
? this.fees / this.totalInvestment
|
||||
: 0;
|
||||
|
||||
if (feeRatio > ruleSettings.threshold) {
|
||||
if (feeRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `The fees do exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
||||
value: false
|
||||
};
|
||||
@ -37,7 +37,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
|
||||
return {
|
||||
evaluation: `The fees do not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
||||
value: true
|
||||
};
|
||||
@ -47,12 +47,12 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.01
|
||||
thresholdMax: 0.01
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMax: number;
|
||||
}
|
||||
|
@ -45,10 +45,11 @@ export class CronService {
|
||||
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
|
||||
public async runEverySundayAtTwelvePm() {
|
||||
if (await this.isDataGatheringEnabled()) {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
DATA_GATHERING_QUEUE,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||
PROPERTY_BENCHMARKS
|
||||
@ -62,9 +63,22 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
public async gather7Days() {
|
||||
const dataGatheringItems = await this.getSymbols7D();
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems,
|
||||
dataGatheringItems: await this.getCurrencies7D(),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
});
|
||||
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems: await this.getSymbols7D({
|
||||
withUserSubscription: true
|
||||
}),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
});
|
||||
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems: await this.getSymbols7D({
|
||||
withUserSubscription: false
|
||||
}),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
|
||||
});
|
||||
}
|
||||
@ -138,7 +152,7 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
if (!uniqueAssets) {
|
||||
uniqueAssets = await this.getUniqueAssets();
|
||||
uniqueAssets = await this.getAllAssetProfileIdentifiers();
|
||||
}
|
||||
|
||||
if (uniqueAssets.length <= 0) {
|
||||
@ -270,7 +284,7 @@ export class DataGatheringService {
|
||||
);
|
||||
}
|
||||
|
||||
public async getUniqueAssets(): Promise<UniqueAsset[]> {
|
||||
public async getAllAssetProfileIdentifiers(): Promise<UniqueAsset[]> {
|
||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }]
|
||||
});
|
||||
@ -290,73 +304,83 @@ export class DataGatheringService {
|
||||
});
|
||||
}
|
||||
|
||||
private getEarliestDate(aStartDate: Date) {
|
||||
return min([aStartDate, subYears(new Date(), 10)]);
|
||||
}
|
||||
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
}
|
||||
});
|
||||
|
||||
// Only consider symbols with incomplete market data for the last
|
||||
// 7 days
|
||||
const symbolsWithCompleteMarketData = (
|
||||
private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise<
|
||||
UniqueAsset[]
|
||||
> {
|
||||
return (
|
||||
await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['symbol'],
|
||||
by: ['dataSource', 'symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
where: {
|
||||
date: { gt: startDate },
|
||||
date: { gt: subDays(resetHours(new Date()), 7) },
|
||||
state: 'CLOSE'
|
||||
}
|
||||
})
|
||||
)
|
||||
.filter((group) => {
|
||||
return group._count >= 6;
|
||||
.filter(({ _count }) => {
|
||||
return _count >= 6;
|
||||
})
|
||||
.map((group) => {
|
||||
return group.symbol;
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
});
|
||||
}
|
||||
|
||||
private async getCurrencies7D(): Promise<IDataGatheringItem[]> {
|
||||
const assetProfileIdentifiersWithCompleteMarketData =
|
||||
await this.getAssetProfileIdentifiersWithCompleteMarketData();
|
||||
|
||||
return this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ dataSource, symbol }) => {
|
||||
return !assetProfileIdentifiersWithCompleteMarketData.some((item) => {
|
||||
return item.dataSource === dataSource && item.symbol === symbol;
|
||||
});
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: subDays(resetHours(new Date()), 7)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getEarliestDate(aStartDate: Date) {
|
||||
return min([aStartDate, subYears(new Date(), 10)]);
|
||||
}
|
||||
|
||||
private async getSymbols7D({
|
||||
withUserSubscription = false
|
||||
}: {
|
||||
withUserSubscription?: boolean;
|
||||
}): Promise<IDataGatheringItem[]> {
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByUserSubscription({
|
||||
withUserSubscription
|
||||
});
|
||||
|
||||
const symbolProfilesToGather = symbolProfiles
|
||||
const assetProfileIdentifiersWithCompleteMarketData =
|
||||
await this.getAssetProfileIdentifiersWithCompleteMarketData();
|
||||
|
||||
return symbolProfiles
|
||||
.filter(({ dataSource, scraperConfiguration, symbol }) => {
|
||||
const manualDataSourceWithScraperConfiguration =
|
||||
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
|
||||
|
||||
return (
|
||||
!symbolsWithCompleteMarketData.includes(symbol) &&
|
||||
!assetProfileIdentifiersWithCompleteMarketData.some((item) => {
|
||||
return item.dataSource === dataSource && item.symbol === symbol;
|
||||
}) &&
|
||||
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
|
||||
);
|
||||
})
|
||||
.map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: startDate
|
||||
date: subDays(resetHours(new Date()), 7)
|
||||
};
|
||||
});
|
||||
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ symbol }) => {
|
||||
return !symbolsWithCompleteMarketData.includes(symbol);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
|
@ -14,7 +14,12 @@ import {
|
||||
DERIVED_CURRENCIES,
|
||||
PROPERTY_DATA_SOURCE_MAPPING
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getCurrencyFromSymbol,
|
||||
getStartOfUtcDate,
|
||||
isDerivedCurrency
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
@ -423,13 +428,18 @@ export class DataProviderService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
const symbols = dataGatheringItems
|
||||
.filter(({ symbol }) => {
|
||||
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
|
||||
})
|
||||
.map(({ symbol }) => {
|
||||
return symbol;
|
||||
});
|
||||
|
||||
const maximumNumberOfSymbolsPerRequest =
|
||||
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
||||
Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < symbols.length;
|
||||
@ -516,7 +526,8 @@ export class DataProviderService {
|
||||
.filter((symbol) => {
|
||||
return (
|
||||
isNumber(response[symbol].marketPrice) &&
|
||||
response[symbol].marketPrice > 0
|
||||
response[symbol].marketPrice > 0 &&
|
||||
response[symbol].marketState === 'open'
|
||||
);
|
||||
})
|
||||
.map((symbol) => {
|
||||
|
@ -246,7 +246,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
for (const { close, code, timestamp } of quotes) {
|
||||
let currency: string;
|
||||
|
||||
if (code.endsWith('.FOREX')) {
|
||||
if (this.isForex(code)) {
|
||||
currency = this.convertFromEodSymbol(code)?.replace(
|
||||
DEFAULT_CURRENCY,
|
||||
''
|
||||
@ -272,7 +272,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
currency,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: close,
|
||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||
marketState:
|
||||
this.isForex(code) || isToday(new Date(timestamp * 1000))
|
||||
? 'open'
|
||||
: 'closed'
|
||||
};
|
||||
} else {
|
||||
Logger.error(
|
||||
@ -311,7 +314,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
items: searchResult
|
||||
.filter(({ currency, symbol }) => {
|
||||
// Remove 'NA' currency and exchange rates
|
||||
return currency?.length === 3 && !symbol.endsWith('.FOREX');
|
||||
return currency?.length === 3 && !this.isForex(symbol);
|
||||
})
|
||||
.map(
|
||||
({
|
||||
@ -349,7 +352,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
private convertFromEodSymbol(aEodSymbol: string) {
|
||||
let symbol = aEodSymbol;
|
||||
|
||||
if (symbol.endsWith('.FOREX')) {
|
||||
if (this.isForex(symbol)) {
|
||||
symbol = symbol.replace('GBX', 'GBp');
|
||||
symbol = symbol.replace('.FOREX', '');
|
||||
}
|
||||
@ -451,6 +454,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return searchResult;
|
||||
}
|
||||
|
||||
private isForex(aCode: string) {
|
||||
return aCode?.endsWith('.FOREX') || false;
|
||||
}
|
||||
|
||||
private parseAssetClass({
|
||||
Exchange,
|
||||
Type
|
||||
|
@ -167,9 +167,10 @@ export class ManualService implements DataProviderInterface {
|
||||
});
|
||||
|
||||
for (const { currency, symbol } of symbolProfiles) {
|
||||
let marketPrice = marketData.find((marketDataItem) => {
|
||||
let marketPrice =
|
||||
marketData.find((marketDataItem) => {
|
||||
return marketDataItem.symbol === symbol;
|
||||
})?.marketPrice;
|
||||
})?.marketPrice ?? 0;
|
||||
|
||||
response[symbol] = {
|
||||
currency,
|
||||
@ -256,7 +257,7 @@ export class ManualService implements DataProviderInterface {
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
if (headers['content-type'] === 'application/json') {
|
||||
if (headers['content-type'].includes('application/json')) {
|
||||
const data = JSON.parse(body);
|
||||
const value = String(
|
||||
jsonpath.query(data, scraperConfiguration.selector)[0]
|
||||
|
@ -91,6 +91,40 @@ export class SymbolProfileService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getSymbolProfilesByUserSubscription({
|
||||
withUserSubscription = false
|
||||
}: {
|
||||
withUserSubscription?: boolean;
|
||||
}) {
|
||||
return this.prismaService.symbolProfile.findMany({
|
||||
include: {
|
||||
Order: {
|
||||
include: {
|
||||
User: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
where: {
|
||||
Order: withUserSubscription
|
||||
? {
|
||||
some: {
|
||||
User: {
|
||||
Subscription: { some: { expiresAt: { gt: new Date() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
: {
|
||||
every: {
|
||||
User: {
|
||||
Subscription: { none: { expiresAt: { gt: new Date() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public updateSymbolProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
@ -221,8 +255,9 @@ export class SymbolProfileService {
|
||||
const { name, weight } = holding as Prisma.JsonObject;
|
||||
|
||||
return {
|
||||
allocationInPercentage: weight as number,
|
||||
name: (name as string) ?? UNKNOWN_KEY,
|
||||
valueInBaseCurrency: weight as number
|
||||
valueInBaseCurrency: undefined
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -70,7 +70,7 @@ export class TwitterBotService {
|
||||
await this.twitterClient.v2.tweet(status);
|
||||
|
||||
Logger.log(
|
||||
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`,
|
||||
`Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`,
|
||||
'TwitterBotService'
|
||||
);
|
||||
}
|
||||
|
44
apps/api/src/validators/is-currency-code.ts
Normal file
44
apps/api/src/validators/is-currency-code.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { DERIVED_CURRENCIES } from '@ghostfolio/common/config';
|
||||
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationArguments
|
||||
} from 'class-validator';
|
||||
import { isISO4217CurrencyCode } from 'class-validator';
|
||||
|
||||
export function IsCurrencyCode(validationOptions?: ValidationOptions) {
|
||||
return function (object: Object, propertyName: string) {
|
||||
registerDecorator({
|
||||
propertyName,
|
||||
constraints: [],
|
||||
options: validationOptions,
|
||||
target: object.constructor,
|
||||
validator: IsExtendedCurrencyConstraint
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ async: false })
|
||||
export class IsExtendedCurrencyConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
public defaultMessage(args: ValidationArguments) {
|
||||
return '$value must be a valid ISO4217 currency code';
|
||||
}
|
||||
|
||||
public validate(currency: any) {
|
||||
// Return true if currency is a standard ISO 4217 code or a derived currency
|
||||
return (
|
||||
isISO4217CurrencyCode(currency) ||
|
||||
[
|
||||
...DERIVED_CURRENCIES.map((derivedCurrency) => {
|
||||
return derivedCurrency.currency;
|
||||
}),
|
||||
'USX'
|
||||
].includes(currency)
|
||||
);
|
||||
}
|
||||
}
|
@ -36,6 +36,10 @@
|
||||
"ngswConfigPath": "apps/client/ngsw-config.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-ca": {
|
||||
"baseHref": "/ca/",
|
||||
"localize": ["ca"]
|
||||
},
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
@ -212,6 +216,7 @@
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": [
|
||||
"messages.ca.xlf",
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.fr.xlf",
|
||||
@ -240,6 +245,10 @@
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"ca": {
|
||||
"baseHref": "/ca/",
|
||||
"translation": "apps/client/src/locales/messages.ca.xlf"
|
||||
},
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
|
@ -138,13 +138,18 @@
|
||||
<li>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
target="_blank"
|
||||
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||
>X (formerly Twitter)<ion-icon class="ml-1" name="open-outline"
|
||||
/></a>
|
||||
</li>
|
||||
<li> </li>
|
||||
<!--
|
||||
<li>
|
||||
<a href="../ca" title="Ghostfolio en català">Català</a>
|
||||
</li>
|
||||
-->
|
||||
<li>
|
||||
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
|
||||
</li>
|
||||
|
@ -259,6 +259,10 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.user?.permissions,
|
||||
permissions.reportDataGlitch
|
||||
),
|
||||
hasPermissionToUpdateOrder:
|
||||
!this.hasImpersonationId &&
|
||||
hasPermission(this.user?.permissions, permissions.updateOrder) &&
|
||||
!user?.settings?.isRestrictedView,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
|
@ -6,10 +6,17 @@ import {
|
||||
ghostfolioScraperApiSymbolPrefix
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
Filter,
|
||||
InfoItem,
|
||||
UniqueAsset,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
@ -68,6 +75,11 @@ export class AdminMarketDataComponent
|
||||
};
|
||||
})
|
||||
.concat([
|
||||
{
|
||||
id: 'BENCHMARKS',
|
||||
label: $localize`Benchmarks`,
|
||||
type: <Filter['type']>'PRESET_ID'
|
||||
},
|
||||
{
|
||||
id: 'CURRENCIES',
|
||||
label: $localize`Currencies`,
|
||||
@ -91,32 +103,23 @@ export class AdminMarketDataComponent
|
||||
new MatTableDataSource();
|
||||
public defaultDateFormat: string;
|
||||
public deviceType: string;
|
||||
public displayedColumns = [
|
||||
'nameWithSymbol',
|
||||
'dataSource',
|
||||
'assetClass',
|
||||
'assetSubClass',
|
||||
'date',
|
||||
'activitiesCount',
|
||||
'marketDataItemCount',
|
||||
'sectorsCount',
|
||||
'countriesCount',
|
||||
'comment',
|
||||
'actions'
|
||||
];
|
||||
public displayedColumns: string[] = [];
|
||||
public filters$ = new Subject<Filter[]>();
|
||||
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public info: InfoItem;
|
||||
public isLoading = false;
|
||||
public isUUID = isUUID;
|
||||
public placeholder = '';
|
||||
public pageSize = DEFAULT_PAGE_SIZE;
|
||||
public selection: SelectionModel<Partial<SymbolProfile>>;
|
||||
public totalItems = 0;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminMarketDataService: AdminMarketDataService,
|
||||
public adminMarketDataService: AdminMarketDataService,
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
@ -126,6 +129,33 @@ export class AdminMarketDataComponent
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.displayedColumns = [
|
||||
'select',
|
||||
'nameWithSymbol',
|
||||
'dataSource',
|
||||
'assetClass',
|
||||
'assetSubClass',
|
||||
'date',
|
||||
'activitiesCount',
|
||||
'marketDataItemCount',
|
||||
'sectorsCount',
|
||||
'countriesCount'
|
||||
];
|
||||
|
||||
if (this.hasPermissionForSubscription) {
|
||||
this.displayedColumns.push('isUsedByUsersWithSubscription');
|
||||
}
|
||||
|
||||
this.displayedColumns.push('comment');
|
||||
this.displayedColumns.push('actions');
|
||||
|
||||
this.route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
@ -183,6 +213,8 @@ export class AdminMarketDataComponent
|
||||
|
||||
this.benchmarks = benchmarks;
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.selection = new SelectionModel(true);
|
||||
}
|
||||
|
||||
public onChangePage(page: PageEvent) {
|
||||
@ -193,8 +225,16 @@ export class AdminMarketDataComponent
|
||||
});
|
||||
}
|
||||
|
||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
|
||||
public onDeleteAssetProfile({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
|
||||
}
|
||||
|
||||
public onDeleteAssetProfiles() {
|
||||
this.adminMarketDataService.deleteAssetProfiles(
|
||||
this.selection.selected.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public onGather7Days() {
|
||||
@ -281,6 +321,8 @@ export class AdminMarketDataComponent
|
||||
this.placeholder =
|
||||
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||
|
||||
this.selection.clear();
|
||||
|
||||
this.adminService
|
||||
.fetchAdminMarketData({
|
||||
sortColumn,
|
||||
|
@ -11,6 +11,7 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
mat-table
|
||||
@ -19,6 +20,27 @@
|
||||
matSortDirection="asc"
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="select">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@if (
|
||||
adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount: element.activitiesCount,
|
||||
isBenchmark: element.isBenchmark,
|
||||
symbol: element.symbol
|
||||
})
|
||||
) {
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
[checked]="selection.isSelected(element)"
|
||||
(change)="$event ? selection.toggle(element) : null"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
</mat-checkbox>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Symbol</ng-container>
|
||||
@ -122,6 +144,15 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="isUsedByUsersWithSubscription">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@if (element.isUsedByUsersWithSubscription) {
|
||||
<gf-premium-indicator [enableLink]="false" />
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="comment">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@ -151,6 +182,13 @@
|
||||
<button mat-menu-item (click)="onGatherProfileData()">
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="!selection.hasValue()"
|
||||
(click)="onDeleteAssetProfiles()"
|
||||
>
|
||||
<ng-container i18n>Delete Profiles</ng-container>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
@ -180,12 +218,14 @@
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="
|
||||
element.activitiesCount !== 0 ||
|
||||
element.isBenchmark ||
|
||||
element.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||
!adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount: element.activitiesCount,
|
||||
isBenchmark: element.isBenchmark,
|
||||
symbol: element.symbol
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
onDeleteProfileData({
|
||||
onDeleteAssetProfile({
|
||||
dataSource: element.dataSource,
|
||||
symbol: element.symbol
|
||||
})
|
||||
@ -213,6 +253,7 @@
|
||||
"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems"
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
|
||||
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
@ -23,8 +25,10 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
|
||||
GfActivitiesFilterComponent,
|
||||
GfAssetProfileDialogModule,
|
||||
GfCreateAssetProfileDialogModule,
|
||||
GfPremiumIndicatorComponent,
|
||||
GfSymbolModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
|
@ -1,14 +1,19 @@
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
||||
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminMarketDataItem,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { takeUntil } from 'rxjs';
|
||||
import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class AdminMarketDataService {
|
||||
public constructor(private adminService: AdminService) {}
|
||||
|
||||
public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
public deleteAssetProfile({ dataSource, symbol }: UniqueAsset) {
|
||||
const confirmation = confirm(
|
||||
$localize`Do you really want to delete this asset profile?`
|
||||
);
|
||||
@ -23,4 +28,44 @@ export class AdminMarketDataService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public deleteAssetProfiles(uniqueAssets: UniqueAsset[]) {
|
||||
const confirmation = confirm(
|
||||
$localize`Do you really want to delete these profiles?`
|
||||
);
|
||||
|
||||
if (confirmation) {
|
||||
const deleteRequests = uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
});
|
||||
|
||||
forkJoin(deleteRequests)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
alert($localize`Oops! Could not delete profiles.`);
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
finalize(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
})
|
||||
)
|
||||
.subscribe(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
public hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount,
|
||||
isBenchmark,
|
||||
symbol
|
||||
}: Pick<AdminMarketDataItem, 'activitiesCount' | 'isBenchmark' | 'symbol'>) {
|
||||
return (
|
||||
activitiesCount === 0 &&
|
||||
!isBenchmark &&
|
||||
!isCurrency(getCurrencyFromSymbol(symbol)) &&
|
||||
!symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminMarketDataService: AdminMarketDataService,
|
||||
public adminMarketDataService: AdminMarketDataService,
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
|
||||
@ -176,7 +176,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
|
||||
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
@ -48,9 +48,11 @@
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="
|
||||
assetProfile?.activitiesCount !== 0 ||
|
||||
isBenchmark ||
|
||||
data.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||
!adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount: assetProfile?.activitiesCount,
|
||||
isBenchmark: isBenchmark,
|
||||
symbol: data.symbol
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
onDeleteProfileData({
|
||||
|
@ -12,11 +12,7 @@
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Count</div>
|
||||
<div class="w-50">
|
||||
<gf-value
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="userCount"
|
||||
/>
|
||||
<gf-value [locale]="user?.settings?.locale" [value]="userCount" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
@ -24,7 +20,6 @@
|
||||
<div class="w-50">
|
||||
<gf-value
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="transactionCount"
|
||||
/>
|
||||
@if (transactionCount && userCount) {
|
||||
|
@ -4,6 +4,7 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-foote
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
@ -18,16 +19,24 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
|
||||
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
|
||||
import { GfValueComponent } from '@ghostfolio/ui/value';
|
||||
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import {
|
||||
MatAutocompleteModule,
|
||||
MatAutocompleteSelectedEvent
|
||||
} from '@angular/material/autocomplete';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import {
|
||||
@ -35,14 +44,15 @@ import {
|
||||
MatDialogModule,
|
||||
MatDialogRef
|
||||
} from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { Account, Tag } from '@prisma/client';
|
||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { Observable, of, Subject } from 'rxjs';
|
||||
import { map, startWith, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@ -59,9 +69,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
GfLineChartComponent,
|
||||
GfPortfolioProportionChartComponent,
|
||||
GfValueComponent,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatTabsModule,
|
||||
NgxSkeletonLoaderModule
|
||||
],
|
||||
@ -72,6 +84,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'holding-detail-dialog.html'
|
||||
})
|
||||
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
|
||||
|
||||
public activityForm: FormGroup;
|
||||
public accounts: Account[];
|
||||
public activities: Activity[];
|
||||
public assetClass: string;
|
||||
@ -84,28 +99,35 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
public dataProviderInfo: DataProviderInfo;
|
||||
public dataSource: MatTableDataSource<Activity>;
|
||||
public dividendInBaseCurrency: number;
|
||||
public dividendInBaseCurrencyPrecision = 2;
|
||||
public dividendYieldPercentWithCurrencyEffect: number;
|
||||
public feeInBaseCurrency: number;
|
||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||
public firstBuyDate: string;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public investment: number;
|
||||
public investmentPrecision = 2;
|
||||
public marketPrice: number;
|
||||
public maxPrice: number;
|
||||
public minPrice: number;
|
||||
public netPerformance: number;
|
||||
public netPerformancePrecision = 2;
|
||||
public netPerformancePercent: number;
|
||||
public netPerformancePercentWithCurrencyEffect: number;
|
||||
public netPerformanceWithCurrencyEffect: number;
|
||||
public netPerformanceWithCurrencyEffectPrecision = 2;
|
||||
public quantity: number;
|
||||
public quantityPrecision = 2;
|
||||
public reportDataGlitchMail: string;
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||
public sortColumn = 'date';
|
||||
public sortDirection: SortDirection = 'desc';
|
||||
public SymbolProfile: EnhancedSymbolProfile;
|
||||
public tags: Tag[];
|
||||
public tagsAvailable: Tag[];
|
||||
public totalItems: number;
|
||||
public transactionCount: number;
|
||||
public user: User;
|
||||
@ -118,10 +140,38 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
|
||||
private formBuilder: FormBuilder,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
const { tags } = this.dataService.fetchInfo();
|
||||
|
||||
this.activityForm = this.formBuilder.group({
|
||||
tags: <string[]>[]
|
||||
});
|
||||
|
||||
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
};
|
||||
});
|
||||
|
||||
this.activityForm
|
||||
.get('tags')
|
||||
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((tags) => {
|
||||
this.dataService
|
||||
.putHoldingTags({
|
||||
tags,
|
||||
dataSource: this.data.dataSource,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchHoldingDetail({
|
||||
dataSource: this.data.dataSource,
|
||||
@ -161,10 +211,20 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
this.dataProviderInfo = dataProviderInfo;
|
||||
this.dataSource = new MatTableDataSource(orders.reverse());
|
||||
this.dividendInBaseCurrency = dividendInBaseCurrency;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.dividendInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.dividendInBaseCurrencyPrecision = 0;
|
||||
}
|
||||
|
||||
this.dividendYieldPercentWithCurrencyEffect =
|
||||
dividendYieldPercentWithCurrencyEffect;
|
||||
|
||||
this.feeInBaseCurrency = feeInBaseCurrency;
|
||||
this.firstBuyDate = firstBuyDate;
|
||||
|
||||
this.historicalDataItems = historicalData.map(
|
||||
({ averagePrice, date, marketPrice }) => {
|
||||
this.benchmarkDataItems.push({
|
||||
@ -178,26 +238,82 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
this.investment = investment;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.investment >= NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.investmentPrecision = 0;
|
||||
}
|
||||
|
||||
this.marketPrice = marketPrice;
|
||||
this.maxPrice = maxPrice;
|
||||
this.minPrice = minPrice;
|
||||
this.netPerformance = netPerformance;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.netPerformancePrecision = 0;
|
||||
}
|
||||
|
||||
this.netPerformancePercent = netPerformancePercent;
|
||||
|
||||
this.netPerformancePercentWithCurrencyEffect =
|
||||
netPerformancePercentWithCurrencyEffect;
|
||||
|
||||
this.netPerformanceWithCurrencyEffect =
|
||||
netPerformanceWithCurrencyEffect;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.netPerformanceWithCurrencyEffect >=
|
||||
NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.netPerformanceWithCurrencyEffectPrecision = 0;
|
||||
}
|
||||
|
||||
this.quantity = quantity;
|
||||
|
||||
if (Number.isInteger(this.quantity)) {
|
||||
this.quantityPrecision = 0;
|
||||
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
|
||||
if (this.quantity < 1) {
|
||||
this.quantityPrecision = 7;
|
||||
} else if (this.quantity < 1000) {
|
||||
this.quantityPrecision = 5;
|
||||
} else if (this.quantity >= 10000000) {
|
||||
this.quantityPrecision = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
|
||||
this.sectors = {};
|
||||
this.SymbolProfile = SymbolProfile;
|
||||
|
||||
this.tags = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
};
|
||||
});
|
||||
|
||||
this.activityForm.setValue({ tags: this.tags }, { emitEvent: false });
|
||||
|
||||
this.filteredTagsObservable = this.activityForm.controls[
|
||||
'tags'
|
||||
].valueChanges.pipe(
|
||||
startWith(this.activityForm.get('tags').value),
|
||||
map((aTags: Tag[] | null) => {
|
||||
return aTags
|
||||
? this.filterTags(aTags)
|
||||
: this.tagsAvailable.slice();
|
||||
})
|
||||
);
|
||||
|
||||
this.transactionCount = transactionCount;
|
||||
this.totalItems = transactionCount;
|
||||
this.value = value;
|
||||
@ -282,18 +398,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
);
|
||||
|
||||
if (Number.isInteger(this.quantity)) {
|
||||
this.quantityPrecision = 0;
|
||||
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
|
||||
if (this.quantity < 1) {
|
||||
this.quantityPrecision = 7;
|
||||
} else if (this.quantity < 1000) {
|
||||
this.quantityPrecision = 5;
|
||||
} else if (this.quantity > 10000000) {
|
||||
this.quantityPrecision = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
);
|
||||
@ -309,6 +413,17 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||
this.activityForm.get('tags').setValue([
|
||||
...(this.activityForm.get('tags').value ?? []),
|
||||
this.tagsAvailable.find(({ id }) => {
|
||||
return id === event.option.value;
|
||||
})
|
||||
]);
|
||||
|
||||
this.tagInput.nativeElement.value = '';
|
||||
}
|
||||
|
||||
public onClose() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
@ -333,8 +448,26 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onRemoveTag(aTag: Tag) {
|
||||
this.activityForm.get('tags').setValue(
|
||||
this.activityForm.get('tags').value.filter(({ id }) => {
|
||||
return id !== aTag.id;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private filterTags(aTags: Tag[]) {
|
||||
const tagIds = aTags.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
|
||||
return this.tagsAvailable.filter(({ id }) => {
|
||||
return !tagIds.includes(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -47,6 +47,7 @@
|
||||
[colorizeSign]="true"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="netPerformanceWithCurrencyEffectPrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="netPerformanceWithCurrencyEffect"
|
||||
>Change with currency effect</gf-value
|
||||
@ -58,6 +59,7 @@
|
||||
[colorizeSign]="true"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="netPerformancePrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="netPerformance"
|
||||
>Change</gf-value
|
||||
@ -160,6 +162,7 @@
|
||||
size="medium"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="investmentPrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="investment"
|
||||
>Investment</gf-value
|
||||
@ -172,6 +175,7 @@
|
||||
size="medium"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="dividendInBaseCurrencyPrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="dividendInBaseCurrency"
|
||||
>Dividend</gf-value
|
||||
@ -321,7 +325,7 @@
|
||||
|
||||
<mat-tab-group
|
||||
animationDuration="0"
|
||||
class="mb-3"
|
||||
class="mb-5"
|
||||
[mat-stretch-tabs]="false"
|
||||
[ngClass]="{ 'd-none': !activities?.length }"
|
||||
>
|
||||
@ -371,7 +375,49 @@
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
@if (tags?.length > 0) {
|
||||
<div
|
||||
class="row"
|
||||
[ngClass]="{
|
||||
'd-none': !data.hasPermissionToUpdateOrder
|
||||
}"
|
||||
>
|
||||
<div class="col">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Tags</mat-label>
|
||||
<mat-chip-grid #tagsChipList>
|
||||
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
||||
<mat-chip-row
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
(removed)="onRemoveTag(tag)"
|
||||
>
|
||||
{{ tag.name }}
|
||||
<ion-icon class="ml-2" matPrefix name="close-outline" />
|
||||
</mat-chip-row>
|
||||
}
|
||||
<input
|
||||
#tagInput
|
||||
name="close-outline"
|
||||
[matAutocomplete]="autocompleteTags"
|
||||
[matChipInputFor]="tagsChipList"
|
||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||
/>
|
||||
</mat-chip-grid>
|
||||
<mat-autocomplete
|
||||
#autocompleteTags="matAutocomplete"
|
||||
(optionSelected)="onAddTag($event)"
|
||||
>
|
||||
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||
<mat-option [value]="tag.id">
|
||||
{{ tag.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!data.hasPermissionToUpdateOrder && tagsAvailable?.length > 0) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="h5" i18n>Tags</div>
|
||||
|
@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams {
|
||||
deviceType: string;
|
||||
hasImpersonationId: boolean;
|
||||
hasPermissionToReportDataGlitch: boolean;
|
||||
hasPermissionToUpdateOrder: boolean;
|
||||
locale: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -1,14 +1,24 @@
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
PortfolioPosition,
|
||||
UniqueAsset,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { HoldingType, ToggleOption } from '@ghostfolio/common/types';
|
||||
import {
|
||||
HoldingType,
|
||||
HoldingsViewMode,
|
||||
ToggleOption
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { skip, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-home-holdings',
|
||||
@ -16,8 +26,11 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './home-holdings.html'
|
||||
})
|
||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
|
||||
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToAccessHoldingsChart: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public holdings: PortfolioPosition[];
|
||||
public holdingType: HoldingType = 'ACTIVE';
|
||||
@ -26,6 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
{ label: $localize`Closed`, value: 'CLOSED' }
|
||||
];
|
||||
public user: User;
|
||||
public viewModeFormControl = new FormControl<HoldingsViewMode>(
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
|
||||
);
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -34,6 +50,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
@ -53,39 +70,51 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToAccessHoldingsChart = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.accessHoldingsChart
|
||||
);
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.holdings = undefined;
|
||||
|
||||
this.fetchHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
this.initialize();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
this.viewModeFormControl.valueChanges
|
||||
.pipe(
|
||||
// Skip inizialization: "new FormControl"
|
||||
skip(1),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe((holdingsViewMode) => {
|
||||
this.dataService
|
||||
.putUserSetting({ holdingsViewMode })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public onChangeHoldingType(aHoldingType: HoldingType) {
|
||||
this.holdingType = aHoldingType;
|
||||
|
||||
this.holdings = undefined;
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
this.fetchHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
public onSymbolClicked({ dataSource, symbol }: UniqueAsset) {
|
||||
if (dataSource && symbol) {
|
||||
this.router.navigate([], {
|
||||
queryParams: { dataSource, symbol, holdingDetailDialog: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
@ -104,4 +133,38 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
range: this.user?.settings?.dateRange
|
||||
});
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.viewModeFormControl.disable();
|
||||
|
||||
if (
|
||||
this.hasPermissionToAccessHoldingsChart &&
|
||||
this.holdingType === 'ACTIVE'
|
||||
) {
|
||||
this.viewModeFormControl.enable({ emitEvent: false });
|
||||
|
||||
this.viewModeFormControl.setValue(
|
||||
this.deviceType === 'mobile'
|
||||
? HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
|
||||
: this.user?.settings?.holdingsViewMode ||
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
|
||||
{ emitEvent: false }
|
||||
);
|
||||
} else if (this.holdingType === 'CLOSED') {
|
||||
this.viewModeFormControl.setValue(
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
|
||||
{ emitEvent: false }
|
||||
);
|
||||
}
|
||||
|
||||
this.holdings = undefined;
|
||||
|
||||
this.fetchHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,25 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<div class="d-flex justify-content-end">
|
||||
<div class="d-flex">
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<div class="d-flex">
|
||||
<div class="d-none d-lg-block">
|
||||
<mat-button-toggle-group
|
||||
[formControl]="viewModeFormControl"
|
||||
[hideSingleSelectionIndicator]="true"
|
||||
>
|
||||
<mat-button-toggle i18n-title title="Table" value="TABLE">
|
||||
<ion-icon name="reorder-four-outline" />
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle i18n-title title="Chart" value="CHART">
|
||||
<ion-icon name="grid-outline" />
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="align-items-center d-flex flex-grow-1 justify-content-end">
|
||||
<gf-toggle
|
||||
class="d-none d-lg-block"
|
||||
[defaultValue]="holdingType"
|
||||
@ -15,6 +33,16 @@
|
||||
(change)="onChangeHoldingType($event.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@if (viewModeFormControl.value === 'CHART') {
|
||||
<gf-treemap-chart
|
||||
class="mt-3"
|
||||
cursor="pointer"
|
||||
[holdings]="holdings"
|
||||
(treemapChartClicked)="onSymbolClicked($event)"
|
||||
/>
|
||||
}
|
||||
<div [ngClass]="{ 'd-none': viewModeFormControl.value !== 'TABLE' }">
|
||||
<gf-holdings-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
@ -35,4 +63,5 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
|
||||
import { GfTreemapChartComponent } from '@ghostfolio/ui/treemap-chart';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
@ -12,9 +15,13 @@ import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
declarations: [HomeHoldingsComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfHoldingsTableComponent,
|
||||
GfToggleModule,
|
||||
GfTreemapChartComponent,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -1,3 +1,9 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-button-toggle-group {
|
||||
.mat-button-toggle-appearance-standard {
|
||||
--mat-standard-button-toggle-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { LayoutService } from '@ghostfolio/client/core/layout.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
|
||||
import {
|
||||
LineChartItem,
|
||||
PortfolioPerformance,
|
||||
@ -34,6 +35,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public isAllTimeLow: boolean;
|
||||
public isLoadingPerformance = true;
|
||||
public performance: PortfolioPerformance;
|
||||
public precision = 2;
|
||||
public showDetails = false;
|
||||
public unit: string;
|
||||
public user: User;
|
||||
@ -67,6 +69,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.showDetails =
|
||||
!this.user.settings.isRestrictedView &&
|
||||
this.user.settings.viewMode !== 'ZEN';
|
||||
|
||||
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
|
||||
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -81,12 +89,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
.subscribe(() => {
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.showDetails =
|
||||
!this.user.settings.isRestrictedView &&
|
||||
this.user.settings.viewMode !== 'ZEN';
|
||||
|
||||
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
|
||||
}
|
||||
|
||||
public onChangeDateRange(dateRange: DateRange) {
|
||||
@ -134,6 +136,14 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
this.deviceType === 'mobile' &&
|
||||
this.performance.currentValueInBaseCurrency >=
|
||||
NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.precision = 0;
|
||||
}
|
||||
|
||||
this.isLoadingPerformance = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
@ -88,6 +88,7 @@
|
||||
[isLoading]="isLoadingPerformance"
|
||||
[locale]="user?.settings?.locale"
|
||||
[performance]="performance"
|
||||
[precision]="precision"
|
||||
[showDetails]="showDetails"
|
||||
[unit]="unit"
|
||||
/>
|
||||
|
@ -14,7 +14,6 @@ import {
|
||||
ElementRef,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { CountUp } from 'countup.js';
|
||||
@ -26,7 +25,7 @@ import { isNumber } from 'lodash';
|
||||
templateUrl: './portfolio-performance.component.html',
|
||||
styleUrls: ['./portfolio-performance.component.scss']
|
||||
})
|
||||
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
export class PortfolioPerformanceComponent implements OnChanges {
|
||||
@Input() deviceType: string;
|
||||
@Input() errors: ResponseError['errors'];
|
||||
@Input() isAllTimeHigh: boolean;
|
||||
@ -34,6 +33,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
@Input() isLoading: boolean;
|
||||
@Input() locale = getLocale();
|
||||
@Input() performance: PortfolioPerformance;
|
||||
@Input() precision: number;
|
||||
@Input() showDetails: boolean;
|
||||
@Input() unit: string;
|
||||
|
||||
@ -41,9 +41,9 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.precision = this.precision >= 0 ? this.precision : 2;
|
||||
|
||||
if (this.isLoading) {
|
||||
if (this.value?.nativeElement) {
|
||||
this.value.nativeElement.innerHTML = '';
|
||||
@ -52,11 +52,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
if (isNumber(this.performance?.currentValueInBaseCurrency)) {
|
||||
new CountUp('value', this.performance?.currentValueInBaseCurrency, {
|
||||
decimal: getNumberFormatDecimal(this.locale),
|
||||
decimalPlaces:
|
||||
this.deviceType === 'mobile' &&
|
||||
this.performance?.currentValueInBaseCurrency >= 100000
|
||||
? 0
|
||||
: 2,
|
||||
decimalPlaces: this.precision,
|
||||
duration: 1,
|
||||
separator: getNumberFormatGroup(this.locale)
|
||||
}).start();
|
||||
|
@ -47,6 +47,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
||||
public isWebAuthnEnabled: boolean;
|
||||
public language = document.documentElement.lang;
|
||||
public locales = [
|
||||
'ca',
|
||||
'de',
|
||||
'de-CH',
|
||||
'en-GB',
|
||||
|
@ -72,6 +72,14 @@
|
||||
<mat-option [value]="null" />
|
||||
<mat-option value="de">Deutsch</mat-option>
|
||||
<mat-option value="en">English</mat-option>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<!--
|
||||
<mat-option value="ca"
|
||||
>Català (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
-->
|
||||
}
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<mat-option value="zh"
|
||||
>Chinese (<ng-container i18n>Community</ng-container
|
||||
@ -95,10 +103,12 @@
|
||||
>)</mat-option
|
||||
>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<!--
|
||||
<mat-option value="pl"
|
||||
>Polski (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
-->
|
||||
}
|
||||
<mat-option value="pt"
|
||||
>Português (<ng-container i18n>Community</ng-container
|
||||
@ -196,7 +206,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@if (hasPermissionToUpdateUserSettings) {
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="pr-1 w-50">
|
||||
<div i18n>Experimental Features</div>
|
||||
@ -214,7 +223,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="pr-1 w-50">
|
||||
Ghostfolio <ng-container i18n>User ID</ng-container>
|
||||
|
@ -55,7 +55,7 @@
|
||||
>
|
||||
community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
@ -75,7 +75,7 @@
|
||||
<p class="align-items-center d-flex justify-content-center">
|
||||
<a
|
||||
class="mx-2"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
mat-icon-button
|
||||
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||
>
|
||||
|
@ -131,9 +131,9 @@
|
||||
</p>
|
||||
<p>
|
||||
Du erreichst mich per E-Mail unter
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> oder auf
|
||||
Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> oder auf X
|
||||
(ehemals Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
Ich freue mich, von dir zu hören.<br />
|
||||
|
@ -126,8 +126,9 @@
|
||||
</p>
|
||||
<p>
|
||||
You can reach me by e-mail at
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
I look forward to hearing from you.<br />
|
||||
|
@ -39,7 +39,7 @@
|
||||
</p>
|
||||
<p>
|
||||
At the end of 2021, Ghostfolio reached an important milestone:
|
||||
<a href="https://twitter.com/ghostfolio_/status/1470075774640218121"
|
||||
<a href="https://x.com/ghostfolio_/status/1470075774640218121"
|
||||
>100 stars</a
|
||||
>
|
||||
on GitHub. This is really exciting with almost no marketing. I am a
|
||||
@ -100,9 +100,10 @@
|
||||
of users. In the future, I would like to involve more contributors
|
||||
to further extend the functionality of Ghostfolio (e.g. with new
|
||||
reports). Get in touch with me by e-mail at
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if
|
||||
you are interested, I’m happy to discuss ideas.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a> if you are
|
||||
interested, I’m happy to discuss ideas.
|
||||
</p>
|
||||
<p>
|
||||
I would like to say thank you for all your feedback and support
|
||||
|
@ -90,8 +90,9 @@
|
||||
<p>
|
||||
If you would like to provide feedback or get involved in further
|
||||
development of Ghostfolio, please get in touch by e-mail via
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
I look forward to hearing from you.<br />
|
||||
|
@ -34,9 +34,9 @@
|
||||
>Slack</a
|
||||
>
|
||||
as well as 100 followers on
|
||||
<a href="https://twitter.com/ghostfolio_">Twitter</a>. If you have
|
||||
not joined yet, this is a good time to make sure you do not miss out
|
||||
on any future updates.
|
||||
<a href="https://x.com/ghostfolio_">Twitter</a>. If you have not
|
||||
joined yet, this is a good time to make sure you do not miss out on
|
||||
any future updates.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
@ -91,9 +91,10 @@
|
||||
engineering to realize the full potential of open source software.
|
||||
If you are a web developer and interested in personal finance,
|
||||
please get in touch by e-mail via
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We
|
||||
are happy to discuss ideas.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>. We are
|
||||
happy to discuss ideas.
|
||||
</p>
|
||||
<p>
|
||||
We would like to say thank you for all your feedback and support
|
||||
|
@ -84,8 +84,8 @@
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
>Slack</a
|
||||
>
|
||||
community or get in touch on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
|
||||
community or get in touch on X (formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a> or by
|
||||
e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||
</p>
|
||||
<p>
|
||||
|
@ -90,8 +90,8 @@
|
||||
target="_blank"
|
||||
>Slack</a
|
||||
>
|
||||
community or via Twitter
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank"
|
||||
community or via X (formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_" target="_blank"
|
||||
>@ghostfolio_</a
|
||||
>. We look forward to hearing from you!
|
||||
</p>
|
||||
|
@ -122,8 +122,9 @@
|
||||
>Slack</a
|
||||
>
|
||||
community or connect with
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> on
|
||||
Twitter. We are happy to discuss ideas and get you involved.
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a> on X
|
||||
(formerly Twitter). We are happy to discuss ideas and get you
|
||||
involved.
|
||||
</p>
|
||||
<p>Thank you for all your feedback and support.</p>
|
||||
<p>
|
||||
|
@ -25,7 +25,7 @@
|
||||
<p>
|
||||
OSS Friends started as a simple
|
||||
<a
|
||||
href="https://twitter.com/formbricks/status/1660735970281508878"
|
||||
href="https://x.com/formbricks/status/1660735970281508878"
|
||||
target="_blank"
|
||||
>post</a
|
||||
>
|
||||
|
@ -123,7 +123,7 @@
|
||||
</li>
|
||||
<li>
|
||||
On
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank">X</a>
|
||||
<a href="https://x.com/ghostfolio_" target="_blank">X</a>
|
||||
(formerly Twitter), over
|
||||
<strong>300 investors and personal finance enthusiasts</strong>
|
||||
follow Ghostfolio, keen to stay updated on the latest
|
||||
@ -151,7 +151,7 @@
|
||||
<p>
|
||||
<strong>Follow us on X</strong>: For release updates and market
|
||||
insights, follow
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank"
|
||||
<a href="https://x.com/ghostfolio_" target="_blank"
|
||||
>Ghostfolio on X</a
|
||||
>. It is the perfect place to stay informed and connect with our
|
||||
team.
|
||||
|
@ -89,7 +89,7 @@
|
||||
>Slack</a
|
||||
>
|
||||
community or get in touch on X
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
We look forward to hearing from you.<br />
|
||||
|
@ -122,7 +122,7 @@
|
||||
>
|
||||
community,
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
@ -152,7 +152,7 @@
|
||||
>Slack </a
|
||||
>community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
|
@ -151,7 +151,7 @@
|
||||
>Slack </a
|
||||
>community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
|
@ -86,6 +86,17 @@
|
||||
</ol>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>What is the concept of platforms?</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
Platforms are used to group multiple accounts, such as a savings
|
||||
account and a trading account at the same bank. By assigning accounts
|
||||
to the same platform, they are displayed with a unified icon and you
|
||||
gain insights into platform-specific risks.
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>How do I add a new platform?</mat-card-title>
|
||||
@ -186,7 +197,7 @@
|
||||
>Slack </a
|
||||
>community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
|
@ -145,8 +145,9 @@
|
||||
</h4>
|
||||
<p class="m-0">
|
||||
Check the rate of return of your portfolio for
|
||||
<code>Today</code>, <code>YTD</code>, <code>1Y</code>,
|
||||
<code>5Y</code>, and <code>Max</code>.
|
||||
<code>Today</code>, <code>WTD</code>, <code>MTD</code>,
|
||||
<code>YTD</code>, <code>1Y</code>, <code>5Y</code>, and
|
||||
<code>Max</code>.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
@ -241,9 +242,11 @@
|
||||
<h4 i18n>Multi-Language</h4>
|
||||
<p class="m-0">
|
||||
Use Ghostfolio in multiple languages: English,
|
||||
<!-- Chinese, -->Dutch, French, German, Italian,
|
||||
<!-- Polish, -->Portuguese, Spanish and Turkish are currently
|
||||
supported.
|
||||
<!--Català, -->
|
||||
<!-- Chinese, -->
|
||||
Dutch, French, German, Italian,
|
||||
<!-- Polish, -->
|
||||
Portuguese, Spanish and Turkish are currently supported.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
@ -186,6 +186,14 @@
|
||||
title="Sackgeld.com – Apps für ein höheres Sackgeld"
|
||||
></a>
|
||||
</div>
|
||||
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-selfh-st mask"
|
||||
href="https://selfh.st"
|
||||
target="_blank"
|
||||
title="selfh.st — Self-hosted content and software"
|
||||
></a>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-sourceforge mask"
|
||||
|
@ -80,6 +80,11 @@
|
||||
mask-image: url('/assets/images/logo-sackgeld.png');
|
||||
}
|
||||
|
||||
&.logo-selfh-st {
|
||||
mask-image: url('/assets/images/logo-selfh-st.svg');
|
||||
max-height: 1.25rem;
|
||||
}
|
||||
|
||||
&.logo-sourceforge {
|
||||
mask-image: url('/assets/images/logo-sourceforge.svg');
|
||||
}
|
||||
@ -131,6 +136,7 @@
|
||||
&.logo-privacy-tools,
|
||||
&.logo-reddit,
|
||||
&.logo-sackgeld,
|
||||
&.logo-selfh-st,
|
||||
&.logo-sourceforge,
|
||||
&.logo-umbrel,
|
||||
&.logo-unraid {
|
||||
|
@ -51,9 +51,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||
public isLoading = false;
|
||||
public isToday = isToday;
|
||||
public mode: 'create' | 'update';
|
||||
public platforms: { id: string; name: string }[];
|
||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
public tags: Tag[] = [];
|
||||
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||
public tagsAvailable: Tag[] = [];
|
||||
public total = 0;
|
||||
public typesTranslationMap = new Map<Type, string>();
|
||||
public Validators = Validators;
|
||||
@ -71,6 +72,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.mode = this.data.activity.id ? 'update' : 'create';
|
||||
this.locale = this.data.user?.settings?.locale;
|
||||
this.dateAdapter.setLocale(this.locale);
|
||||
|
||||
@ -79,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.currencies = currencies;
|
||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||
this.platforms = platforms;
|
||||
this.tags = tags.map(({ id, name }) => {
|
||||
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
@ -92,7 +94,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
|
||||
this.activityForm = this.formBuilder.group({
|
||||
accountId: [
|
||||
this.data.accounts.length === 1 && !this.data.activity?.accountId
|
||||
this.data.accounts.length === 1 &&
|
||||
!this.data.activity?.accountId &&
|
||||
this.mode === 'create'
|
||||
? this.data.accounts[0].id
|
||||
: this.data.activity?.accountId,
|
||||
Validators.required
|
||||
@ -283,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
].valueChanges.pipe(
|
||||
startWith(this.activityForm.get('tags').value),
|
||||
map((aTags: Tag[] | null) => {
|
||||
return aTags ? this.filterTags(aTags) : this.tags.slice();
|
||||
return aTags ? this.filterTags(aTags) : this.tagsAvailable.slice();
|
||||
})
|
||||
);
|
||||
|
||||
@ -437,10 +441,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||
this.activityForm.get('tags').setValue([
|
||||
...(this.activityForm.get('tags').value ?? []),
|
||||
this.tags.find(({ id }) => {
|
||||
this.tagsAvailable.find(({ id }) => {
|
||||
return id === event.option.value;
|
||||
})
|
||||
]);
|
||||
|
||||
this.tagInput.nativeElement.value = '';
|
||||
}
|
||||
|
||||
@ -479,18 +484,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.data.activity.id) {
|
||||
(activity as UpdateOrderDto).id = this.data.activity.id;
|
||||
|
||||
await validateObjectForForm({
|
||||
classDto: UpdateOrderDto,
|
||||
form: this.activityForm,
|
||||
ignoreFields: ['dataSource', 'date'],
|
||||
object: activity as UpdateOrderDto
|
||||
});
|
||||
|
||||
this.dialogRef.close(activity as UpdateOrderDto);
|
||||
} else {
|
||||
if (this.mode === 'create') {
|
||||
(activity as CreateOrderDto).updateAccountBalance =
|
||||
this.activityForm.get('updateAccountBalance').value;
|
||||
|
||||
@ -502,6 +496,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
});
|
||||
|
||||
this.dialogRef.close(activity as CreateOrderDto);
|
||||
} else {
|
||||
(activity as UpdateOrderDto).id = this.data.activity.id;
|
||||
|
||||
await validateObjectForForm({
|
||||
classDto: UpdateOrderDto,
|
||||
form: this.activityForm,
|
||||
ignoreFields: ['dataSource', 'date'],
|
||||
object: activity as UpdateOrderDto
|
||||
});
|
||||
|
||||
this.dialogRef.close(activity as UpdateOrderDto);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@ -514,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
}
|
||||
|
||||
private filterTags(aTags: Tag[]) {
|
||||
const tagIds = aTags.map((tag) => {
|
||||
return tag.id;
|
||||
const tagIds = aTags.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
|
||||
return this.tags.filter((tag) => {
|
||||
return !tagIds.includes(tag.id);
|
||||
return this.tagsAvailable.filter(({ id }) => {
|
||||
return !tagIds.includes(id);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -4,10 +4,10 @@
|
||||
(keyup.enter)="activityForm.valid && onSubmit()"
|
||||
(ngSubmit)="onSubmit()"
|
||||
>
|
||||
@if (data.activity.id) {
|
||||
<h1 i18n mat-dialog-title>Update activity</h1>
|
||||
} @else {
|
||||
@if (mode === 'create') {
|
||||
<h1 i18n mat-dialog-title>Add activity</h1>
|
||||
} @else {
|
||||
<h1 i18n mat-dialog-title>Update activity</h1>
|
||||
}
|
||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||
<div class="mb-3">
|
||||
@ -76,16 +76,17 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div [ngClass]="{ 'mb-3': data.activity.id }">
|
||||
<div [ngClass]="{ 'mb-3': mode === 'update' }">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="w-100"
|
||||
[ngClass]="{ 'mb-1 without-hint': !data.activity.id }"
|
||||
[ngClass]="{ 'mb-1 without-hint': mode === 'create' }"
|
||||
>
|
||||
<mat-label i18n>Account</mat-label>
|
||||
<mat-select formControlName="accountId">
|
||||
@if (
|
||||
!activityForm.get('accountId').hasValidator(Validators.required)
|
||||
!activityForm.get('accountId').hasValidator(Validators.required) ||
|
||||
(!activityForm.get('accountId').value && mode === 'update')
|
||||
) {
|
||||
<mat-option [value]="null" />
|
||||
}
|
||||
@ -106,7 +107,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': data.activity.id }">
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': mode === 'update' }">
|
||||
<mat-checkbox color="primary" formControlName="updateAccountBalance" i18n
|
||||
>Update Cash Balance</mat-checkbox
|
||||
>
|
||||
@ -377,11 +378,11 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': tags?.length < 1 }">
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Tags</mat-label>
|
||||
<mat-chip-grid #tagsChipList>
|
||||
@for (tag of activityForm.get('tags')?.value; track tag) {
|
||||
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
||||
<mat-chip-row
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
@ -403,7 +404,7 @@
|
||||
#autocompleteTags="matAutocomplete"
|
||||
(optionSelected)="onAddTag($event)"
|
||||
>
|
||||
@for (tag of filteredTagsObservable | async; track tag) {
|
||||
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||
<mat-option [value]="tag.id">
|
||||
{{ tag.name }}
|
||||
</mat-option>
|
||||
|
@ -454,33 +454,25 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
if (position.holdings.length > 0) {
|
||||
for (const holding of position.holdings) {
|
||||
const { name, valueInBaseCurrency } = holding;
|
||||
const { allocationInPercentage, name, valueInBaseCurrency } =
|
||||
holding;
|
||||
|
||||
if (
|
||||
!this.hasImpersonationId &&
|
||||
!this.user.settings.isRestrictedView
|
||||
) {
|
||||
if (this.topHoldingsMap[name]?.value) {
|
||||
this.topHoldingsMap[name].value +=
|
||||
valueInBaseCurrency *
|
||||
(isNumber(position.valueInBaseCurrency)
|
||||
? position.valueInBaseCurrency
|
||||
: position.valueInPercentage);
|
||||
this.topHoldingsMap[name].value += isNumber(valueInBaseCurrency)
|
||||
? valueInBaseCurrency
|
||||
: allocationInPercentage *
|
||||
this.portfolioDetails.holdings[symbol].valueInPercentage;
|
||||
} else {
|
||||
this.topHoldingsMap[name] = {
|
||||
name,
|
||||
value:
|
||||
valueInBaseCurrency *
|
||||
(isNumber(position.valueInBaseCurrency)
|
||||
? this.portfolioDetails.holdings[symbol]
|
||||
.valueInBaseCurrency
|
||||
: this.portfolioDetails.holdings[symbol]
|
||||
.valueInPercentage)
|
||||
value: isNumber(valueInBaseCurrency)
|
||||
? valueInBaseCurrency
|
||||
: allocationInPercentage *
|
||||
this.portfolioDetails.holdings[symbol].valueInPercentage
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (position.sectors.length > 0) {
|
||||
for (const sector of position.sectors) {
|
||||
@ -562,6 +554,14 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.topHoldings = Object.values(this.topHoldingsMap)
|
||||
.map(({ name, value }) => {
|
||||
if (this.hasImpersonationId || this.user.settings.isRestrictedView) {
|
||||
return {
|
||||
name,
|
||||
allocationInPercentage: value,
|
||||
valueInBaseCurrency: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
allocationInPercentage:
|
||||
@ -570,7 +570,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return b.valueInBaseCurrency - a.valueInBaseCurrency;
|
||||
return b.allocationInPercentage - a.allocationInPercentage;
|
||||
});
|
||||
|
||||
if (this.topHoldings.length > MAX_TOP_HOLDINGS) {
|
||||
|
@ -253,7 +253,8 @@
|
||||
} @else {
|
||||
{{ baseCurrency }} <strong>{{ price }}</strong>
|
||||
}
|
||||
<span i18n>per year</span></span
|
||||
<span> </span>
|
||||
<span i18n>per year</span></span
|
||||
>
|
||||
</p>
|
||||
@if (
|
||||
|
@ -152,7 +152,7 @@
|
||||
</div>
|
||||
<div class="row my-5">
|
||||
<div class="col-md-10 offset-md-1">
|
||||
<h2 class="h4 mb-1 text-center">
|
||||
<h2 class="h4 mb-1 text-center" i18n>
|
||||
Would you like to <strong>refine</strong> your
|
||||
<strong>personal investment strategy</strong>?
|
||||
</h2>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Product } from '@ghostfolio/common/interfaces';
|
||||
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
@ -26,6 +27,7 @@ export class GfProductPageComponent implements OnInit {
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
public tags: string[];
|
||||
|
||||
public constructor(
|
||||
private dataService: DataService,
|
||||
@ -56,7 +58,7 @@ export class GfProductPageComponent implements OnInit {
|
||||
],
|
||||
name: 'Ghostfolio',
|
||||
origin: $localize`Switzerland`,
|
||||
region: $localize`Global`,
|
||||
regions: [$localize`Global`],
|
||||
slogan: 'Open Source Wealth Management',
|
||||
useAnonymously: true
|
||||
};
|
||||
@ -64,5 +66,41 @@ export class GfProductPageComponent implements OnInit {
|
||||
this.product2 = personalFinanceTools.find(({ key }) => {
|
||||
return key === this.route.snapshot.data['key'];
|
||||
});
|
||||
|
||||
if (this.product2.origin) {
|
||||
this.product2.origin = translate(this.product2.origin);
|
||||
}
|
||||
|
||||
if (this.product2.regions) {
|
||||
this.product2.regions = this.product2.regions.map((region) => {
|
||||
return translate(region);
|
||||
});
|
||||
}
|
||||
|
||||
this.tags = [
|
||||
this.product1.name,
|
||||
this.product2.name,
|
||||
$localize`Alternative`,
|
||||
$localize`App`,
|
||||
$localize`Budgeting`,
|
||||
$localize`Community`,
|
||||
$localize`Family Office`,
|
||||
`Fintech`,
|
||||
$localize`Investment`,
|
||||
$localize`Investor`,
|
||||
$localize`Open Source`,
|
||||
`OSS`,
|
||||
$localize`Personal Finance`,
|
||||
$localize`Privacy`,
|
||||
$localize`Portfolio`,
|
||||
$localize`Software`,
|
||||
$localize`Tool`,
|
||||
$localize`User Experience`,
|
||||
$localize`Wealth`,
|
||||
$localize`Wealth Management`,
|
||||
`WealthTech`
|
||||
].sort((a, b) => {
|
||||
return a.localeCompare(b, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user