Compare commits

...

55 Commits

Author SHA1 Message Date
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
118 changed files with 13426 additions and 3668 deletions

View File

@ -13,7 +13,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node_version: node_version:
- 18 - 20
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4

2
.nvmrc
View File

@ -1 +1 @@
v18 v20

View File

@ -5,6 +5,94 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 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 ## 2.92.0 - 2024-06-30
### Added ### Added
@ -4765,7 +4853,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the attribute `precision` in the value component - Added the attribute `precision` to the value component
### Fixed ### Fixed

View File

@ -10,7 +10,7 @@ Remove permission in `UserService` using `without()`
### Frontend ### Frontend
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Git ## Git

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 # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings RUN yarn database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:18-slim FROM node:20-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio" LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"

View File

@ -7,7 +7,7 @@
**Open Source Wealth Management Software** **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) | [**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: 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) [![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 - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ 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 - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions - ✅ 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 | | Name | Type | Default Value | Description |
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens | | `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_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API | | `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` | | `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 | | `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) | | `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 | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | string | | The host where _Redis_ is running | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | string | | The password of _Redis_ | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | number | | The port where _Redis_ is running | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds | | `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose ### Run with Docker Compose
@ -149,7 +149,7 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 18+) - [Node.js](https://nodejs.org/en/download) (version 20+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone) - 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`) - Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
@ -234,17 +234,17 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
``` ```
| Field | Type | Description | | Field | Type | Description |
| ---------- | ------------------- | ----------------------------------------------------------------------------- | | ------------ | ------------------- | ----------------------------------------------------------------------------- |
| accountId | string (`optional`) | Id of the account | | `accountId` | `string` (optional) | Id of the account |
| comment | string (`optional`) | Comment of the activity | | `comment` | `string` (optional) | Comment of the activity |
| currency | string | `CHF` \| `EUR` \| `USD` etc. | | `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` | | `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` | | `date` | `string` | Date in the format `ISO-8601` |
| fee | number | Fee of the activity | | `fee` | `number` | Fee of the activity |
| quantity | number | Quantity of the activity | | `quantity` | `number` | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) | | `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` | | `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| unitPrice | number | Price per unit of the activity | | `unitPrice` | `number` | Price per unit of the activity |
#### Response #### Response
@ -275,7 +275,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. 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). 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, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag TAG: filtersByTag
} = groupBy(filters, (filter) => { } = groupBy(filters, ({ type }) => {
return filter.type; return type;
}); });
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {

View File

@ -81,10 +81,11 @@ export class AdminController {
@Post('gather/max') @Post('gather/max')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,
@ -107,10 +108,11 @@ export class AdminController {
@Post('gather/profile-data') @Post('gather/profile-data')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,

View File

@ -27,12 +27,13 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource, DataSource,
Prisma, Prisma,
PrismaClient,
Property, Property,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
@ -212,8 +213,11 @@ export class AdminService {
} }
} }
const extendedPrismaClient = this.getExtendedPrismaClient();
try {
let [assetProfiles, count] = await Promise.all([ let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({ extendedPrismaClient.symbolProfile.findMany({
orderBy, orderBy,
skip, skip,
take, take,
@ -229,6 +233,7 @@ export class AdminService {
currency: true, currency: true,
dataSource: true, dataSource: true,
id: true, id: true,
isUsedByUsersWithSubscription: true,
name: true, name: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
@ -243,8 +248,9 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
]); ]);
let marketData: AdminMarketDataItem[] = assetProfiles.map( let marketData: AdminMarketDataItem[] = await Promise.all(
({ assetProfiles.map(
async ({
_count, _count,
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -253,12 +259,15 @@ export class AdminService {
currency, currency,
dataSource, dataSource,
id, id,
isUsedByUsersWithSubscription,
name, name,
Order, Order,
sectors, sectors,
symbol symbol
}) => { }) => {
const countriesCount = countries ? Object.keys(countries).length : 0; const countriesCount = countries
? Object.keys(countries).length
: 0;
const marketDataItemCount = const marketDataItemCount =
marketDataItems.find((marketDataItem) => { marketDataItems.find((marketDataItem) => {
return ( return (
@ -281,9 +290,11 @@ export class AdminService {
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
activitiesCount: _count.Order, activitiesCount: _count.Order,
date: Order?.[0]?.date date: Order?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
}; };
} }
)
); );
if (presetId) { if (presetId) {
@ -304,6 +315,11 @@ export class AdminService {
count, count,
marketData marketData
}; };
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
}
} }
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
@ -431,6 +447,52 @@ export class AdminService {
return response; 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> { private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({ const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true, _count: true,

View File

@ -72,8 +72,12 @@ export class ImportService {
}) })
]); ]);
const accounts = orders.map((order) => { const accounts = orders
return order.Account; .filter(({ Account }) => {
return !!Account;
})
.map(({ Account }) => {
return Account;
}); });
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;

View File

@ -291,7 +291,8 @@ export class OrderService {
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<Activities> { }): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [ let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' } { date: 'asc' },
{ id: 'asc' }
]; ];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
@ -311,10 +312,14 @@ export class OrderService {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag TAG: filtersByTag
} = groupBy(filters, (filter) => { } = groupBy(filters, ({ type }) => {
return filter.type; return type;
}); });
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {
where.accountId = { where.accountId = {
in: filtersByAccount.map(({ id }) => { in: filtersByAccount.map(({ id }) => {
@ -356,6 +361,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) { if (filtersByTag?.length > 0) {
where.tags = { where.tags = {
some: { some: {
@ -367,7 +396,7 @@ export class OrderService {
} }
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
} }
if (types) { if (types) {

View File

@ -300,6 +300,12 @@ export abstract class PortfolioCalculator {
const errors: ResponseError['errors'] = []; const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) { for (const item of lastTransactionPoint.items) {
const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
lastTransactionPoint.date
]
);
const marketPriceInBaseCurrency = ( const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul( ).mul(
@ -340,10 +346,11 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
positions.push({ positions.push({
dividend: totalDividend, feeInBaseCurrency,
dividendInBaseCurrency: totalDividendInBaseCurrency,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
averagePrice: item.averagePrice, averagePrice: item.averagePrice,
currency: item.currency, currency: item.currency,
dataSource: item.dataSource, dataSource: item.dataSource,

View File

@ -168,6 +168,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22', firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'), grossPerformancePercentage: new Big('-0.04408677396780965649'),

View File

@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22', firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'), grossPerformancePercentage: new Big('-0.0440867739678096571'),

View File

@ -138,6 +138,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'), fee: new Big('1.55'),
feeInBaseCurrency: new Big('1.55'),
firstBuyDate: '2021-11-30', firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'), grossPerformancePercentage: new Big('0.09004392386530014641'),

View File

@ -166,6 +166,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2015-01-01', firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'), grossPerformancePercentage: new Big('42.41978276196153750666'),

View File

@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('49'), fee: new Big('49'),
feeInBaseCurrency: new Big('49'),
firstBuyDate: '2021-09-01', firstBuyDate: '2021-09-01',
grossPerformance: null, grossPerformance: null,
grossPerformancePercentage: null, grossPerformancePercentage: null,

View File

@ -151,6 +151,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('1'), fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03', firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33'), grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'), grossPerformancePercentage: new Big('0.3066651705565529623'),
@ -177,7 +178,7 @@ describe('PortfolioCalculator', () => {
valueInBaseCurrency: new Big('103.10483') valueInBaseCurrency: new Big('103.10483')
} }
], ],
totalFeesWithCurrencyEffect: new Big('1'), totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12'), totalInvestment: new Big('89.12'),
totalInvestmentWithCurrencyEffect: new Big('82.329056'), totalInvestmentWithCurrencyEffect: new Big('82.329056'),

View File

@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01', firstBuyDate: '2022-01-01',
grossPerformance: null, grossPerformance: null,
grossPerformancePercentage: null, grossPerformancePercentage: null,

View File

@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'), fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'), grossPerformancePercentage: new Big('0.15113417083448194384'),

View File

@ -183,6 +183,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'), grossPerformancePercentage: new Big('0.13100263852242744063'),

View File

@ -34,9 +34,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) { for (const currentPosition of positions) {
if (currentPosition.fee) { if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.fee currentPosition.feeInBaseCurrency
); );
} }

View File

@ -496,9 +496,6 @@ export class PortfolioController {
@Param('accessId') accessId @Param('accessId') accessId
): Promise<PortfolioPublicDetails> { ): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId }); const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
id: access.userId
});
if (!access) { if (!access) {
throw new HttpException( throw new HttpException(
@ -508,6 +505,11 @@ export class PortfolioController {
} }
let hasDetails = true; let hasDetails = true;
const user = await this.userService.user({
id: access.userId
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium'; hasDetails = user.subscription.type === 'Premium';
} }

View File

@ -499,7 +499,17 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings, holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
investment: investment.toNumber(), investment: investment.toNumber(),
marketState: dataProviderResponse?.marketState ?? 'delayed', marketState: dataProviderResponse?.marketState ?? 'delayed',
name: assetProfile.name, name: assetProfile.name,

View File

@ -237,6 +237,7 @@ export class UserService {
currentPermissions = without( currentPermissions = without(
currentPermissions, currentPermissions,
permissions.accessHoldingsChart,
permissions.createAccess permissions.createAccess
); );

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" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> 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> <url>
<loc>https://ghostfol.io/de</loc> <loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -55,10 +55,10 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0; const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
if (maxInvestmentRatio > ruleSettings.threshold) { if (maxInvestmentRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `Over ${ evaluation: `Over ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}% of your current investment is at ${maxItem.name} (${( }% of your current investment is at ${maxItem.name} (${(
maxInvestmentRatio * 100 maxInvestmentRatio * 100
).toPrecision(3)}%)`, ).toPrecision(3)}%)`,
@ -70,7 +70,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
evaluation: `The major part of your current investment is at ${ evaluation: `The major part of your current investment is at ${
maxItem.name maxItem.name
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${ } (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}%`, }%`,
value: true value: true
}; };
@ -80,12 +80,12 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true, isActive: true,
threshold: 0.5 thresholdMax: 0.5
}; };
} }
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: string; baseCurrency: string;
threshold: number; thresholdMax: number;
} }

View File

@ -41,10 +41,10 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
const maxValueRatio = maxItem?.value / totalValue || 0; const maxValueRatio = maxItem?.value / totalValue || 0;
if (maxValueRatio > ruleSettings.threshold) { if (maxValueRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `Over ${ evaluation: `Over ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}% of your current investment is in ${maxItem.groupKey} (${( }% of your current investment is in ${maxItem.groupKey} (${(
maxValueRatio * 100 maxValueRatio * 100
).toPrecision(3)}%)`, ).toPrecision(3)}%)`,
@ -56,7 +56,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
evaluation: `The major part of your current investment is in ${ evaluation: `The major part of your current investment is in ${
maxItem?.groupKey ?? ruleSettings.baseCurrency maxItem?.groupKey ?? ruleSettings.baseCurrency
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${ } (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}%`, }%`,
value: true value: true
}; };
@ -66,12 +66,12 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true, isActive: true,
threshold: 0.5 thresholdMax: 0.5
}; };
} }
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: string; baseCurrency: string;
threshold: number; thresholdMax: number;
} }

View File

@ -19,16 +19,16 @@ export class EmergencyFundSetup extends Rule<Settings> {
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
if (this.emergencyFund > ruleSettings.threshold) { if (this.emergencyFund < ruleSettings.thresholdMin) {
return { return {
evaluation: 'An emergency fund has been set up', evaluation: 'No emergency fund has been set up',
value: true value: false
}; };
} }
return { return {
evaluation: 'No emergency fund has been set up', evaluation: 'An emergency fund has been set up',
value: false value: true
}; };
} }
@ -36,12 +36,12 @@ export class EmergencyFundSetup extends Rule<Settings> {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true, isActive: true,
threshold: 0 thresholdMin: 0
}; };
} }
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: string; baseCurrency: string;
threshold: number; thresholdMin: number;
} }

View File

@ -26,10 +26,10 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
? this.fees / this.totalInvestment ? this.fees / this.totalInvestment
: 0; : 0;
if (feeRatio > ruleSettings.threshold) { if (feeRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `The fees do exceed ${ evaluation: `The fees do exceed ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`, }% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
value: false value: false
}; };
@ -37,7 +37,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
return { return {
evaluation: `The fees do not exceed ${ evaluation: `The fees do not exceed ${
ruleSettings.threshold * 100 ruleSettings.thresholdMax * 100
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`, }% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
value: true value: true
}; };
@ -47,12 +47,12 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true, isActive: true,
threshold: 0.01 thresholdMax: 0.01
}; };
} }
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: string; baseCurrency: string;
threshold: number; thresholdMax: number;
} }

View File

@ -45,10 +45,11 @@ export class CronService {
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME) @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
public async runEverySundayAtTwelvePm() { public async runEverySundayAtTwelvePm() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,

View File

@ -10,6 +10,7 @@ import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
@ -62,9 +63,22 @@ export class DataGatheringService {
} }
public async gather7Days() { public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D();
await this.gatherSymbols({ 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 priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
}); });
} }
@ -138,7 +152,7 @@ export class DataGatheringService {
}); });
if (!uniqueAssets) { if (!uniqueAssets) {
uniqueAssets = await this.getUniqueAssets(); uniqueAssets = await this.getAllAssetProfileIdentifiers();
} }
if (uniqueAssets.length <= 0) { 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({ const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }] orderBy: [{ symbol: 'asc' }]
}); });
@ -290,73 +304,83 @@ export class DataGatheringService {
}); });
} }
private getEarliestDate(aStartDate: Date) { private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise<
return min([aStartDate, subYears(new Date(), 10)]); UniqueAsset[]
} > {
return (
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 = (
await this.prismaService.marketData.groupBy({ await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['symbol'], by: ['dataSource', 'symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
where: { where: {
date: { gt: startDate }, date: { gt: subDays(resetHours(new Date()), 7) },
state: 'CLOSE' state: 'CLOSE'
} }
}) })
) )
.filter((group) => { .filter(({ _count }) => {
return group._count >= 6; return _count >= 6;
}) })
.map((group) => { .map(({ dataSource, symbol }) => {
return group.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 }) => { .filter(({ dataSource, scraperConfiguration, symbol }) => {
const manualDataSourceWithScraperConfiguration = const manualDataSourceWithScraperConfiguration =
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return ( return (
!symbolsWithCompleteMarketData.includes(symbol) && !assetProfileIdentifiersWithCompleteMarketData.some((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
}) &&
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
); );
}) })
.map((symbolProfile) => { .map((symbolProfile) => {
return { return {
...symbolProfile, ...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[]> { private async getSymbolsMax(): Promise<IDataGatheringItem[]> {

View File

@ -14,7 +14,12 @@ import {
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
PROPERTY_DATA_SOURCE_MAPPING PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config'; } 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 { UniqueAsset } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
@ -423,13 +428,18 @@ export class DataProviderService {
continue; continue;
} }
const symbols = dataGatheringItems.map((dataGatheringItem) => { const symbols = dataGatheringItems
return dataGatheringItem.symbol; .filter(({ symbol }) => {
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
})
.map(({ symbol }) => {
return symbol;
}); });
const maximumNumberOfSymbolsPerRequest = const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER; Number.MAX_SAFE_INTEGER;
for ( for (
let i = 0; let i = 0;
i < symbols.length; i < symbols.length;

View File

@ -246,7 +246,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
for (const { close, code, timestamp } of quotes) { for (const { close, code, timestamp } of quotes) {
let currency: string; let currency: string;
if (code.endsWith('.FOREX')) { if (this.isForex(code)) {
currency = this.convertFromEodSymbol(code)?.replace( currency = this.convertFromEodSymbol(code)?.replace(
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
'' ''
@ -272,7 +272,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
currency, currency,
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: close, marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed' marketState:
this.isForex(code) || isToday(new Date(timestamp * 1000))
? 'open'
: 'closed'
}; };
} else { } else {
Logger.error( Logger.error(
@ -311,7 +314,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
items: searchResult items: searchResult
.filter(({ currency, symbol }) => { .filter(({ currency, symbol }) => {
// Remove 'NA' currency and exchange rates // Remove 'NA' currency and exchange rates
return currency?.length === 3 && !symbol.endsWith('.FOREX'); return currency?.length === 3 && !this.isForex(symbol);
}) })
.map( .map(
({ ({
@ -349,7 +352,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
private convertFromEodSymbol(aEodSymbol: string) { private convertFromEodSymbol(aEodSymbol: string) {
let symbol = aEodSymbol; let symbol = aEodSymbol;
if (symbol.endsWith('.FOREX')) { if (this.isForex(symbol)) {
symbol = symbol.replace('GBX', 'GBp'); symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', ''); symbol = symbol.replace('.FOREX', '');
} }
@ -451,6 +454,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
return searchResult; return searchResult;
} }
private isForex(aCode: string) {
return aCode?.endsWith('.FOREX') || false;
}
private parseAssetClass({ private parseAssetClass({
Exchange, Exchange,
Type Type

View File

@ -257,7 +257,7 @@ export class ManualService implements DataProviderInterface {
signal: abortController.signal signal: abortController.signal
}); });
if (headers['content-type'] === 'application/json') { if (headers['content-type'].includes('application/json')) {
const data = JSON.parse(body); const data = JSON.parse(body);
const value = String( const value = String(
jsonpath.query(data, scraperConfiguration.selector)[0] 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({ public updateSymbolProfile({
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -221,8 +255,9 @@ export class SymbolProfileService {
const { name, weight } = holding as Prisma.JsonObject; const { name, weight } = holding as Prisma.JsonObject;
return { return {
allocationInPercentage: weight as number,
name: (name as string) ?? UNKNOWN_KEY, 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); await this.twitterClient.v2.tweet(status);
Logger.log( 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' 'TwitterBotService'
); );
} }

View File

@ -36,6 +36,10 @@
"ngswConfigPath": "apps/client/ngsw-config.json" "ngswConfigPath": "apps/client/ngsw-config.json"
}, },
"configurations": { "configurations": {
"development-ca": {
"baseHref": "/ca/",
"localize": ["ca"]
},
"development-de": { "development-de": {
"baseHref": "/de/", "baseHref": "/de/",
"localize": ["de"] "localize": ["de"]
@ -212,6 +216,7 @@
"includeContext": true, "includeContext": true,
"outputPath": "src/locales", "outputPath": "src/locales",
"targetFiles": [ "targetFiles": [
"messages.ca.xlf",
"messages.de.xlf", "messages.de.xlf",
"messages.es.xlf", "messages.es.xlf",
"messages.fr.xlf", "messages.fr.xlf",
@ -240,6 +245,10 @@
}, },
"i18n": { "i18n": {
"locales": { "locales": {
"ca": {
"baseHref": "/ca/",
"translation": "apps/client/src/locales/messages.ca.xlf"
},
"de": { "de": {
"baseHref": "/de/", "baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf" "translation": "apps/client/src/locales/messages.de.xlf"

View File

@ -138,13 +138,18 @@
<li> <li>
<a <a
class="align-items-baseline d-flex" class="align-items-baseline d-flex"
href="https://twitter.com/ghostfolio_" href="https://x.com/ghostfolio_"
target="_blank" target="_blank"
title="Follow Ghostfolio on X (formerly Twitter)" title="Follow Ghostfolio on X (formerly Twitter)"
>X (formerly Twitter)<ion-icon class="ml-1" name="open-outline" >X (formerly Twitter)<ion-icon class="ml-1" name="open-outline"
/></a> /></a>
</li> </li>
<li>&nbsp;</li> <li>&nbsp;</li>
<!--
<li>
<a href="../ca" title="Ghostfolio en català">Català</a>
</li>
-->
<li> <li>
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a> <a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
</li> </li>

View File

@ -6,8 +6,14 @@ import {
ghostfolioScraperApiSymbolPrefix ghostfolioScraperApiSymbolPrefix
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; 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 { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { SelectionModel } from '@angular/cdk/collections'; import { SelectionModel } from '@angular/cdk/collections';
@ -97,22 +103,11 @@ export class AdminMarketDataComponent
new MatTableDataSource(); new MatTableDataSource();
public defaultDateFormat: string; public defaultDateFormat: string;
public deviceType: string; public deviceType: string;
public displayedColumns = [ public displayedColumns: string[] = [];
'select',
'nameWithSymbol',
'dataSource',
'assetClass',
'assetSubClass',
'date',
'activitiesCount',
'marketDataItemCount',
'sectorsCount',
'countriesCount',
'comment',
'actions'
];
public filters$ = new Subject<Filter[]>(); public filters$ = new Subject<Filter[]>();
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public isLoading = false; public isLoading = false;
public isUUID = isUUID; public isUUID = isUUID;
public placeholder = ''; public placeholder = '';
@ -124,7 +119,7 @@ export class AdminMarketDataComponent
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminMarketDataService: AdminMarketDataService, public adminMarketDataService: AdminMarketDataService,
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
@ -134,6 +129,33 @@ export class AdminMarketDataComponent
private router: Router, private router: Router,
private userService: UserService 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 this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {

View File

@ -24,11 +24,11 @@
<th *matHeaderCellDef class="px-1" mat-header-cell></th> <th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if ( @if (
!( adminMarketDataService.hasPermissionToDeleteAssetProfile({
element.activitiesCount !== 0 || activitiesCount: element.activitiesCount,
element.isBenchmark || isBenchmark: element.isBenchmark,
element.symbol.startsWith(ghostfolioScraperApiSymbolPrefix) symbol: element.symbol
) })
) { ) {
<mat-checkbox <mat-checkbox
color="primary" color="primary"
@ -144,6 +144,15 @@
</td> </td>
</ng-container> </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"> <ng-container matColumnDef="comment">
<th *matHeaderCellDef class="px-1" mat-header-cell></th> <th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@ -178,7 +187,7 @@
[disabled]="!selection.hasValue()" [disabled]="!selection.hasValue()"
(click)="onDeleteAssetProfiles()" (click)="onDeleteAssetProfiles()"
> >
<ng-container i18n>Delete Asset Profiles</ng-container> <ng-container i18n>Delete Profiles</ng-container>
</button> </button>
</mat-menu> </mat-menu>
</th> </th>
@ -209,9 +218,11 @@
<button <button
mat-menu-item mat-menu-item
[disabled]=" [disabled]="
element.activitiesCount !== 0 || !adminMarketDataService.hasPermissionToDeleteAssetProfile({
element.isBenchmark || activitiesCount: element.activitiesCount,
element.symbol.startsWith(ghostfolioScraperApiSymbolPrefix) isBenchmark: element.isBenchmark,
symbol: element.symbol
})
" "
(click)=" (click)="
onDeleteAssetProfile({ onDeleteAssetProfile({

View File

@ -1,5 +1,6 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -24,6 +25,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
GfActivitiesFilterComponent, GfActivitiesFilterComponent,
GfAssetProfileDialogModule, GfAssetProfileDialogModule,
GfCreateAssetProfileDialogModule, GfCreateAssetProfileDialogModule,
GfPremiumIndicatorComponent,
GfSymbolModule, GfSymbolModule,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,

View File

@ -1,5 +1,10 @@
import { AdminService } from '@ghostfolio/client/services/admin.service'; 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 { Injectable } from '@angular/core';
import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs'; import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs';
@ -26,7 +31,7 @@ export class AdminMarketDataService {
public deleteAssetProfiles(uniqueAssets: UniqueAsset[]) { public deleteAssetProfiles(uniqueAssets: UniqueAsset[]) {
const confirmation = confirm( const confirmation = confirm(
$localize`Do you really want to delete these asset profiles?` $localize`Do you really want to delete these profiles?`
); );
if (confirmation) { if (confirmation) {
@ -37,7 +42,7 @@ export class AdminMarketDataService {
forkJoin(deleteRequests) forkJoin(deleteRequests)
.pipe( .pipe(
catchError(() => { catchError(() => {
alert($localize`Oops! Could not delete asset profiles.`); alert($localize`Oops! Could not delete profiles.`);
return EMPTY; return EMPTY;
}), }),
@ -50,4 +55,17 @@ export class AdminMarketDataService {
.subscribe(() => {}); .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>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminMarketDataService: AdminMarketDataService, public adminMarketDataService: AdminMarketDataService,
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams, @Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,

View File

@ -48,9 +48,11 @@
mat-menu-item mat-menu-item
type="button" type="button"
[disabled]=" [disabled]="
assetProfile?.activitiesCount !== 0 || !adminMarketDataService.hasPermissionToDeleteAssetProfile({
isBenchmark || activitiesCount: assetProfile?.activitiesCount,
data.symbol.startsWith(ghostfolioScraperApiSymbolPrefix) isBenchmark: isBenchmark,
symbol: data.symbol
})
" "
(click)=" (click)="
onDeleteProfileData({ onDeleteProfileData({

View File

@ -12,11 +12,7 @@
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>User Count</div> <div class="w-50" i18n>User Count</div>
<div class="w-50"> <div class="w-50">
<gf-value <gf-value [locale]="user?.settings?.locale" [value]="userCount" />
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userCount"
/>
</div> </div>
</div> </div>
<div class="d-flex my-3"> <div class="d-flex my-3">
@ -24,7 +20,6 @@
<div class="w-50"> <div class="w-50">
<gf-value <gf-value
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="0"
[value]="transactionCount" [value]="transactionCount"
/> />
@if (transactionCount && userCount) { @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 { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
@ -84,18 +85,22 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dataProviderInfo: DataProviderInfo; public dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public dividendInBaseCurrency: number; public dividendInBaseCurrency: number;
public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number; public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public firstBuyDate: string; public firstBuyDate: string;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
public investmentPrecision = 2;
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
public netPerformance: number; public netPerformance: number;
public netPerformancePrecision = 2;
public netPerformancePercent: number; public netPerformancePercent: number;
public netPerformancePercentWithCurrencyEffect: number; public netPerformancePercentWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffect: number; public netPerformanceWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffectPrecision = 2;
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public reportDataGlitchMail: string; public reportDataGlitchMail: string;
@ -161,10 +166,20 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dataProviderInfo = dataProviderInfo; this.dataProviderInfo = dataProviderInfo;
this.dataSource = new MatTableDataSource(orders.reverse()); this.dataSource = new MatTableDataSource(orders.reverse());
this.dividendInBaseCurrency = dividendInBaseCurrency; this.dividendInBaseCurrency = dividendInBaseCurrency;
if (
this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD
) {
this.dividendInBaseCurrencyPrecision = 0;
}
this.dividendYieldPercentWithCurrencyEffect = this.dividendYieldPercentWithCurrencyEffect =
dividendYieldPercentWithCurrencyEffect; dividendYieldPercentWithCurrencyEffect;
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
({ averagePrice, date, marketPrice }) => { ({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
@ -178,17 +193,58 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}; };
} }
); );
this.investment = investment; this.investment = investment;
if (
this.data.deviceType === 'mobile' &&
this.investment >= NUMERICAL_PRECISION_THRESHOLD
) {
this.investmentPrecision = 0;
}
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.maxPrice = maxPrice; this.maxPrice = maxPrice;
this.minPrice = minPrice; this.minPrice = minPrice;
this.netPerformance = netPerformance; this.netPerformance = netPerformance;
if (
this.data.deviceType === 'mobile' &&
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD
) {
this.netPerformancePrecision = 0;
}
this.netPerformancePercent = netPerformancePercent; this.netPerformancePercent = netPerformancePercent;
this.netPerformancePercentWithCurrencyEffect = this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect; netPerformancePercentWithCurrencyEffect;
this.netPerformanceWithCurrencyEffect = this.netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect; netPerformanceWithCurrencyEffect;
if (
this.data.deviceType === 'mobile' &&
this.netPerformanceWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD
) {
this.netPerformanceWithCurrencyEffectPrecision = 0;
}
this.quantity = quantity; 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.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.sectors = {};
this.SymbolProfile = SymbolProfile; this.SymbolProfile = SymbolProfile;
@ -282,18 +338,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(); this.changeDetectorRef.markForCheck();
} }
); );

View File

@ -47,6 +47,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="netPerformanceWithCurrencyEffectPrecision"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="netPerformanceWithCurrencyEffect" [value]="netPerformanceWithCurrencyEffect"
>Change with currency effect</gf-value >Change with currency effect</gf-value
@ -58,6 +59,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="netPerformancePrecision"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="netPerformance" [value]="netPerformance"
>Change</gf-value >Change</gf-value
@ -160,6 +162,7 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="investmentPrecision"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="investment" [value]="investment"
>Investment</gf-value >Investment</gf-value
@ -172,6 +175,7 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="dividendInBaseCurrencyPrecision"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="dividendInBaseCurrency" [value]="dividendInBaseCurrency"
>Dividend</gf-value >Dividend</gf-value

View File

@ -1,11 +1,21 @@
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; import {
PortfolioPosition,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HoldingType, ToggleOption } from '@ghostfolio/common/types'; import {
HoldingType,
HoldingViewMode,
ToggleOption
} from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; 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 { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -18,6 +28,7 @@ import { takeUntil } from 'rxjs/operators';
export class HomeHoldingsComponent implements OnDestroy, OnInit { export class HomeHoldingsComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToAccessHoldingsChart: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public holdings: PortfolioPosition[]; public holdings: PortfolioPosition[];
public holdingType: HoldingType = 'ACTIVE'; public holdingType: HoldingType = 'ACTIVE';
@ -26,6 +37,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
{ label: $localize`Closed`, value: 'CLOSED' } { label: $localize`Closed`, value: 'CLOSED' }
]; ];
public user: User; public user: User;
public viewModeFormControl = new FormControl<HoldingViewMode>('TABLE');
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -34,6 +46,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private router: Router,
private userService: UserService private userService: UserService
) {} ) {}
@ -53,20 +66,17 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToAccessHoldingsChart = hasPermission(
this.user.permissions,
permissions.accessHoldingsChart
);
this.hasPermissionToCreateOrder = hasPermission( this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions, this.user.permissions,
permissions.createOrder permissions.createOrder
); );
this.holdings = undefined; this.initialize();
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
@ -76,16 +86,16 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
public onChangeHoldingType(aHoldingType: HoldingType) { public onChangeHoldingType(aHoldingType: HoldingType) {
this.holdingType = aHoldingType; this.holdingType = aHoldingType;
this.holdings = undefined; this.initialize();
}
this.fetchHoldings() public onSymbolClicked({ dataSource, symbol }: UniqueAsset) {
.pipe(takeUntil(this.unsubscribeSubject)) if (dataSource && symbol) {
.subscribe(({ holdings }) => { this.router.navigate([], {
this.holdings = holdings; queryParams: { dataSource, symbol, holdingDetailDialog: true }
this.changeDetectorRef.markForCheck();
}); });
} }
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
@ -104,4 +114,27 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}); });
} }
private initialize() {
this.viewModeFormControl.disable();
if (
this.hasPermissionToAccessHoldingsChart &&
this.holdingType === 'ACTIVE'
) {
this.viewModeFormControl.enable();
} else if (this.holdingType === 'CLOSED') {
this.viewModeFormControl.setValue('TABLE');
}
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
}
} }

View File

@ -6,7 +6,25 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<div class="d-flex justify-content-end"> <div class="d-flex">
@if (user?.settings?.isExperimentalFeatures) {
<div class="d-flex">
<div class="d-none d-lg-block">
<mat-button-toggle-group
[formControl]="viewModeFormControl"
[hideSingleSelectionIndicator]="true"
>
<mat-button-toggle i18n-title title="Table" value="TABLE">
<ion-icon name="reorder-four-outline" />
</mat-button-toggle>
<mat-button-toggle i18n-title title="Chart" value="CHART">
<ion-icon name="grid-outline" />
</mat-button-toggle>
</mat-button-toggle-group>
</div>
</div>
}
<div class="align-items-center d-flex flex-grow-1 justify-content-end">
<gf-toggle <gf-toggle
class="d-none d-lg-block" class="d-none d-lg-block"
[defaultValue]="holdingType" [defaultValue]="holdingType"
@ -15,6 +33,16 @@
(change)="onChangeHoldingType($event.value)" (change)="onChangeHoldingType($event.value)"
/> />
</div> </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 <gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
@ -35,4 +63,5 @@
} }
</div> </div>
</div> </div>
</div>
</div> </div>

View File

@ -1,9 +1,12 @@
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfTreemapChartComponent } from '@ghostfolio/ui/treemap-chart';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { HomeHoldingsComponent } from './home-holdings.component'; import { HomeHoldingsComponent } from './home-holdings.component';
@ -12,9 +15,13 @@ import { HomeHoldingsComponent } from './home-holdings.component';
declarations: [HomeHoldingsComponent], declarations: [HomeHoldingsComponent],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
GfHoldingsTableComponent, GfHoldingsTableComponent,
GfToggleModule, GfToggleModule,
GfTreemapChartComponent,
MatButtonModule, MatButtonModule,
MatButtonToggleModule,
ReactiveFormsModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -1,3 +1,9 @@
:host { :host {
display: block; 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 { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
import { import {
LineChartItem, LineChartItem,
PortfolioPerformance, PortfolioPerformance,
@ -34,6 +35,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public isAllTimeLow: boolean; public isAllTimeLow: boolean;
public isLoadingPerformance = true; public isLoadingPerformance = true;
public performance: PortfolioPerformance; public performance: PortfolioPerformance;
public precision = 2;
public showDetails = false; public showDetails = false;
public unit: string; public unit: string;
public user: User; public user: User;
@ -67,6 +69,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; 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 this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -81,12 +89,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
.subscribe(() => { .subscribe(() => {
this.update(); 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) { 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.isLoadingPerformance = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

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

View File

@ -14,7 +14,6 @@ import {
ElementRef, ElementRef,
Input, Input,
OnChanges, OnChanges,
OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { CountUp } from 'countup.js'; import { CountUp } from 'countup.js';
@ -26,7 +25,7 @@ import { isNumber } from 'lodash';
templateUrl: './portfolio-performance.component.html', templateUrl: './portfolio-performance.component.html',
styleUrls: ['./portfolio-performance.component.scss'] styleUrls: ['./portfolio-performance.component.scss']
}) })
export class PortfolioPerformanceComponent implements OnChanges, OnInit { export class PortfolioPerformanceComponent implements OnChanges {
@Input() deviceType: string; @Input() deviceType: string;
@Input() errors: ResponseError['errors']; @Input() errors: ResponseError['errors'];
@Input() isAllTimeHigh: boolean; @Input() isAllTimeHigh: boolean;
@ -34,6 +33,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() performance: PortfolioPerformance; @Input() performance: PortfolioPerformance;
@Input() precision: number;
@Input() showDetails: boolean; @Input() showDetails: boolean;
@Input() unit: string; @Input() unit: string;
@ -41,9 +41,9 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
public constructor() {} public constructor() {}
public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.precision = this.precision >= 0 ? this.precision : 2;
if (this.isLoading) { if (this.isLoading) {
if (this.value?.nativeElement) { if (this.value?.nativeElement) {
this.value.nativeElement.innerHTML = ''; this.value.nativeElement.innerHTML = '';
@ -52,11 +52,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
if (isNumber(this.performance?.currentValueInBaseCurrency)) { if (isNumber(this.performance?.currentValueInBaseCurrency)) {
new CountUp('value', this.performance?.currentValueInBaseCurrency, { new CountUp('value', this.performance?.currentValueInBaseCurrency, {
decimal: getNumberFormatDecimal(this.locale), decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: decimalPlaces: this.precision,
this.deviceType === 'mobile' &&
this.performance?.currentValueInBaseCurrency >= 100000
? 0
: 2,
duration: 1, duration: 1,
separator: getNumberFormatGroup(this.locale) separator: getNumberFormatGroup(this.locale)
}).start(); }).start();

View File

@ -47,6 +47,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
public isWebAuthnEnabled: boolean; public isWebAuthnEnabled: boolean;
public language = document.documentElement.lang; public language = document.documentElement.lang;
public locales = [ public locales = [
'ca',
'de', 'de',
'de-CH', 'de-CH',
'en-GB', 'en-GB',

View File

@ -70,6 +70,14 @@
" "
> >
<mat-option [value]="null" /> <mat-option [value]="null" />
@if (user?.settings?.isExperimentalFeatures) {
<!--
<mat-option value="de"
>Català (<ng-container i18n>Community</ng-container
>)</mat-option
>
-->
}
<mat-option value="de">Deutsch</mat-option> <mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option> <mat-option value="en">English</mat-option>
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
@ -95,10 +103,12 @@
>)</mat-option >)</mat-option
> >
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<!--
<mat-option value="pl" <mat-option value="pl"
>Polski (<ng-container i18n>Community</ng-container >Polski (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option
> >
-->
} }
<mat-option value="pt" <mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container >Português (<ng-container i18n>Community</ng-container
@ -196,7 +206,6 @@
/> />
</div> </div>
</div> </div>
@if (hasPermissionToUpdateUserSettings) {
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50"> <div class="pr-1 w-50">
<div i18n>Experimental Features</div> <div i18n>Experimental Features</div>
@ -214,7 +223,6 @@
/> />
</div> </div>
</div> </div>
}
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50"> <div class="pr-1 w-50">
Ghostfolio <ng-container i18n>User ID</ng-container> Ghostfolio <ng-container i18n>User ID</ng-container>

View File

@ -55,7 +55,7 @@
> >
community, post to community, post to
<a <a
href="https://twitter.com/ghostfolio_" href="https://x.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)" title="Post to Ghostfolio on X (formerly Twitter)"
>&#64;ghostfolio_</a >&#64;ghostfolio_</a
> >
@ -75,7 +75,7 @@
<p class="align-items-center d-flex justify-content-center"> <p class="align-items-center d-flex justify-content-center">
<a <a
class="mx-2" class="mx-2"
href="https://twitter.com/ghostfolio_" href="https://x.com/ghostfolio_"
mat-icon-button mat-icon-button
title="Follow Ghostfolio on X (formerly Twitter)" title="Follow Ghostfolio on X (formerly Twitter)"
> >

View File

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

View File

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

View File

@ -39,7 +39,7 @@
</p> </p>
<p> <p>
At the end of 2021, Ghostfolio reached an important milestone: 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 >100 stars</a
> >
on GitHub. This is really exciting with almost no marketing. I am 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 of users. In the future, I would like to involve more contributors
to further extend the functionality of Ghostfolio (e.g. with new to further extend the functionality of Ghostfolio (e.g. with new
reports). Get in touch with me by e-mail at 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="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a> if (formerly Twitter)
you are interested, Im happy to discuss ideas. <a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a> if you are
interested, Im happy to discuss ideas.
</p> </p>
<p> <p>
I would like to say thank you for all your feedback and support I would like to say thank you for all your feedback and support

View File

@ -90,8 +90,9 @@
<p> <p>
If you would like to provide feedback or get involved in further If you would like to provide feedback or get involved in further
development of Ghostfolio, please get in touch by e-mail via 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="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>. (formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>.
</p> </p>
<p> <p>
I look forward to hearing from you.<br /> I look forward to hearing from you.<br />

View File

@ -34,9 +34,9 @@
>Slack</a >Slack</a
> >
as well as 100 followers on as well as 100 followers on
<a href="https://twitter.com/ghostfolio_">Twitter</a>. If you have <a href="https://x.com/ghostfolio_">Twitter</a>. If you have not
not joined yet, this is a good time to make sure you do not miss out joined yet, this is a good time to make sure you do not miss out on
on any future updates. any future updates.
</p> </p>
</section> </section>
<section class="mb-4"> <section class="mb-4">
@ -91,9 +91,10 @@
engineering to realize the full potential of open source software. engineering to realize the full potential of open source software.
If you are a web developer and interested in personal finance, If you are a web developer and interested in personal finance,
please get in touch by e-mail via please get in touch by e-mail via
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on Twitter <a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>. We (formerly Twitter)
are happy to discuss ideas. <a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>. We are
happy to discuss ideas.
</p> </p>
<p> <p>
We would like to say thank you for all your feedback and support 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" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>Slack</a >Slack</a
> >
community or get in touch on Twitter community or get in touch on X (formerly Twitter)
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a> or by <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>. e-mail via <a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a>.
</p> </p>
<p> <p>

View File

@ -90,8 +90,8 @@
target="_blank" target="_blank"
>Slack</a >Slack</a
> >
community or via Twitter community or via X (formerly Twitter)
<a href="https://twitter.com/ghostfolio_" target="_blank" <a href="https://x.com/ghostfolio_" target="_blank"
>&#64;ghostfolio_</a >&#64;ghostfolio_</a
>. We look forward to hearing from you! >. We look forward to hearing from you!
</p> </p>

View File

@ -122,8 +122,9 @@
>Slack</a >Slack</a
> >
community or connect with community or connect with
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a> on <a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a> on X
Twitter. We are happy to discuss ideas and get you involved. (formerly Twitter). We are happy to discuss ideas and get you
involved.
</p> </p>
<p>Thank you for all your feedback and support.</p> <p>Thank you for all your feedback and support.</p>
<p> <p>

View File

@ -25,7 +25,7 @@
<p> <p>
OSS Friends started as a simple OSS Friends started as a simple
<a <a
href="https://twitter.com/formbricks/status/1660735970281508878" href="https://x.com/formbricks/status/1660735970281508878"
target="_blank" target="_blank"
>post</a >post</a
> >

View File

@ -123,7 +123,7 @@
</li> </li>
<li> <li>
On On
<a href="https://twitter.com/ghostfolio_" target="_blank">X</a> <a href="https://x.com/ghostfolio_" target="_blank">X</a>
(formerly Twitter), over (formerly Twitter), over
<strong>300 investors and personal finance enthusiasts</strong> <strong>300 investors and personal finance enthusiasts</strong>
follow Ghostfolio, keen to stay updated on the latest follow Ghostfolio, keen to stay updated on the latest
@ -151,7 +151,7 @@
<p> <p>
<strong>Follow us on X</strong>: For release updates and market <strong>Follow us on X</strong>: For release updates and market
insights, follow insights, follow
<a href="https://twitter.com/ghostfolio_" target="_blank" <a href="https://x.com/ghostfolio_" target="_blank"
>Ghostfolio on X</a >Ghostfolio on X</a
>. It is the perfect place to stay informed and connect with our >. It is the perfect place to stay informed and connect with our
team. team.

View File

@ -89,7 +89,7 @@
>Slack</a >Slack</a
> >
community or get in touch on X 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>
<p> <p>
We look forward to hearing from you.<br /> We look forward to hearing from you.<br />

View File

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

View File

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

View File

@ -86,6 +86,17 @@
</ol> </ol>
</mat-card-content> </mat-card-content>
</mat-card> </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 appearance="outlined" class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title>How do I add a new platform?</mat-card-title> <mat-card-title>How do I add a new platform?</mat-card-title>
@ -186,7 +197,7 @@
>Slack </a >Slack </a
>community, post to >community, post to
<a <a
href="https://twitter.com/ghostfolio_" href="https://x.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)" title="Post to Ghostfolio on X (formerly Twitter)"
>&#64;ghostfolio_</a >&#64;ghostfolio_</a
> >

View File

@ -145,8 +145,9 @@
</h4> </h4>
<p class="m-0"> <p class="m-0">
Check the rate of return of your portfolio for Check the rate of return of your portfolio for
<code>Today</code>, <code>YTD</code>, <code>1Y</code>, <code>Today</code>, <code>WTD</code>, <code>MTD</code>,
<code>5Y</code>, and <code>Max</code>. <code>YTD</code>, <code>1Y</code>, <code>5Y</code>, and
<code>Max</code>.
</p> </p>
</div> </div>
</mat-card-content> </mat-card-content>
@ -241,9 +242,11 @@
<h4 i18n>Multi-Language</h4> <h4 i18n>Multi-Language</h4>
<p class="m-0"> <p class="m-0">
Use Ghostfolio in multiple languages: English, Use Ghostfolio in multiple languages: English,
<!-- Chinese, -->Dutch, French, German, Italian, <!--Català, -->
<!-- Polish, -->Portuguese, Spanish and Turkish are currently <!-- Chinese, -->
supported. Dutch, French, German, Italian,
<!-- Polish, -->
Portuguese, Spanish and Turkish are currently supported.
</p> </p>
</div> </div>
</mat-card-content> </mat-card-content>

View File

@ -186,6 +186,14 @@
title="Sackgeld.com Apps für ein höheres Sackgeld" title="Sackgeld.com Apps für ein höheres Sackgeld"
></a> ></a>
</div> </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"> <div class="col-md-3 d-flex justify-content-center my-1">
<a <a
class="d-block logo logo-sourceforge mask" class="d-block logo logo-sourceforge mask"

View File

@ -80,6 +80,11 @@
mask-image: url('/assets/images/logo-sackgeld.png'); 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 { &.logo-sourceforge {
mask-image: url('/assets/images/logo-sourceforge.svg'); mask-image: url('/assets/images/logo-sourceforge.svg');
} }
@ -131,6 +136,7 @@
&.logo-privacy-tools, &.logo-privacy-tools,
&.logo-reddit, &.logo-reddit,
&.logo-sackgeld, &.logo-sackgeld,
&.logo-selfh-st,
&.logo-sourceforge, &.logo-sourceforge,
&.logo-umbrel, &.logo-umbrel,
&.logo-unraid { &.logo-unraid {

View File

@ -51,6 +51,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public filteredTagsObservable: Observable<Tag[]> = of([]); public filteredTagsObservable: Observable<Tag[]> = of([]);
public isLoading = false; public isLoading = false;
public isToday = isToday; public isToday = isToday;
public mode: 'create' | 'update';
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public separatorKeysCodes: number[] = [ENTER, COMMA]; public separatorKeysCodes: number[] = [ENTER, COMMA];
public tags: Tag[] = []; public tags: Tag[] = [];
@ -71,6 +72,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.mode = this.data.activity.id ? 'update' : 'create';
this.locale = this.data.user?.settings?.locale; this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);
@ -92,7 +94,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm = this.formBuilder.group({ this.activityForm = this.formBuilder.group({
accountId: [ 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.accounts[0].id
: this.data.activity?.accountId, : this.data.activity?.accountId,
Validators.required Validators.required
@ -479,18 +483,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
}; };
try { try {
if (this.data.activity.id) { if (this.mode === 'create') {
(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 {
(activity as CreateOrderDto).updateAccountBalance = (activity as CreateOrderDto).updateAccountBalance =
this.activityForm.get('updateAccountBalance').value; this.activityForm.get('updateAccountBalance').value;
@ -502,6 +495,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
}); });
this.dialogRef.close(activity as CreateOrderDto); 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) { } catch (error) {
console.error(error); console.error(error);

View File

@ -4,10 +4,10 @@
(keyup.enter)="activityForm.valid && onSubmit()" (keyup.enter)="activityForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
@if (data.activity.id) { @if (mode === 'create') {
<h1 i18n mat-dialog-title>Update activity</h1>
} @else {
<h1 i18n mat-dialog-title>Add activity</h1> <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="flex-grow-1 py-3" mat-dialog-content>
<div class="mb-3"> <div class="mb-3">
@ -76,16 +76,17 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div [ngClass]="{ 'mb-3': data.activity.id }"> <div [ngClass]="{ 'mb-3': mode === 'update' }">
<mat-form-field <mat-form-field
appearance="outline" appearance="outline"
class="w-100" 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-label i18n>Account</mat-label>
<mat-select formControlName="accountId"> <mat-select formControlName="accountId">
@if ( @if (
!activityForm.get('accountId').hasValidator(Validators.required) !activityForm.get('accountId').hasValidator(Validators.required) ||
(!activityForm.get('accountId').value && mode === 'update')
) { ) {
<mat-option [value]="null" /> <mat-option [value]="null" />
} }
@ -106,7 +107,7 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </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 <mat-checkbox color="primary" formControlName="updateAccountBalance" i18n
>Update Cash Balance</mat-checkbox >Update Cash Balance</mat-checkbox
> >

View File

@ -454,33 +454,25 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (position.holdings.length > 0) { if (position.holdings.length > 0) {
for (const holding of position.holdings) { 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) { if (this.topHoldingsMap[name]?.value) {
this.topHoldingsMap[name].value += this.topHoldingsMap[name].value += isNumber(valueInBaseCurrency)
valueInBaseCurrency * ? valueInBaseCurrency
(isNumber(position.valueInBaseCurrency) : allocationInPercentage *
? position.valueInBaseCurrency this.portfolioDetails.holdings[symbol].valueInPercentage;
: position.valueInPercentage);
} else { } else {
this.topHoldingsMap[name] = { this.topHoldingsMap[name] = {
name, name,
value: value: isNumber(valueInBaseCurrency)
valueInBaseCurrency * ? valueInBaseCurrency
(isNumber(position.valueInBaseCurrency) : allocationInPercentage *
? this.portfolioDetails.holdings[symbol] this.portfolioDetails.holdings[symbol].valueInPercentage
.valueInBaseCurrency
: this.portfolioDetails.holdings[symbol]
.valueInPercentage)
}; };
} }
} }
} }
}
if (position.sectors.length > 0) { if (position.sectors.length > 0) {
for (const sector of position.sectors) { for (const sector of position.sectors) {
@ -562,6 +554,14 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.topHoldings = Object.values(this.topHoldingsMap) this.topHoldings = Object.values(this.topHoldingsMap)
.map(({ name, value }) => { .map(({ name, value }) => {
if (this.hasImpersonationId || this.user.settings.isRestrictedView) {
return {
name,
allocationInPercentage: value,
valueInBaseCurrency: null
};
}
return { return {
name, name,
allocationInPercentage: allocationInPercentage:
@ -570,7 +570,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}; };
}) })
.sort((a, b) => { .sort((a, b) => {
return b.valueInBaseCurrency - a.valueInBaseCurrency; return b.allocationInPercentage - a.allocationInPercentage;
}); });
if (this.topHoldings.length > MAX_TOP_HOLDINGS) { if (this.topHoldings.length > MAX_TOP_HOLDINGS) {

View File

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

View File

@ -152,7 +152,7 @@
</div> </div>
<div class="row my-5"> <div class="row my-5">
<div class="col-md-10 offset-md-1"> <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 Would you like to <strong>refine</strong> your
<strong>personal investment strategy</strong>? <strong>personal investment strategy</strong>?
</h2> </h2>

View File

@ -1,6 +1,7 @@
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Product } from '@ghostfolio/common/interfaces'; import { Product } from '@ghostfolio/common/interfaces';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { translate } from '@ghostfolio/ui/i18n';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
@ -26,6 +27,7 @@ export class GfProductPageComponent implements OnInit {
'/' + $localize`resources`, '/' + $localize`resources`,
'personal-finance-tools' 'personal-finance-tools'
]; ];
public tags: string[];
public constructor( public constructor(
private dataService: DataService, private dataService: DataService,
@ -56,7 +58,7 @@ export class GfProductPageComponent implements OnInit {
], ],
name: 'Ghostfolio', name: 'Ghostfolio',
origin: $localize`Switzerland`, origin: $localize`Switzerland`,
region: $localize`Global`, regions: [$localize`Global`],
slogan: 'Open Source Wealth Management', slogan: 'Open Source Wealth Management',
useAnonymously: true useAnonymously: true
}; };
@ -64,5 +66,41 @@ export class GfProductPageComponent implements OnInit {
this.product2 = personalFinanceTools.find(({ key }) => { this.product2 = personalFinanceTools.find(({ key }) => {
return key === this.route.snapshot.data['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' });
});
} }
} }

View File

@ -80,8 +80,24 @@
</tr> </tr>
<tr class="mat-mdc-row"> <tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Region</td> <td class="mat-mdc-cell px-3 py-2 text-right" i18n>Region</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1.region }}</td> <td class="mat-mdc-cell px-1 py-2">
<td class="mat-mdc-cell px-1 py-2">{{ product2.region }}</td> @for (
region of product1.regions;
track region;
let isLast = $last
) {
{{ region }}{{ isLast ? '' : ', ' }}
}
</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
region of product2.regions;
track region;
let isLast = $last
) {
{{ region }}{{ isLast ? '' : ', ' }}
}
</td>
</tr> </tr>
<tr class="mat-mdc-row"> <tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n> <td class="mat-mdc-cell px-3 py-2 text-right" i18n>
@ -236,69 +252,11 @@
</section> </section>
<section class="mb-4"> <section class="mb-4">
<ul class="list-inline"> <ul class="list-inline">
@for (tag of tags; track tag) {
<li class="list-inline-item"> <li class="list-inline-item">
<span class="badge badge-light">Alternative</span> <span class="badge badge-light">{{ tag }}</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">App</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Budgeting</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Family Office</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">{{ product1.name }}</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investor</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Privacy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Tool</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">User Experience</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">WealthTech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">{{ product2.name }}</span>
</li> </li>
}
</ul> </ul>
</section> </section>
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">

View File

@ -55,6 +55,22 @@
</div> </div>
<h2 class="h4 mb-3" i18n>Markets</h2> <h2 class="h4 mb-3" i18n>Markets</h2>
<div class="mb-5"> <div class="mb-5">
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Crypto Coins Heatmap</h3>
<div class="mb-1">
With the <i>Crypto Coins Heatmap</i> you can track the daily
market movements of cryptocurrencies as a visual snapshot.
</div>
<div>
<a
href="https://www.tradingview.com/heatmap/crypto"
target="_blank"
>Crypto Coins Heatmap →</a
>
</div>
</div>
</div>
<div class="mb-4 media"> <div class="mb-4 media">
<div class="media-body"> <div class="media-body">
<h3 class="h5 mt-0">Fear & Greed Index</h3> <h3 class="h5 mt-0">Fear & Greed Index</h3>
@ -73,10 +89,10 @@
</div> </div>
</div> </div>
<div class="media"> <div class="media">
<div class="media-body"> <div class="mb-4 media">
<h3 class="h5 mt-0">Inflation Chart</h3> <h3 class="h5 mt-0">Inflation Chart</h3>
<div class="mb-1"> <div class="mb-1">
Inflation Chart helps you find the intrinsic value of stock <i>Inflation Chart</i> helps you find the intrinsic value of stock
markets, stock prices, goods and services by adjusting them to the markets, stock prices, goods and services by adjusting them to the
amount of the money supply (M0, M1, M2) or price of other goods amount of the money supply (M0, M1, M2) or price of other goods
(food or oil). (food or oil).
@ -88,6 +104,22 @@
</div> </div>
</div> </div>
</div> </div>
<div class="media">
<div class="media-body">
<h3 class="h5 mt-0">Stock Heatmap</h3>
<div class="mb-1">
With the <i>Stock Heatmap</i> you can track the daily market
movements of stocks as a visual snapshot.
</div>
<div>
<a
href="https://www.tradingview.com/heatmap/stock"
target="_blank"
>Stock Heatmap →</a
>
</div>
</div>
</div>
</div> </div>
<h2 class="h4 mb-3" i18n>Glossary</h2> <h2 class="h4 mb-3" i18n>Glossary</h2>
<div> <div>

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 323 87"><defs><style>.cls-1{opacity:0.1;}</style></defs><g class="cls-1"><path d="M201.13,52.72a30.78,30.78,0,0,1,3-13.73,21.72,21.72,0,0,1,8.55-9.33,24.91,24.91,0,0,1,12.94-3.31q10.48,0,17.11,6.42t7.39,17.41l.1,3.55q0,11.91-6.66,19.11T225.68,80q-11.2,0-17.88-7.17t-6.67-19.53Zm13.83,1q0,7.38,2.77,11.29a10,10,0,0,0,15.79.05q2.83-3.87,2.83-12.34,0-7.25-2.83-11.22a9.18,9.18,0,0,0-7.94-4,9,9,0,0,0-7.85,4C215.88,44.09,215,48.18,215,53.7Z"/></g><path d="M35.37,64.78a4.47,4.47,0,0,0-2.52-4,28.42,28.42,0,0,0-8.06-2.6Q6.33,54.3,6.32,42.48A14.24,14.24,0,0,1,12,31q5.72-4.62,15-4.62,9.86,0,15.77,4.65a14.61,14.61,0,0,1,5.91,12H34.84a6.71,6.71,0,0,0-1.91-4.9q-1.92-1.93-6-1.94a8.26,8.26,0,0,0-5.4,1.58,5,5,0,0,0-1.92,4,4.27,4.27,0,0,0,2.18,3.71A22.51,22.51,0,0,0,29.15,48a60.73,60.73,0,0,1,8.7,2.32q11,4,11,13.93a13.49,13.49,0,0,1-6.08,11.46Q36.66,80,27,80a27.37,27.37,0,0,1-11.56-2.32,19.36,19.36,0,0,1-7.92-6.36,14.83,14.83,0,0,1-2.87-8.73H17.8a7.23,7.23,0,0,0,2.73,5.64,10.82,10.82,0,0,0,6.8,2,10,10,0,0,0,6-1.5A4.69,4.69,0,0,0,35.37,64.78Z"/><path d="M79.58,80q-11.39,0-18.54-7T53.89,54.44V53.1a31.28,31.28,0,0,1,3-14,22.21,22.21,0,0,1,8.54-9.47,24,24,0,0,1,12.61-3.33q10.62,0,16.73,6.7t6.1,19V57.7h-33a12.79,12.79,0,0,0,4,8.13,12.24,12.24,0,0,0,8.54,3.06q8,0,12.49-5.79l6.8,7.61a20.71,20.71,0,0,1-8.43,6.87A27.65,27.65,0,0,1,79.58,80ZM78,37.5a8.62,8.62,0,0,0-6.67,2.79,14.43,14.43,0,0,0-3.28,8H87.29V47.16A10.34,10.34,0,0,0,84.8,40,8.94,8.94,0,0,0,78,37.5Z"/><path d="M121.36,79.09H107.48V5.59h13.88Z"/><path d="M134.57,79.09V37.46h-7.71V27.31h7.71v-4.4q0-8.71,5-13.52t14-4.81a32.27,32.27,0,0,1,7,1l-.14,10.72a17.57,17.57,0,0,0-4.22-.43q-7.8,0-7.8,7.32v4.16h10.29V37.46H148.44V79.09Z"/><path d="M177.44,33a17.28,17.28,0,0,1,13.83-6.61q16.85,0,17.09,19.58V79.09H194.53V46.31q0-4.45-1.92-6.58t-6.36-2.13q-6.08,0-8.81,4.69v36.8H163.62V5.59h13.82Z"/><path d="M271.22,64.78a4.48,4.48,0,0,0-2.51-4,28.42,28.42,0,0,0-8.06-2.6q-18.48-3.88-18.47-15.7A14.22,14.22,0,0,1,247.9,31q5.72-4.62,15-4.62,9.86,0,15.77,4.65a14.64,14.64,0,0,1,5.91,12H270.7a6.68,6.68,0,0,0-1.92-4.9c-1.27-1.29-3.27-1.94-6-1.94a8.31,8.31,0,0,0-5.41,1.58,5,5,0,0,0-1.91,4,4.27,4.27,0,0,0,2.18,3.71A22.52,22.52,0,0,0,265,48a60.54,60.54,0,0,1,8.71,2.32q11,4,11,13.93a13.51,13.51,0,0,1-6.08,11.46Q272.52,80,262.9,80a27.37,27.37,0,0,1-11.56-2.32,19.24,19.24,0,0,1-7.92-6.36,14.83,14.83,0,0,1-2.87-8.73h13.11a7.23,7.23,0,0,0,2.73,5.64,10.79,10.79,0,0,0,6.79,2,10.07,10.07,0,0,0,6-1.5A4.71,4.71,0,0,0,271.22,64.78Z"/><path d="M308.17,14.58V27.31H317V37.46h-8.85V63.3a6.13,6.13,0,0,0,1.1,4.11c.73.83,2.13,1.25,4.21,1.25a22.24,22.24,0,0,0,4.06-.34V78.8A28.42,28.42,0,0,1,309.17,80q-14.55,0-14.83-14.69V37.46h-7.56V27.31h7.56V14.58Z"/><path d="M222.41,75.64a4.12,4.12,0,0,1,1.07-2.85,3.87,3.87,0,0,1,3-1.17,4,4,0,0,1,3,1.17,4.06,4.06,0,0,1,1.1,2.85,3.68,3.68,0,0,1-1.1,2.75,4.14,4.14,0,0,1-3,1.08,4,4,0,0,1-3-1.08A3.73,3.73,0,0,1,222.41,75.64Z"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -7,10 +7,16 @@
{ {
"sizes": "192x192", "sizes": "192x192",
"src": "/assets/android-chrome-192x192.png", "src": "/assets/android-chrome-192x192.png",
"type": "image/png", "type": "image/png"
"purpose": "any maskable"
}, },
{ {
"purpose": "any",
"sizes": "512x512",
"src": "/assets/android-chrome-512x512.png",
"type": "image/png"
},
{
"purpose": "maskable",
"sizes": "512x512", "sizes": "512x512",
"src": "/assets/android-chrome-512x512.png", "src": "/assets/android-chrome-512x512.png",
"type": "image/png" "type": "image/png"
@ -21,5 +27,5 @@
"short_name": "Ghostfolio", "short_name": "Ghostfolio",
"start_url": "/en/", "start_url": "/en/",
"theme_color": "#FFFFFF", "theme_color": "#FFFFFF",
"url": "https://www.ghostfol.io" "url": "https://ghostfol.io"
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,3 @@
version: '3.9'
services: services:
ghostfolio: ghostfolio:
build: ../ build: ../

View File

@ -1,4 +1,3 @@
version: '3.9'
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15

View File

@ -1,4 +1,3 @@
version: '3.9'
services: services:
ghostfolio: ghostfolio:
image: ghostfolio/ghostfolio:latest image: ghostfolio/ghostfolio:latest

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