Compare commits

..

84 Commits

Author SHA1 Message Date
420f331be9 Release 2.101.0 (#3632) 2024-08-03 16:58:08 +02:00
e0068c4d5d Feature/harden container security following OWASP best practices (#3614)
* Harden container security

* Update changelog
2024-08-03 16:55:18 +02:00
85661884a6 Release 2.100.0 (#3631) 2024-08-03 15:47:52 +02:00
8f6203d296 Feature/manage tags of holdings (#3630)
* Manage tags of holdings

* Update changelog
2024-08-03 15:46:01 +02:00
2fa723dc3c Bugfix/fix language selector of user account settings (#3613)
Fix value of Català
2024-08-03 15:42:21 +02:00
a500fb72c5 Feature/refactor Angular Material theme (#3629) 2024-08-02 20:57:03 +02:00
02db0db733 Feature/persist view mode of holdings tab on home page (#3624)
* Persist view mode of holdings in user settings

* Update changelog
2024-08-02 20:27:58 +02:00
c87b08ca8b Feature/improve language localization for es (#3625)
* Update translations

* Update changelog
2024-08-01 20:28:42 +02:00
fcc2ab1a48 Feature/change color assignment by annualized performance in treemap chart (#3617)
* Change color assignment to annualized performance

* Update changelog
2024-07-31 19:18:50 +02:00
7efda2f890 Feature/improve language localization for Catalan (#3598)
* Update translations

* Update changelog
2024-07-30 14:30:08 +02:00
3794a61d2d Release 2.99.0 (#3618) 2024-07-29 20:10:26 +02:00
c1d1ea9dde Feature/migrate from Yarn 1 (Classic) to npm (#3601)
* Migrate from yarn to npm
2024-07-29 20:08:43 +02:00
0d676a46c8 Release 2.98.0 (#3615) 2024-07-27 19:53:44 +02:00
97db144e01 Feature/skip derived currencies in get quotes of data provider service (#3610)
* Skip derived currencies

* Update changelog
2024-07-27 19:47:06 +02:00
cec55127c8 Bugix/fix dividend import from data provider for holdings without account (#3606)
* Fix dividend import for holdings without account

* Update changelog
2024-07-27 19:45:12 +02:00
f3f359bcfb Feature/Improve language localization for spanish (#3612)
* Update messages.es.xlf
2024-07-26 15:55:26 +02:00
601e6f4147 Feature/improve account selector of create or update activity dialog (#3607)
* Improve empty value of account selector

* Update changelog
2024-07-25 19:39:07 +02:00
e228b4925c Feature/update notes of personal finance tools (#3611)
* Update notes
2024-07-25 19:38:52 +02:00
62e3ffe413 Feature/upgrade prisma to version 5.17.0 (#3597)
* Upgrade prisma to version 5.17.0

* Update changelog
2024-07-24 19:28:05 +02:00
6af885fde0 Feature/improve language localization for Spanish (#3605)
* Improve language localization for Spanish

* Update changelog
2024-07-24 11:51:58 +02:00
dd15bba359 Bugfix/fix public page for non existent access (#3604)
* Handle non-existent access

* Update changelog
2024-07-23 21:00:20 +02:00
43fca7ff43 Feature/improve personal finance tools product page (#3599)
* Localize origin
* Localize regions
* Localize tags
2024-07-23 20:59:23 +02:00
faa6af5694 Feature/improve handling of numerical precision in value component (#3595)
* Improve handling of numerical precision in value component

* Update changelog
2024-07-22 19:35:25 +02:00
d2ea7a0bfb Feature/upgrade nx to version 19.5.1 (#3596)
* Upgrade angular and Nx

* Update changelog
2024-07-21 09:41:16 +02:00
3f6319e00b Feature/setup catala (#3593)
* Set up Català

* Update changelog
2024-07-20 17:13:44 +02:00
5601299648 Release 2.97.0 (#3592) 2024-07-20 11:24:47 +02:00
6060c7cfe0 Feature/upgrade prettier to version 3.3.3 (#3586)
* Upgrade prettier to version 3.3.3

* Update changelog
2024-07-20 11:18:00 +02:00
ba78c2783d Feature/improve numerical precision in holding detail dialog (#3584)
* Improve numerical precision in holding detail dialog

* Update changelog
2024-07-20 11:17:36 +02:00
48eee5f865 Feature/upgrade node.js from version 18 to 20 (#3553)
* Upgrade to Node.js 20

* Update changelog
2024-07-20 10:30:05 +02:00
f4a8acdb46 Feature/add selfh.st logo to landing page (#3582)
* Add selfh.st

* Update changelog
2024-07-20 10:13:28 +02:00
1d6ba22598 Feature/improve language localization for de (#3583)
* Update translations
2024-07-20 10:12:49 +02:00
e38be8d710 Feature/upgrade nx to version 19.4.3 (#3581)
* Upgrade Nx to version 19.4.3

* Update changelog
2024-07-19 11:56:18 +02:00
da5be3fb57 Feature/reuse open-color in portfolio proportion chart component (#3562)
* Reuse open-color
2024-07-18 10:14:12 +02:00
b5317a7f95 Feature/improve language localization for de 20240715 (#3574)
* Update translations

* Update changelog
2024-07-17 17:37:56 +02:00
43afb16808 Feature/introduce isUsedByUsersWithSubscription flag (#3573) 2024-07-16 20:51:49 +02:00
d5c56fb16c Feature/optimize 7d data gathering by prioritization (#3575)
* Optimize 7d data gathering by prioritization

* Update changelog
2024-07-16 20:45:34 +02:00
b94c1f280b Bugfix/fix spacing on pricing page (#3571)
* Fix spacing
2024-07-16 20:42:41 +02:00
acc59866a3 Bugfix/fix table sorting of holdings (#3572)
* Hide holdings table to fix sorting

* Update changelog
2024-07-15 15:14:34 +02:00
c9fc3e402d Release 2.96.0 (#3570) 2024-07-13 20:13:53 +02:00
6c1317f978 Bugfix/fix search for holding in assistant (#3569)
* Fix search for holding

* Update changelog
2024-07-13 20:11:40 +02:00
89be438e66 Bugfix/remove show condition of experimental features setting (#3568)
* Remove show condition of experimental feature setting

* Update changelog
2024-07-13 19:02:47 +02:00
9d6214e93a Bugfix/fix fees calculation in portfolio summary (#3567)
* Fix fees calculation

* Update changelog
2024-07-13 18:24:03 +02:00
0640b24290 Feature/improve site.webmanifest (#3564)
* Separate icon purposes

* Update changelog
2024-07-13 11:40:45 +02:00
6eb9d9d973 Feature/extend personal finance tools 20240713 (#3565) 2024-07-13 11:40:29 +02:00
9ecc3176a5 Feature/improve treemap chart for holdings (#3563)
* Various improvements

* Introduce permission: accessHoldingsChart
* Improve style of toggle
* Add border radius

* Update changelog
2024-07-13 10:45:10 +02:00
96434c5a54 Release 2.95.0 (#3561) 2024-07-12 21:04:38 +02:00
4063c62a17 Feature/setup treemap chart for holdings (#3560)
* Setup treemap chart

* Update changelog
2024-07-12 21:02:12 +02:00
890c5b986c Feature/improve formatting of variables in README.md (#3546) 2024-07-10 17:22:47 +02:00
423bd92b89 Release 2.94.0 (#3556) 2024-07-09 18:44:53 +02:00
5dc331e386 Feature/improve language localization for de 20240709 (#3555)
* Update translations

* Update changelog
2024-07-09 18:43:20 +02:00
744dc51dcd Bugfix/fix pagination issue in activities endpoint by adding secondary sort criterion (#3554)
* Add id as secondary sort criterion to ensure consistent ordering

* Update changelog
2024-07-09 18:42:03 +02:00
b0c53d050a Feature/harmonize delete labels in admin market data (#3552) 2024-07-09 18:20:25 +02:00
830569b38e Release 2.93.0 (#3551) 2024-07-07 18:25:33 +02:00
35b4aef06f Feature/improve market state logic for forex in eod historical data service (#3550) 2024-07-07 18:23:51 +02:00
bc2fd9c970 Feature/add WTD and MTD to documentation (#3542) 2024-07-07 09:55:52 +02:00
c42a8aebed Feature/add platforms concept to faq page (#3549)
* Add concept of platforms

* Update changelog
2024-07-07 09:55:12 +02:00
fad1adb91b Feature/improve usability to delete currency asset profile (#3541)
* Improve usability

* Update changelog
2024-07-07 09:54:54 +02:00
9cd37f8de0 Feature/add crypto coins and stock heatmaps to resources page (#3548)
* Add heatmaps

* Crypto Coins Heatmap
* Stock Heatmap

* Update changelog
2024-07-07 09:40:55 +02:00
d49b90d7a5 Feature/refresh cryptocurrencies list 20240706 (#3544)
* Refresh cryptocurrencies list

* Update changelog
2024-07-07 09:39:29 +02:00
130a9ea062 Feature/remove obsolete version from docker compose files (#3543)
* Remove obsolete version

* Update changelog
2024-07-07 09:16:48 +02:00
ffc6309850 Feature/refactor thresholds of x ray rules (#3545)
* Refactor thresholds

* Update changelog
2024-07-07 08:25:51 +02:00
976cc7f243 Feature/upgrade nx to version 19.4.0 (#3540)
* Upgrade Nx to version 19.4.0

* Update changelog
2024-07-06 22:15:33 +02:00
7067aca04b Feature/replace twitter.com with x.com (#3535)
* Replace twitter.com with x.com
2024-07-05 17:26:12 +02:00
1c9805bb96 Feature/improve allocations by etf holding for impersonation mode (#3534)
* Improve allocations by ETF holding for impersonation mode

* Update changelog
2024-07-04 20:25:15 +02:00
8227a2d91a Feature/improve detection of json used via scraper configuration (#3539)
* Improve detection of json

* Update changelog
2024-07-03 18:16:07 +02:00
194aee97db Feature/update development instructions to control flow (#3466) 2024-07-02 11:58:13 +02:00
0f77169952 Fix wording (#3463) 2024-07-01 21:03:15 +02:00
0f8dc62c53 Release 2.92.0 (#3532) 2024-06-30 09:23:03 +02:00
554136cdcd Feature/bulk deletion for asset profiles (#3531)
* Add support for bulk deletion of asset profiles

* Update changelog
2024-06-30 09:21:04 +02:00
83b5cfff1f Feature/upgrade prisma to version 5.16.1 (#3526)
* Upgrade prisma to version 5.16.1

* Update changelog
2024-06-29 17:06:21 +02:00
dcec3accf0 Feature/improve caching of benchmarks (#3530)
* Improve caching

* Update changelog
2024-06-29 16:53:35 +02:00
f08b0b570b Feature/support derived currencies in currency validation (#3529)
* Support derived currencies in currency validation

* Update changelog
2024-06-29 16:30:40 +02:00
8386fec98a Feature/automatic deletion of unused asset profiles (#3525)
* Automatic deletion of unused asset profiles

* Update changelog
2024-06-29 10:53:25 +02:00
4d3dff3e5b Feature/extend personal finance tools 20240629 (#3528)
* Add Anlage.App

* Add Portfoloo

* Add SharesMaster

* Add Merlin

* Add Holistic

* Add AlphaTrackr

* Add Segmio
2024-06-29 10:53:08 +02:00
76890e63fa Bugfix/fix all time high in benchmarks (#3527)
* Fix all time high

* Update changelog
2024-06-29 10:03:45 +02:00
4fb2aebf4f Release 2.91.0 (#3522) 2024-06-26 20:40:29 +02:00
ed5cd3b978 Feature/upgrade angular to version 18.0.4 (#3520)
* Upgrade Angular to version 18.0.4

* Update changelog
2024-06-26 20:38:26 +02:00
469c1936b4 Bugfix/fix horizontal overflow in historical market data table of admin control panel (#3515)
* Fix horizontal overflow

* Update changelog
2024-06-26 20:38:12 +02:00
8b3cc5c11a Bugfix/fix dialog position on mobile (#3521)
* Fix dialog position on mobile

* Update changelog
2024-06-26 20:19:25 +02:00
ee086638f3 Feature/add benchmarks preset to admin control panel (#3513)
* Add benchmarks preset

* Update changelog
2024-06-26 20:18:53 +02:00
58d1abbd38 Feature/clean up imports (#3514)
* Clean up imports
2024-06-25 19:52:07 +02:00
ba979cbae2 Bugfix/fix addition of manual asset without market data (#3516)
* Provide default value

* Update changelog
2024-06-24 21:24:03 +02:00
8cda43bb63 Bugfix/persist intraday market data only if market state is open (#3509)
* Persist INTRADAY data only if market state is open

* Update changelog
2024-06-23 10:23:03 +02:00
c4499df74c Feature/add wealthy tracker (#3510)
* Add Wealthy Tracker
2024-06-23 10:22:35 +02:00
146 changed files with 46581 additions and 23688 deletions

View File

@ -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
View File

@ -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

2
.nvmrc
View File

@ -1 +1 @@
v18
v20

View File

@ -1 +0,0 @@
network-timeout 600000

View File

@ -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

View File

@ -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

View File

@ -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 \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install
RUN apt-get update && apt-get install -y --no-install-suggests \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
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 \
curl \
openssl \
&& rm -rf /var/lib/apt/lists/*
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" ]

View File

@ -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_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#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
@ -233,18 +232,18 @@ 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 |
| 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 |
#### 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).

View File

@ -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) {

View File

@ -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()

View File

@ -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()

View File

@ -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,

View File

@ -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,

View File

@ -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,98 +213,113 @@ export class AdminService {
}
}
let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({
orderBy,
skip,
take,
where,
select: {
_count: {
select: { Order: true }
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true
const extendedPrismaClient = this.getExtendedPrismaClient();
try {
let [assetProfiles, count] = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
orderBy,
skip,
take,
where,
select: {
_count: {
select: { Order: true }
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
isUsedByUsersWithSubscription: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
_count,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
isUsedByUsersWithSubscription,
name,
Order,
sectors,
symbol
}) => {
const countriesCount = countries
? Object.keys(countries).length
: 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
id,
name,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
};
}
)
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
}),
this.prismaService.symbolProfile.count({ where })
]);
let marketData: AdminMarketDataItem[] = assetProfiles.map(
({
_count,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
name,
Order,
sectors,
symbol
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
id,
name,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date
};
}
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
count = marketData.length;
}
count = marketData.length;
return {
count,
marketData
};
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
}
return {
count,
marketData
};
}
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,

View File

@ -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;

View File

@ -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';

View File

@ -72,9 +72,13 @@ 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;

View File

@ -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';

View File

@ -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;

View File

@ -66,7 +66,6 @@ export class OrderController {
return this.orderService.deleteOrders({
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
});
}

View File

@ -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) {

View File

@ -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;

View File

@ -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,

View File

@ -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'),

View File

@ -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'),

View File

@ -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'),

View File

@ -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'),

View File

@ -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,

View File

@ -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'),

View File

@ -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,

View File

@ -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'),

View File

@ -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'),

View File

@ -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
);
}

View File

@ -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,23 +571,23 @@ 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
);
}
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
return holding;
}
@Get('report')
@ -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
});
}
}

View File

@ -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);
});
});
});

View File

@ -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

View File

@ -0,0 +1,7 @@
import { Tag } from '@prisma/client';
import { IsArray } from 'class-validator';
export class UpdateHoldingTagsDto {
@IsArray()
tags: Tag[];
}

View File

@ -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;

View File

@ -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,

View File

@ -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

View File

@ -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>
@ -441,10 +447,10 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/pt</loc>

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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[]> {

View File

@ -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) => {

View File

@ -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

View File

@ -167,9 +167,10 @@ export class ManualService implements DataProviderInterface {
});
for (const { currency, symbol } of symbolProfiles) {
let marketPrice = marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol;
})?.marketPrice;
let marketPrice =
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol;
})?.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]

View File

@ -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
};
}
);

View File

@ -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'
);
}

View 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)
);
}
}

View File

@ -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"

View File

@ -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>&nbsp;</li>
<!--
<li>
<a href="../ca" title="Ghostfolio en català">Català</a>
</li>
-->
<li>
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
</li>

View File

@ -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',

View File

@ -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,

View File

@ -11,208 +11,249 @@
</div>
<div class="row">
<div class="col">
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="symbol"
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Symbol</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol }}
</td>
</ng-container>
<ng-container matColumnDef="nameWithSymbol">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="symbol"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
<div class="text-truncate">{{ element.name }}</div>
@if (!isUUID(element.symbol)) {
<div>
<small class="text-muted">{{
element.symbol | gfSymbol
}}</small>
</div>
}
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Data Source</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.dataSource }}
</td>
</ng-container>
<ng-container matColumnDef="assetClass">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetClass }}
</td>
</ng-container>
<ng-container matColumnDef="assetSubClass">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Sub Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetSubClass }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>First Activity</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ (element.date | date: defaultDateFormat) ?? '' }}
</td>
</ng-container>
<ng-container matColumnDef="activitiesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Activities Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.activitiesCount }}
</td>
</ng-container>
<ng-container matColumnDef="marketDataItemCount">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Historical Data</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.marketDataItemCount }}
</td>
</ng-container>
<ng-container matColumnDef="sectorsCount">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Sectors Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.sectorsCount }}
</td>
</ng-container>
<ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Countries Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.countriesCount }}
</td>
</ng-container>
<ng-container matColumnDef="comment">
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (element.comment) {
<ion-icon class="d-block" name="document-text-outline" />
}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="assetProfilesActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical" />
</button>
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onGather7Days()">
<ng-container i18n>Gather Recent Data</ng-container>
</button>
<button mat-menu-item (click)="onGatherMax()">
<ng-container i18n>Gather All Data</ng-container>
</button>
<button mat-menu-item (click)="onGatherProfileData()">
<ng-container i18n>Gather Profile Data</ng-container>
</button>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="assetProfileActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<a
mat-menu-item
[queryParams]="{
assetProfileDialog: true,
dataSource: element.dataSource,
<div class="overflow-x-auto">
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="symbol"
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
}"
[routerLink]="[]"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span>
</span>
</a>
})
) {
<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>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol }}
</td>
</ng-container>
<ng-container matColumnDef="nameWithSymbol">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="symbol"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
<div class="text-truncate">{{ element.name }}</div>
@if (!isUUID(element.symbol)) {
<div>
<small class="text-muted">{{
element.symbol | gfSymbol
}}</small>
</div>
}
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Data Source</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.dataSource }}
</td>
</ng-container>
<ng-container matColumnDef="assetClass">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetClass }}
</td>
</ng-container>
<ng-container matColumnDef="assetSubClass">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Sub Class</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.assetSubClass }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>First Activity</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ (element.date | date: defaultDateFormat) ?? '' }}
</td>
</ng-container>
<ng-container matColumnDef="activitiesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Activities Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.activitiesCount }}
</td>
</ng-container>
<ng-container matColumnDef="marketDataItemCount">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Historical Data</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.marketDataItemCount }}
</td>
</ng-container>
<ng-container matColumnDef="sectorsCount">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Sectors Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.sectorsCount }}
</td>
</ng-container>
<ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Countries Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.countriesCount }}
</td>
</ng-container>
<ng-container matColumnDef="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>
@if (element.comment) {
<ion-icon class="d-block" name="document-text-outline" />
}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button
mat-menu-item
[disabled]="
element.activitiesCount !== 0 ||
element.isBenchmark ||
element.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
"
(click)="
onDeleteProfileData({
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="assetProfilesActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical" />
</button>
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onGather7Days()">
<ng-container i18n>Gather Recent Data</ng-container>
</button>
<button mat-menu-item (click)="onGatherMax()">
<ng-container i18n>Gather All Data</ng-container>
</button>
<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>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="assetProfileActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<a
mat-menu-item
[queryParams]="{
assetProfileDialog: true,
dataSource: element.dataSource,
symbol: element.symbol
})
"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>
</ng-container>
}"
[routerLink]="[]"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span>
</span>
</a>
<button
mat-menu-item
[disabled]="
!adminMarketDataService.hasPermissionToDeleteAssetProfile({
activitiesCount: element.activitiesCount,
isBenchmark: element.isBenchmark,
symbol: element.symbol
})
"
(click)="
onDeleteAssetProfile({
dataSource: element.dataSource,
symbol: element.symbol
})
"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
class="cursor-pointer"
mat-row
(click)="
onOpenAssetProfileDialog({
dataSource: row.dataSource,
symbol: row.symbol
})
"
></tr>
</table>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
class="cursor-pointer"
mat-row
(click)="
onOpenAssetProfileDialog({
dataSource: row.dataSource,
symbol: row.symbol
})
"
></tr>
</table>
</div>
<mat-paginator
[length]="totalItems"

View File

@ -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,

View File

@ -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)
);
}
}

View File

@ -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();
}

View File

@ -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({

View File

@ -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) {

View File

@ -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);
});
}
}

View File

@ -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>

View File

@ -9,6 +9,7 @@ export interface HoldingDetailDialogParams {
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToReportDataGlitch: boolean;
hasPermissionToUpdateOrder: boolean;
locale: string;
symbol: string;
}

View File

@ -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,38 +70,50 @@ 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() {
@ -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();
});
}
}

View File

@ -6,33 +6,62 @@
</div>
<div class="row">
<div class="col-lg">
<div class="d-flex justify-content-end">
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="holdingType"
[isLoading]="false"
[options]="holdingTypeOptions"
(change)="onChangeHoldingType($event.value)"
/>
</div>
<gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[holdings]="holdings"
[locale]="user?.settings?.locale"
/>
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
<div class="text-center">
<a
class="mt-3"
i18n
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>
<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"
[isLoading]="false"
[options]="holdingTypeOptions"
(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"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[holdings]="holdings"
[locale]="user?.settings?.locale"
/>
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
<div class="text-center">
<a
class="mt-3"
i18n
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>
</div>
}
</div>
</div>
</div>
</div>

View File

@ -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]

View File

@ -1,3 +1,9 @@
:host {
display: block;
.mat-button-toggle-group {
.mat-button-toggle-appearance-standard {
--mat-standard-button-toggle-height: 1.5rem;
}
}
}

View File

@ -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();

View File

@ -88,6 +88,7 @@
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"
[precision]="precision"
[showDetails]="showDetails"
[unit]="unit"
/>

View File

@ -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();

View File

@ -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',

View File

@ -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="pl"
>Polski (<ng-container i18n>Community</ng-container
>)</mat-option
>
-->
}
<mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container
@ -196,25 +206,23 @@
/>
</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>
<div class="hint-text text-muted" i18n>
Sneak peek at upcoming functionality
</div>
</div>
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="user.settings.isExperimentalFeatures"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onExperimentalFeaturesChange($event)"
/>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Experimental Features</div>
<div class="hint-text text-muted" i18n>
Sneak peek at upcoming functionality
</div>
</div>
}
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="user.settings.isExperimentalFeatures"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onExperimentalFeaturesChange($event)"
/>
</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>

View File

@ -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)"
>&#64;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)"
>

View File

@ -131,9 +131,9 @@
</p>
<p>
Du erreichst mich per E-Mail unter
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> oder auf
Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> oder auf X
(ehemals Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
Ich freue mich, von dir zu hören.<br />

View File

@ -126,8 +126,9 @@
</p>
<p>
You can reach me by e-mail at
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
(formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
I look forward to hearing from you.<br />

View File

@ -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&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a> if
you are interested, Im happy to discuss ideas.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
(formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a> if you are
interested, Im happy to discuss ideas.
</p>
<p>
I would like to say thank you for all your feedback and support

View File

@ -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&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
(formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
I look forward to hearing from you.<br />

View File

@ -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&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>. We
are happy to discuss ideas.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
(formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>. We are
happy to discuss ideas.
</p>
<p>
We would like to say thank you for all your feedback and support

View File

@ -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_">&#64;ghostfolio_</a> or by
community or get in touch on X (formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a> or by
e-mail via <a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a>.
</p>
<p>

View File

@ -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"
>&#64;ghostfolio_</a
>. We look forward to hearing from you!
</p>

View File

@ -122,8 +122,9 @@
>Slack</a
>
community or connect with
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a> on
Twitter. We are happy to discuss ideas and get you involved.
<a href="https://x.com/ghostfolio_">&#64;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>

View File

@ -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
>

View File

@ -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.

View File

@ -89,7 +89,7 @@
>Slack</a
>
community or get in touch on X
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
We look forward to hearing from you.<br />

View File

@ -122,7 +122,7 @@
>
community,
<a
href="https://twitter.com/ghostfolio_"
href="https://x.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>&#64;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)"
>&#64;ghostfolio_</a
>

View File

@ -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)"
>&#64;ghostfolio_</a
>

View File

@ -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)"
>&#64;ghostfolio_</a
>

View File

@ -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>

View File

@ -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"

View File

@ -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 {

View File

@ -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);
});
}

View File

@ -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>

View File

@ -454,30 +454,22 @@ 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);
} else {
this.topHoldingsMap[name] = {
name,
value:
valueInBaseCurrency *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol]
.valueInBaseCurrency
: this.portfolioDetails.holdings[symbol]
.valueInPercentage)
};
}
if (this.topHoldingsMap[name]?.value) {
this.topHoldingsMap[name].value += isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage;
} else {
this.topHoldingsMap[name] = {
name,
value: isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage
};
}
}
}
@ -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) {

View File

@ -253,7 +253,8 @@
} @else {
{{ baseCurrency }}&nbsp;<strong>{{ price }}</strong>
}
&nbsp;<span i18n>per year</span></span
<span>&nbsp;</span>
<span i18n>per year</span></span
>
</p>
@if (

View File

@ -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>

View File

@ -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