Compare commits

...

38 Commits

Author SHA1 Message Date
9ad1c2177c Release 2.81.0 (#3403) 2024-05-12 10:42:04 +02:00
15bf9f2f9c Feature/add Türkçe to footer (#3401) 2024-05-12 10:34:33 +02:00
ebc5008569 Feature/improve language localization for de 20240512 (#3402)
* Update translations

* Update changelog
2024-05-12 10:25:59 +02:00
37759ba03f Feature/improve language localization for tr 20240501 (#3355)
* Improve translations

* Update changelog

---------

Co-authored-by: sadmimye <134071831+sadmimye@users.noreply.github.com>
2024-05-12 09:58:11 +02:00
8319b216bb Feature/support delete activities with filtering (#3394)
* Support delete activities with filtering

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-05-12 09:56:07 +02:00
782d131b0d Feature/add indicator for active filters (#3398)
* Add indicator for active filters

* Update changelog
2024-05-12 09:42:27 +02:00
72e75208df Bugfix/fix position detail dialog close functionality (#3396)
* Handle holding detail dialog open functionality in a single place (AppComponent)

* Update changelog
2024-05-11 20:27:18 +02:00
4b1c27c245 Feature/upgrade nx to version 19.0.2 (#3391)
* Upgrade Nx dependencies to version 19.0.2

* Update changelog
2024-05-11 08:33:33 +02:00
61f0da35bc Feature/disable delete all activities if filters are active (#3389)
* Disable delete all activities button if filters are active

* Update changelog
2024-05-10 08:51:34 +02:00
80464c7846 Release 2.80.0 (#3386) 2024-05-08 20:55:39 +02:00
74f4323903 Feature/increase spacing around floating action buttons (#3385)
* Increase spacing around floating action buttons

* Update changelog
2024-05-08 20:54:13 +02:00
127dbf9dcd Update translations (#3384) 2024-05-08 20:37:53 +02:00
66bdb374e8 Feature/set icon columns of tables to stick at beginning (#3377)
* Set icon columns to stick at the beginning

* Update changelog
2024-05-08 20:04:58 +02:00
4ad4fa2b30 Feature/clean up deprecated GET api/portfolio/positions endpoint (#3373) 2024-05-08 20:04:32 +02:00
1fd836194f Feature/add absolute change column to holdings table (#3378)
* Add absolute change column

* Update changelog
2024-05-08 20:02:50 +02:00
2090db1199 Feature/increase number of attempts of queue jobs (#3376)
* Increase number of attempts

* Update changelog
2024-05-07 20:48:02 +02:00
053c7e591e Feature/upgrade ionicons to version 7.4.0 (#3356)
* Upgrade ionicons to version 7.4.0

* Update changelog
2024-05-07 19:00:55 +02:00
9b5e350e3b Feature/harmonize log message (#3343) 2024-05-06 17:03:58 +02:00
378e57c3bc Feature/add links to Home Assistant add-on (#3367)
* Add links to Home Assistant add-on
2024-05-06 17:02:18 +02:00
6765191a8c Bugfix/fix position detail dialog open in holding search of assistant (#3374)
* Open position detail dialog (via holding search of assistant)

* Update changelog
2024-05-05 09:09:57 +02:00
8438a45bcf Update changelog (#3372) 2024-05-04 16:32:32 +02:00
30a64e7fc1 Release 2.79.0 (#3371) 2024-05-04 15:54:29 +02:00
f2cb671c7f Feature/optimize get porfolio details endpoint (#3366)
* Eliminate getPerformance() from getSummary() function

* Disable cache for getDetails()

* Add hint to portfolio summary

* Update changelog
2024-05-04 15:53:02 +02:00
3f41e5c5de Bugfix/fix locale in markets overview (#3369)
* Fix locale if no user is logged in

* Update changelog
2024-05-04 15:51:09 +02:00
c1ad483f33 Improve alignment (#3370) 2024-05-04 15:50:47 +02:00
f3d961bc16 Feature/move holdings table to holdings tab of home page (#3368)
* Move holdings table to holdings tab of home page

* Deprecate api/v1/portfolio/positions endpoint

* Update changelog
2024-05-04 14:11:37 +02:00
42b70ef568 Feature/improve performance labels in position detail dialog (#3363)
* Improve performance labels (with and without currency effects)

* Update changelog
2024-05-04 07:49:37 +02:00
77beaaba08 Refactoring portfolio service (#3365) 2024-05-03 21:48:46 +02:00
d9c07456cd Release 2.78.0 (#3361) 2024-05-02 20:32:47 +02:00
0a53df4293 Feature/improve inactive user role (#3360)
* Improve inactive role

* Update changelog
2024-05-02 20:31:20 +02:00
4416ba0c88 Feature/set performance column of holdings table to stick at end (#3353)
* Set up stickyEnd in performance column

* Update changelog
2024-05-02 19:16:59 +02:00
486de968a2 Bugfix/fix division by zero error in dividend yield calculation (#3354)
* Handle division by zero

* Update changelog
2024-05-02 17:53:34 +02:00
a5833566a8 Feature/skip caching in portfolio calculator if active filters (#3348)
* Skip caching if active filters

* Update changelog
2024-05-02 17:52:39 +02:00
261f5844dd Add type column to README.md (#3295) 2024-04-30 14:08:14 +02:00
2173c418a7 Feature/validate forms using DTO for access, asset profile, tag and platform management (#3337)
* Validate forms using DTO for access, asset profile, tag and platform management

* Update changelog
2024-04-30 08:04:45 +02:00
4efd5cefd8 Bugfix/calculation of portfolio summary caused by future liabilities (#3342)
* Adapt date of future activities

* Update changelog
2024-04-29 20:12:12 +02:00
d735e4db75 Release 2.77.1 (#3340) 2024-04-27 19:25:23 +02:00
e10707fde4 Add missing guard to fix public page (#3339) 2024-04-27 19:24:08 +02:00
118 changed files with 4560 additions and 4835 deletions

View File

@ -5,7 +5,75 @@ 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.77.0 - 2024-04-27 ## 2.81.0 - 2024-05-12
### Added
- Added an indicator for active filters (experimental)
### Changed
- Improved the delete all activities functionality on the portfolio activities page to work with the filters of the assistant
- Improved the language localization for German (`de`)
- Improved the language localization for Türkçe (`tr`)
- Upgraded `Nx` from version `18.3.3` to `19.0.2`
### Fixed
- Fixed the position detail dialog close functionality
## 2.80.0 - 2024-05-08
### Added
- Added the absolute change column to the holdings table on the home page
### Changed
- Increased the spacing around the floating action buttons (FAB)
- Set the icon column of the activities table to stick at the beginning
- Set the icon column of the holdings table to stick at the beginning
- Increased the number of attempts of queue jobs from `10` to `12` (fail later)
- Upgraded `ionicons` from version `7.3.0` to `7.4.0`
### Fixed
- Fixed the position detail dialog open functionality when searching for a holding in the assistant
## 2.79.0 - 2024-05-04
### Changed
- Moved the holdings table to the holdings tab of the home page
- Improved the performance labels (with and without currency effects) in the position detail dialog
- Optimized the calculations of the portfolio details endpoint
### Fixed
- Fixed an issue with the benchmarks in the markets overview
- Fixed an issue with the _Fear & Greed Index_ (market mood) in the markets overview
## 2.78.0 - 2024-05-02
### Added
- Added a form validation against the DTO in the create or update access dialog
- Added a form validation against the DTO in the asset profile details dialog of the admin control
- Added a form validation against the DTO in the platform management of the admin control panel
- Added a form validation against the DTO in the tag management of the admin control panel
### Changed
- Set the performance column of the holdings table to stick at the end
- Skipped the caching in the portfolio calculator if there are active filters (experimental)
- Improved the `INACTIVE` user role
### Fixed
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
- Fixed a division by zero error in the dividend yield calculation (experimental)
## 2.77.1 - 2024-04-27
### Added ### Added

View File

@ -85,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Default Value | Description | | Name | Type | Default Value | Description |
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | 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` |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API | | `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
| `DATABASE_URL` | | 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` | `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` | | A random string used for _JSON Web Tokens_ (JWT) | | `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `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` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `0` | The database index of _Redis_ | | `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | | The host where _Redis_ is running | | `REDIS_HOST` | string | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ | | `REDIS_PASSWORD` | string | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running | | `REDIS_PORT` | number | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `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
@ -142,7 +142,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
### Home Server Systems (Community) ### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), Home Assistant, [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development

View File

@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import * as countriesAndTimezones from 'countries-and-timezones'; import * as countriesAndTimezones from 'countries-and-timezones';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable() @Injectable()
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (user) { if (user) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country = const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return user; return user;
} else { } else {
throw ''; throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
} catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
throw error;
} else {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
} }
} catch (err) {
throw new UnauthorizedException('unauthorized', err.message);
} }
} }
} }

View File

@ -11,7 +11,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -53,8 +53,20 @@ export class OrderController {
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrders(): Promise<number> { public async deleteOrders(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
return this.orderService.deleteOrders({ return this.orderService.deleteOrders({
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@ -88,21 +100,26 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange?: DateRange,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('take') take?: number @Query('take') take?: number
): Promise<Activities> { ): Promise<Activities> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getInterval(dateRange));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByTags filterByTags
}); });
const { endDate, startDate } = getInterval(dateRange);
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;

View File

@ -194,16 +194,36 @@ export class OrderService {
return order; return order;
} }
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> { public async deleteOrders({
filters,
userCurrency,
userId
}: {
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<number> {
const { activities } = await this.getOrders({
filters,
userId,
userCurrency,
includeDrafts: true,
withExcludedAccounts: true
});
const { count } = await this.prismaService.order.deleteMany({ const { count } = await this.prismaService.order.deleteMany({
where where: {
id: {
in: activities.map(({ id }) => {
return id;
})
}
}
}); });
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({ userId })
userId: <string>where.userId
})
); );
return count; return count;

View File

@ -32,6 +32,7 @@ export class PortfolioCalculatorFactory {
calculationType, calculationType,
currency, currency,
dateRange = 'max', dateRange = 'max',
hasFilters,
isExperimentalFeatures = false, isExperimentalFeatures = false,
userId userId
}: { }: {
@ -40,9 +41,12 @@ export class PortfolioCalculatorFactory {
calculationType: PerformanceCalculationType; calculationType: PerformanceCalculationType;
currency: string; currency: string;
dateRange?: DateRange; dateRange?: DateRange;
hasFilters: boolean;
isExperimentalFeatures?: boolean; isExperimentalFeatures?: boolean;
userId: string; userId: string;
}): PortfolioCalculator { }): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) { switch (calculationType) {
case PerformanceCalculationType.MWR: case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({ return new MWRPortfolioCalculator({
@ -50,7 +54,7 @@ export class PortfolioCalculatorFactory {
activities, activities,
currency, currency,
dateRange, dateRange,
isExperimentalFeatures, useCache,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -64,7 +68,7 @@ export class PortfolioCalculatorFactory {
currency, currency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
dateRange, dateRange,
isExperimentalFeatures, useCache,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService, exchangeRateDataService: this.exchangeRateDataService,

View File

@ -37,6 +37,7 @@ import {
eachDayOfInterval, eachDayOfInterval,
endOfDay, endOfDay,
format, format,
isAfter,
isBefore, isBefore,
isSameDay, isSameDay,
max, max,
@ -44,26 +45,26 @@ import {
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { first, last, uniq, uniqBy } from 'lodash'; import { first, last, uniq, uniqBy } from 'lodash';
import ms from 'ms';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
protected accountBalanceItems: HistoricalDataItem[]; protected accountBalanceItems: HistoricalDataItem[];
protected orders: PortfolioOrder[]; protected activities: PortfolioOrder[];
private configurationService: ConfigurationService; private configurationService: ConfigurationService;
private currency: string; private currency: string;
private currentRateService: CurrentRateService; private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[]; private dataProviderInfos: DataProviderInfo[];
private dateRange: DateRange;
private endDate: Date; private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService; private exchangeRateDataService: ExchangeRateDataService;
private isExperimentalFeatures: boolean;
private redisCacheService: RedisCacheService; private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot; private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>; private snapshotPromise: Promise<void>;
private startDate: Date; private startDate: Date;
private transactionPoints: TransactionPoint[]; private transactionPoints: TransactionPoint[];
private useCache: boolean;
private userId: string; private userId: string;
public constructor({ public constructor({
@ -74,8 +75,8 @@ export abstract class PortfolioCalculator {
currentRateService, currentRateService,
dateRange, dateRange,
exchangeRateDataService, exchangeRateDataService,
isExperimentalFeatures,
redisCacheService, redisCacheService,
useCache,
userId userId
}: { }: {
accountBalanceItems: HistoricalDataItem[]; accountBalanceItems: HistoricalDataItem[];
@ -85,18 +86,18 @@ export abstract class PortfolioCalculator {
currentRateService: CurrentRateService; currentRateService: CurrentRateService;
dateRange: DateRange; dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService; exchangeRateDataService: ExchangeRateDataService;
isExperimentalFeatures: boolean;
redisCacheService: RedisCacheService; redisCacheService: RedisCacheService;
useCache: boolean;
userId: string; userId: string;
}) { }) {
this.accountBalanceItems = accountBalanceItems; this.accountBalanceItems = accountBalanceItems;
this.configurationService = configurationService; this.configurationService = configurationService;
this.currency = currency; this.currency = currency;
this.currentRateService = currentRateService; this.currentRateService = currentRateService;
this.dateRange = dateRange;
this.exchangeRateDataService = exchangeRateDataService; this.exchangeRateDataService = exchangeRateDataService;
this.isExperimentalFeatures = isExperimentalFeatures;
this.orders = activities this.activities = activities
.map( .map(
({ ({
date, date,
@ -107,6 +108,12 @@ export abstract class PortfolioCalculator {
type, type,
unitPrice unitPrice
}) => { }) => {
if (isAfter(date, new Date(Date.now()))) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
}
return { return {
SymbolProfile, SymbolProfile,
tags, tags,
@ -123,6 +130,7 @@ export abstract class PortfolioCalculator {
}); });
this.redisCacheService = redisCacheService; this.redisCacheService = redisCacheService;
this.useCache = useCache;
this.userId = userId; this.userId = userId;
const { endDate, startDate } = getInterval(dateRange); const { endDate, startDate } = getInterval(dateRange);
@ -917,7 +925,7 @@ export abstract class PortfolioCalculator {
tags, tags,
type, type,
unitPrice unitPrice
} of this.orders) { } of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol]; const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
@ -1041,11 +1049,13 @@ export abstract class PortfolioCalculator {
} }
private async initialize() { private async initialize() {
if (this.isExperimentalFeatures) { if (this.useCache) {
const startTimeTotal = performance.now(); const startTimeTotal = performance.now();
const cachedSnapshot = await this.redisCacheService.get( const cachedSnapshot = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey(this.userId) this.redisCacheService.getPortfolioSnapshotKey({
userId: this.userId
})
); );
if (cachedSnapshot) { if (cachedSnapshot) {
@ -1068,7 +1078,9 @@ export abstract class PortfolioCalculator {
); );
this.redisCacheService.set( this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey(this.userId), this.redisCacheService.getPortfolioSnapshotKey({
userId: this.userId
}),
JSON.stringify(this.snapshot), JSON.stringify(this.snapshot),
this.configurationService.get('CACHE_QUOTES_TTL') this.configurationService.get('CACHE_QUOTES_TTL')
); );

View File

@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -108,6 +108,7 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -93,6 +93,7 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -121,6 +121,7 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -93,6 +93,7 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD', currency: 'USD',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -106,6 +106,7 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -93,6 +93,7 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD', currency: 'USD',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -74,7 +74,7 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2022-01-01'), date: new Date('2023-01-01'), // Date in future
fee: 0, fee: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
@ -93,64 +93,16 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD', currency: 'USD',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-01-01')
);
spy.mockRestore(); spy.mockRestore();
expect(portfolioSnapshot).toEqual({ const liabilitiesInBaseCurrency =
currentValueInBaseCurrency: new Big('0'), await portfolioCalculator.getLiabilitiesInBaseCurrency();
errors: [],
grossPerformance: new Big('0'), expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('3000'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 3000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: '55196015-1365-4560-aa60-8751ae6d18f8',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
}); });
}); });
}); });

View File

@ -121,6 +121,7 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD', currency: 'USD',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -71,6 +71,7 @@ describe('PortfolioCalculator', () => {
activities: [], activities: [],
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -108,6 +108,7 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -108,6 +108,7 @@ describe('PortfolioCalculator', () => {
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF', currency: 'CHF',
hasFilters: false,
userId: userDummyData.id userId: userDummyData.id
}); });

View File

@ -203,7 +203,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let valueAtStartDateWithCurrencyEffect: Big; let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders // Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter( let orders: PortfolioOrderItem[] = cloneDeep(this.activities).filter(
({ SymbolProfile }) => { ({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol; return SymbolProfile.symbol === symbol;
} }

View File

@ -8,6 +8,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) { function mockGetValue(symbol: string, date: Date) {
switch (symbol) { switch (symbol) {
case '55196015-1365-4560-aa60-8751ae6d18f8':
if (isSameDay(parseDate('2022-01-31'), date)) {
return { marketPrice: 3000 };
}
return { marketPrice: 0 };
case 'BALN.SW': case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) { if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 }; return { marketPrice: 146 };

View File

@ -1,5 +0,0 @@
import { Position } from '@ghostfolio/common/interfaces';
export interface PortfolioPositions {
positions: Position[];
}

View File

@ -52,7 +52,6 @@ import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
@ -165,21 +164,21 @@ export class PortfolioController {
portfolioSummary = nullifyValuesInObject(summary, [ portfolioSummary = nullifyValuesInObject(summary, [
'cash', 'cash',
'committedFunds', 'committedFunds',
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth', 'currentNetWorth',
'currentValue', 'currentValueInBaseCurrency',
'dividendInBaseCurrency', 'dividendInBaseCurrency',
'emergencyFund', 'emergencyFund',
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'filteredValueInBaseCurrency', 'filteredValueInBaseCurrency',
'fireWealth', 'fireWealth',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interest', 'interest',
'items', 'items',
'liabilities', 'liabilities',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalBuy', 'totalBuy',
'totalInvestment', 'totalInvestment',
'totalSell', 'totalSell',
@ -449,10 +448,14 @@ export class PortfolioController {
.div(performanceInformation.performance.totalInvestment) .div(performanceInformation.performance.totalInvestment)
.toNumber(), .toNumber(),
valueInPercentage: valueInPercentage:
performanceInformation.performance.currentValue === 0 performanceInformation.performance.currentValueInBaseCurrency ===
0
? 0 ? 0
: new Big(value) : new Big(value)
.div(performanceInformation.performance.currentValue) .div(
performanceInformation.performance
.currentValueInBaseCurrency
)
.toNumber() .toNumber()
}; };
} }
@ -461,12 +464,12 @@ export class PortfolioController {
performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance, performanceInformation.performance,
[ [
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth', 'currentNetWorth',
'currentValue', 'currentValueInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalInvestment' 'totalInvestment'
] ]
); );
@ -483,39 +486,13 @@ export class PortfolioController {
); );
performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance, performanceInformation.performance,
['currentNetPerformance', 'currentNetPerformancePercent'] ['netPerformance']
); );
} }
return performanceInformation; return performanceInformation;
} }
@Get('positions')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPositions> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
});
return this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
}
@Get('public/:accessId') @Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic( public async getPublic(

View File

@ -27,7 +27,7 @@ describe('PortfolioService', () => {
portfolioService portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0) netPerformancePercentage: new Big(0)
}) })
.toNumber() .toNumber()
).toEqual(0); ).toEqual(0);
@ -36,7 +36,7 @@ describe('PortfolioService', () => {
portfolioService portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 0, daysInMarket: 0,
netPerformancePercent: new Big(0) netPerformancePercentage: new Big(0)
}) })
.toNumber() .toNumber()
).toEqual(0); ).toEqual(0);
@ -48,7 +48,7 @@ describe('PortfolioService', () => {
portfolioService portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025) netPerformancePercentage: new Big(0.1025)
}) })
.toNumber() .toNumber()
).toBeCloseTo(0.729705); ).toBeCloseTo(0.729705);
@ -57,7 +57,7 @@ describe('PortfolioService', () => {
portfolioService portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05) netPerformancePercentage: new Big(0.05)
}) })
.toNumber() .toNumber()
).toBeCloseTo(0.05); ).toBeCloseTo(0.05);
@ -69,7 +69,7 @@ describe('PortfolioService', () => {
portfolioService portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374) netPerformancePercentage: new Big(0.2374)
}) })
.toNumber() .toNumber()
).toBeCloseTo(0.145); ).toBeCloseTo(0.145);

View File

@ -208,16 +208,16 @@ export class PortfolioService {
public getAnnualizedPerformancePercent({ public getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercent netPerformancePercentage
}: { }: {
daysInMarket: number; daysInMarket: number;
netPerformancePercent: Big; netPerformancePercentage: Big;
}): Big { }): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) { if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber(); const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big( return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent) Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
).minus(1); ).minus(1);
} }
@ -277,9 +277,11 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
dateRange,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency, currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures: isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures this.request.user.Settings.settings.isExperimentalFeatures
}); });
@ -358,8 +360,9 @@ export class PortfolioService {
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: userCurrency, currency: userCurrency,
hasFilters: true, // disable cache
isExperimentalFeatures: isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures this.request.user?.Settings.settings.isExperimentalFeatures
}); });
const { currentValueInBaseCurrency, hasErrors, positions } = const { currentValueInBaseCurrency, hasErrors, positions } =
@ -660,6 +663,7 @@ export class PortfolioService {
}), }),
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: userCurrency, currency: userCurrency,
hasFilters: true,
isExperimentalFeatures: isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures this.request.user.Settings.settings.isExperimentalFeatures
}); });
@ -700,17 +704,21 @@ export class PortfolioService {
const dividendYieldPercent = this.getAnnualizedPerformancePercent({ const dividendYieldPercent = this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercent: dividendInBaseCurrency.div( netPerformancePercentage: timeWeightedInvestment.eq(0)
timeWeightedInvestment ? new Big(0)
) : dividendInBaseCurrency.div(timeWeightedInvestment)
}); });
const dividendYieldPercentWithCurrencyEffect = const dividendYieldPercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({ this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercent: dividendInBaseCurrency.div( netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
timeWeightedInvestmentWithCurrencyEffect 0
) )
? new Big(0)
: dividendInBaseCurrency.div(
timeWeightedInvestmentWithCurrencyEffect
)
}); });
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
@ -931,6 +939,7 @@ export class PortfolioService {
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency, currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures: isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures this.request.user.Settings.settings.isExperimentalFeatures
}); });
@ -1085,7 +1094,7 @@ export class PortfolioService {
) )
); );
const { endDate, startDate } = getInterval(dateRange); const { endDate } = getInterval(dateRange);
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
endDate, endDate,
@ -1101,16 +1110,16 @@ export class PortfolioService {
firstOrderDate: undefined, firstOrderDate: undefined,
hasErrors: false, hasErrors: false,
performance: { performance: {
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentGrossPerformancePercentWithCurrencyEffect: 0,
currentGrossPerformanceWithCurrencyEffect: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentNetPerformancePercentWithCurrencyEffect: 0,
currentNetPerformanceWithCurrencyEffect: 0,
currentNetWorth: 0, currentNetWorth: 0,
currentValue: 0, currentValueInBaseCurrency: 0,
grossPerformance: 0,
grossPerformancePercentage: 0,
grossPerformancePercentageWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
netPerformance: 0,
netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0 totalInvestment: 0
} }
}; };
@ -1123,6 +1132,7 @@ export class PortfolioService {
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: userCurrency, currency: userCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures: isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures this.request.user.Settings.settings.isExperimentalFeatures
}); });
@ -1144,9 +1154,9 @@ export class PortfolioService {
let currentNetPerformance = netPerformance; let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage; let currentNetPerformancePercentage = netPerformancePercentage;
let currentNetPerformancePercentWithCurrencyEffect = let currentNetPerformancePercentageWithCurrencyEffect =
netPerformancePercentageWithCurrencyEffect; netPerformancePercentageWithCurrencyEffect;
let currentNetPerformanceWithCurrencyEffect = let currentNetPerformanceWithCurrencyEffect =
@ -1165,11 +1175,11 @@ export class PortfolioService {
if (itemOfToday) { if (itemOfToday) {
currentNetPerformance = new Big(itemOfToday.netPerformance); currentNetPerformance = new Big(itemOfToday.netPerformance);
currentNetPerformancePercent = new Big( currentNetPerformancePercentage = new Big(
itemOfToday.netPerformanceInPercentage itemOfToday.netPerformanceInPercentage
).div(100); ).div(100);
currentNetPerformancePercentWithCurrencyEffect = new Big( currentNetPerformancePercentageWithCurrencyEffect = new Big(
itemOfToday.netPerformanceInPercentageWithCurrencyEffect itemOfToday.netPerformanceInPercentageWithCurrencyEffect
).div(100); ).div(100);
@ -1187,19 +1197,19 @@ export class PortfolioService {
firstOrderDate: parseDate(items[0]?.date), firstOrderDate: parseDate(items[0]?.date),
performance: { performance: {
currentNetWorth, currentNetWorth,
currentGrossPerformance: grossPerformance.toNumber(), currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
currentGrossPerformancePercent: grossPerformancePercentage.toNumber(), grossPerformance: grossPerformance.toNumber(),
currentGrossPerformancePercentWithCurrencyEffect: grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(), grossPerformancePercentageWithCurrencyEffect.toNumber(),
currentGrossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(), grossPerformanceWithCurrencyEffect.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(), netPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(), netPerformancePercentage: currentNetPerformancePercentage.toNumber(),
currentNetPerformancePercentWithCurrencyEffect: netPerformancePercentageWithCurrencyEffect:
currentNetPerformancePercentWithCurrencyEffect.toNumber(), currentNetPerformancePercentageWithCurrencyEffect.toNumber(),
currentNetPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
currentNetPerformanceWithCurrencyEffect.toNumber(), currentNetPerformanceWithCurrencyEffect.toNumber(),
currentValue: currentValueInBaseCurrency.toNumber(),
totalInvestment: totalInvestment.toNumber() totalInvestment: totalInvestment.toNumber()
} }
}; };
@ -1220,6 +1230,7 @@ export class PortfolioService {
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency, currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: false,
isExperimentalFeatures: isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures this.request.user.Settings.settings.isExperimentalFeatures
}); });
@ -1595,11 +1606,6 @@ export class PortfolioService {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance({
impersonationId,
userId
});
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
userCurrency, userCurrency,
userId, userId,
@ -1617,6 +1623,19 @@ export class PortfolioService {
} }
} }
const {
currentValueInBaseCurrency,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalInvestment
} = await portfolioCalculator.getSnapshot();
const dividendInBaseCurrency = const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency(); await portfolioCalculator.getDividendInBaseCurrency();
@ -1685,7 +1704,7 @@ export class PortfolioService {
.toNumber(); .toNumber();
const netWorth = new Big(balanceInBaseCurrency) const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue) .plus(currentValueInBaseCurrency)
.plus(valuables) .plus(valuables)
.plus(excludedAccountsAndActivities) .plus(excludedAccountsAndActivities)
.minus(liabilities) .minus(liabilities)
@ -1695,21 +1714,18 @@ export class PortfolioService {
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({ const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercent: new Big( netPerformancePercentage: new Big(netPerformancePercentage)
performanceInformation.performance.currentNetPerformancePercent
)
})?.toNumber(); })?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect = const annualizedPerformancePercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({ this.getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercent: new Big( netPerformancePercentage: new Big(
performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect netPerformancePercentageWithCurrencyEffect
) )
})?.toNumber(); })?.toNumber();
return { return {
...performanceInformation.performance,
annualizedPerformancePercent, annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect, annualizedPerformancePercentWithCurrencyEffect,
cash, cash,
@ -1718,6 +1734,7 @@ export class PortfolioService {
totalBuy, totalBuy,
totalSell, totalSell,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: { emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency, assets: emergencyFundPositionsValueInBaseCurrency,
@ -1731,15 +1748,28 @@ export class PortfolioService {
filteredValueInPercentage: netWorth filteredValueInPercentage: netWorth
? filteredValueInBaseCurrency.div(netWorth).toNumber() ? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined, : undefined,
fireWealth: new Big(performanceInformation.performance.currentValue) fireWealth: new Big(currentValueInBaseCurrency)
.minus(emergencyFundPositionsValueInBaseCurrency) .minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(), .toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
interest: interest.toNumber(), interest: interest.toNumber(),
items: valuables.toNumber(), items: valuables.toNumber(),
liabilities: liabilities.toNumber(), liabilities: liabilities.toNumber(),
netPerformance: netPerformance.toNumber(),
netPerformancePercentage: netPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect.toNumber(),
ordersCount: activities.filter(({ type }) => { ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL'; return ['BUY', 'SELL'].includes(type);
}).length, }).length,
totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth totalValueInBaseCurrency: netWorth
}; };
} }

View File

@ -24,7 +24,7 @@ export class RedisCacheService {
return this.cache.get(key); return this.cache.get(key);
} }
public getPortfolioSnapshotKey(userId: string) { public getPortfolioSnapshotKey({ userId }: { userId: string }) {
return `portfolio-snapshot-${userId}`; return `portfolio-snapshot-${userId}`;
} }

View File

@ -2,11 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -63,13 +59,6 @@ export class UserController {
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string @Headers('accept-language') acceptLanguage: string
): Promise<User> { ): Promise<User> {
if (hasRole(this.request.user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
return this.userService.getUser( return this.userService.getUser(
this.request.user, this.request.user,
acceptLanguage?.split(',')?.[0] acceptLanguage?.split(',')?.[0]

View File

@ -12,12 +12,14 @@ export class PortfolioChangedListener {
@OnEvent(PortfolioChangedEvent.getName()) @OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) { handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
Logger.log( Logger.log(
`Portfolio of user with id ${event.getUserId()} has changed`, `Portfolio of user '${event.getUserId()}' has changed`,
'PortfolioChangedListener' 'PortfolioChangedListener'
); );
this.redisCacheService.remove( this.redisCacheService.remove(
this.redisCacheService.getPortfolioSnapshotKey(event.getUserId()) this.redisCacheService.getPortfolioSnapshotKey({
userId: event.getUserId()
})
); );
} }
} }

View File

@ -158,11 +158,9 @@
<li> <li>
<a href="../pt" title="Ghostfolio in Português">Português</a> <a href="../pt" title="Ghostfolio in Português">Português</a>
</li> </li>
<!-- <li>
<li> <a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a> </li>
</li>
-->
<!-- <!--
<li> <li>
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a> <a href="../zh" title="Ghostfolio in Chinese">Chinese</a>

View File

@ -13,13 +13,23 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router'; import {
ActivatedRoute,
NavigationEnd,
PRIMARY_OUTLET,
Router
} from '@angular/router';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from './components/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from './components/position-detail-dialog/position-detail-dialog.component';
import { DataService } from './services/data.service'; import { DataService } from './services/data.service';
import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { TokenStorageService } from './services/token-storage.service'; import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service'; import { UserService } from './services/user/user.service';
@ -38,6 +48,7 @@ export class AppComponent implements OnDestroy, OnInit {
public currentRoute: string; public currentRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean; public hasInfoMessage: boolean;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
@ -67,7 +78,10 @@ export class AppComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog,
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router, private router: Router,
private title: Title, private title: Title,
private tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService,
@ -75,6 +89,21 @@ export class AppComponent implements OnDestroy, OnInit {
) { ) {
this.initializeTheme(); this.initializeTheme();
this.user = undefined; this.user = undefined;
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['dataSource'] &&
params['holdingDetailDialog'] &&
params['symbol']
) {
this.openHoldingDetailDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
});
} }
public ngOnInit() { public ngOnInit() {
@ -96,6 +125,13 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.enableFearAndGreedIndex permissions.enableFearAndGreedIndex
); );
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.router.events this.router.events
.pipe(filter((event) => event instanceof NavigationEnd)) .pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => { .subscribe(() => {
@ -197,6 +233,55 @@ export class AppComponent implements OnDestroy, OnInit {
}); });
} }
private openHoldingDetailDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate([], {
queryParams: {
dataSource: null,
holdingDetailDialog: null,
symbol: null
},
queryParamsHandling: 'merge',
relativeTo: this.route
});
});
});
}
private toggleTheme(isDarkTheme: boolean) { private toggleTheme(isDarkTheme: boolean) {
const themeColor = getCssVariable( const themeColor = getCssVariable(
isDarkTheme ? '--dark-background' : '--light-background' isDarkTheme ? '--dark-background' : '--light-background'

View File

@ -94,6 +94,7 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]=" [hasPermissionToExportActivities]="
!data.hasImpersonationId && !user.settings.isRestrictedView !data.hasImpersonationId && !user.settings.isRestrictedView
" "

View File

@ -38,6 +38,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
selector: 'gf-admin-market-data', selector: 'gf-admin-market-data',
styleUrls: ['./admin-market-data.scss'], styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html' templateUrl: './admin-market-data.html'

View File

@ -3,6 +3,7 @@ import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-dat
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
@ -258,7 +259,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}); });
} }
public onSubmit() { public async onSubmit() {
let countries = []; let countries = [];
let scraperConfiguration = {}; let scraperConfiguration = {};
let sectors = []; let sectors = [];
@ -299,6 +300,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
url: this.assetProfileForm.get('url').value || null url: this.assetProfileForm.get('url').value || null
}; };
try {
await validateObjectForForm({
classDto: UpdateAssetProfileDto,
form: this.assetProfileForm,
object: assetProfileData
});
} catch (error) {
console.error(error);
return;
}
this.adminService this.adminService
.patchAssetProfile({ .patchAssetProfile({
...assetProfileData, ...assetProfileData,

View File

@ -143,9 +143,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((platform: CreatePlatformDto | null) => {
const platform: CreatePlatformDto = data?.platform;
if (platform) { if (platform) {
this.adminService this.adminService
.postPlatform(platform) .postPlatform(platform)
@ -182,9 +180,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((platform: UpdatePlatformDto | null) => {
const platform: UpdatePlatformDto = data?.platform;
if (platform) { if (platform) {
this.adminService this.adminService
.putPlatform(platform) .putPlatform(platform)

View File

@ -1,4 +1,14 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -11,18 +21,54 @@ import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-platform-dialog.scss'], styleUrls: ['./create-or-update-platform-dialog.scss'],
templateUrl: 'create-or-update-platform-dialog.html' templateUrl: 'create-or-update-platform-dialog.html'
}) })
export class CreateOrUpdatePlatformDialog { export class CreateOrUpdatePlatformDialog implements OnDestroy {
public platformForm: FormGroup;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog> public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>,
) {} private formBuilder: FormBuilder
) {
this.platformForm = this.formBuilder.group({
name: [this.data.platform.name, Validators.required],
url: [this.data.platform.url, Validators.required]
});
}
public onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public async onSubmit() {
try {
const platform: CreatePlatformDto | UpdatePlatformDto = {
name: this.platformForm.get('name')?.value,
url: this.platformForm.get('url')?.value
};
if (this.data.platform.id) {
(platform as UpdatePlatformDto).id = this.data.platform.id;
await validateObjectForForm({
classDto: UpdatePlatformDto,
form: this.platformForm,
object: platform
});
} else {
await validateObjectForForm({
classDto: CreatePlatformDto,
form: this.platformForm,
object: platform
});
}
this.dialogRef.close(platform);
} catch (error) {
console.error(error);
}
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -1,17 +1,30 @@
<form #addPlatformForm="ngForm" class="d-flex flex-column h-100"> <form
class="d-flex flex-column h-100"
[formGroup]="platformForm"
(keyup.enter)="platformForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.platform.id" i18n mat-dialog-title>Update platform</h1> <h1 *ngIf="data.platform.id" i18n mat-dialog-title>Update platform</h1>
<h1 *ngIf="!data.platform.id" i18n mat-dialog-title>Add platform</h1> <h1 *ngIf="!data.platform.id" i18n mat-dialog-title>Add platform</h1>
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label> <mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.platform.name" /> <input
formControlName="name"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Url</mat-label> <mat-label i18n>Url</mat-label>
<input matInput name="url" required [(ngModel)]="data.platform.url" /> <input
formControlName="url"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
@if (data.platform.url) { @if (data.platform.url) {
<gf-asset-profile-icon <gf-asset-profile-icon
class="mr-3" class="mr-3"
@ -23,12 +36,12 @@
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
[disabled]="!addPlatformForm.form.valid" type="submit"
[mat-dialog-close]="data" [disabled]="!platformForm.valid"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

View File

@ -142,9 +142,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((tag: CreateTagDto | null) => {
const tag: CreateTagDto = data?.tag;
if (tag) { if (tag) {
this.adminService this.adminService
.postTag(tag) .postTag(tag)
@ -180,9 +178,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((tag: UpdateTagDto | null) => {
const tag: UpdateTagDto = data?.tag;
if (tag) { if (tag) {
this.adminService this.adminService
.putTag(tag) .putTag(tag)

View File

@ -1,4 +1,14 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -11,18 +21,52 @@ import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-tag-dialog.scss'], styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html' templateUrl: 'create-or-update-tag-dialog.html'
}) })
export class CreateOrUpdateTagDialog { export class CreateOrUpdateTagDialog implements OnDestroy {
public tagForm: FormGroup;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog> public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>,
) {} private formBuilder: FormBuilder
) {
this.tagForm = this.formBuilder.group({
name: [this.data.tag.name]
});
}
public onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public async onSubmit() {
try {
const tag: CreateTagDto | UpdateTagDto = {
name: this.tagForm.get('name')?.value
};
if (this.data.tag.id) {
(tag as UpdateTagDto).id = this.data.tag.id;
await validateObjectForForm({
classDto: UpdateTagDto,
form: this.tagForm,
object: tag
});
} else {
await validateObjectForForm({
classDto: CreateTagDto,
form: this.tagForm,
object: tag
});
}
this.dialogRef.close(tag);
} catch (error) {
console.error(error);
}
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -1,21 +1,30 @@
<form #addTagForm="ngForm" class="d-flex flex-column h-100"> <form
class="d-flex flex-column h-100"
[formGroup]="tagForm"
(keyup.enter)="tagForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1> <h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1>
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1> <h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1>
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label> <mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.tag.name" /> <input
formControlName="name"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
[disabled]="!addTagForm.form.valid" type="submit"
[mat-dialog-close]="data" [disabled]="!tagForm.valid"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

View File

@ -116,7 +116,12 @@
#assistantTrigger="matMenuTrigger" #assistantTrigger="matMenuTrigger"
class="h-100 no-min-width px-2" class="h-100 no-min-width px-2"
mat-button mat-button
matBadge="✓"
matBadgeSize="small"
[mat-menu-trigger-for]="assistantMenu" [mat-menu-trigger-for]="assistantMenu"
[matBadgeHidden]="
!hasFilters || !user?.settings?.isExperimentalFeatures
"
[matMenuTriggerRestoreFocus]="false" [matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()" (menuOpened)="onOpenAssistant()"
> >

View File

@ -28,6 +28,17 @@
text-underline-offset: 0.25rem; text-underline-offset: 0.25rem;
} }
&.mat-badge {
::ng-deep {
.mat-badge-content {
--mat-badge-small-size-container-overlap-offset: -0.9rem;
--mat-badge-small-size-text-size: 0;
transform: scale(0.45);
}
}
}
ion-icon { ion-icon {
font-size: 1.5rem; font-size: 1.5rem;

View File

@ -65,6 +65,7 @@ export class HeaderComponent implements OnChanges {
@ViewChild('assistant') assistantElement: GfAssistantComponent; @ViewChild('assistant') assistantElement: GfAssistantComponent;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger; @ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
public hasFilters: boolean;
public hasPermissionForSocialLogin: boolean; public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
@ -106,6 +107,8 @@ export class HeaderComponent implements OnChanges {
} }
public ngOnChanges() { public ngOnChanges() {
this.hasFilters = this.userService.hasFilters();
this.hasPermissionForSocialLogin = hasPermission( this.hasPermissionForSocialLogin = hasPermission(
this.info?.globalPermissions, this.info?.globalPermissions,
permissions.enableSocialLogin permissions.enableSocialLogin

View File

@ -5,6 +5,7 @@ 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';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
@ -21,6 +22,7 @@ import { HeaderComponent } from './header.component';
GfLogoComponent, GfLogoComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
LoginWithAccessTokenDialogModule, LoginWithAccessTokenDialogModule,
MatBadgeModule,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatToolbarModule, MatToolbarModule,

View File

@ -1,33 +1,30 @@
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
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 { Position, User } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { HoldingType, ToggleOption } from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DataSource } from '@prisma/client';
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';
import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces';
@Component({ @Component({
selector: 'gf-home-holdings', selector: 'gf-home-holdings',
styleUrls: ['./home-holdings.scss'], styleUrls: ['./home-holdings.scss'],
templateUrl: './home-holdings.html' templateUrl: './home-holdings.html'
}) })
export class HomeHoldingsComponent implements OnDestroy, OnInit { export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public positions: Position[]; public holdings: PortfolioPosition[];
public holdingType: HoldingType = 'ACTIVE';
public holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' }
];
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -36,25 +33,18 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService private userService: UserService
) { ) {}
this.route.queryParams
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((impersonationId) => {
if ( this.hasImpersonationId = !!impersonationId;
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
}); });
this.userService.stateChanged this.userService.stateChanged
@ -68,37 +58,32 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
permissions.createOrder permissions.createOrder
); );
this.update(); this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
} }
}); });
} }
public ngOnInit() { public onChangeHoldingType(aHoldingType: HoldingType) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.holdingType = aHoldingType;
this.impersonationStorageService this.holdings = undefined;
.onChangeHasImpersonation()
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => { .subscribe(({ holdings }) => {
this.hasImpersonationId = !!impersonationId; this.holdings = holdings;
});
}
public onChangeDateRange(dateRange: DateRange) { this.changeDetectorRef.markForCheck();
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
}); });
} }
@ -107,59 +92,16 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openPositionDialog({ private fetchHoldings() {
dataSource, const filters = this.userService.getFilters();
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, { if (this.holdingType === 'CLOSED') {
autoFocus: false, filters.push({ id: 'CLOSED', type: 'HOLDING_TYPE' });
data: <PositionDetailDialogParams>{ }
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef return this.dataService.fetchPortfolioHoldings({
.afterClosed() filters,
.pipe(takeUntil(this.unsubscribeSubject)) range: this.user?.settings?.dateRange
.subscribe(() => { });
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private update() {
this.positions = undefined;
this.dataService
.fetchPositions({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => {
this.positions = positions;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
} }
} }

View File

@ -1,27 +1,38 @@
<div class="container justify-content-center p-3"> <div class="container">
<div class="row"> <div class="row">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2"> <div class="col">
<mat-card appearance="outlined"> <h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Holdings</h1>
<mat-card-content class="p-0"> </div>
<gf-positions </div>
[baseCurrency]="user?.settings?.baseCurrency" <div class="row">
[deviceType]="deviceType" <div class="col-lg">
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder" <div class="d-flex justify-content-end">
[locale]="user?.settings?.locale" <gf-toggle
[positions]="positions" class="d-none d-lg-block"
[range]="user?.settings?.dateRange" [defaultValue]="holdingType"
/> [isLoading]="false"
</mat-card-content> [options]="holdingTypeOptions"
</mat-card> (change)="onChangeHoldingType($event.value)"
<div *ngIf="hasPermissionToCreateOrder" class="text-center"> />
<a
class="mt-3"
i18n
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>
</div> </div>
<gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[holdings]="holdings"
[locale]="user?.settings?.locale"
/>
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
<div class="text-center">
<a
class="mt-3"
i18n
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>
</div>
}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,11 +1,9 @@
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
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 { 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 { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { HomeHoldingsComponent } from './home-holdings.component'; import { HomeHoldingsComponent } from './home-holdings.component';
@ -14,11 +12,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
declarations: [HomeHoldingsComponent], declarations: [HomeHoldingsComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfPositionDetailDialogModule, GfHoldingsTableComponent,
GfPositionsModule,
GfToggleModule, GfToggleModule,
MatButtonModule, MatButtonModule,
MatCardModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -11,7 +11,7 @@
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[isAnimated]="true" [isAnimated]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale || undefined"
[showXAxis]="true" [showXAxis]="true"
[showYAxis]="true" [showYAxis]="true"
[yMax]="100" [yMax]="100"
@ -30,7 +30,7 @@
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark <gf-benchmark
[benchmarks]="benchmarks" [benchmarks]="benchmarks"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale || undefined"
[user]="user" [user]="user"
/> />
<ngx-skeleton-loader <ngx-skeleton-loader

View File

@ -41,9 +41,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]=" [value]="
isLoading isLoading ? undefined : performance?.netPerformanceWithCurrencyEffect
? undefined
: performance?.currentNetPerformanceWithCurrencyEffect
" "
/> />
</div> </div>
@ -55,7 +53,7 @@
[value]=" [value]="
isLoading isLoading
? undefined ? undefined
: performance?.currentNetPerformancePercentWithCurrencyEffect : performance?.netPerformancePercentageWithCurrencyEffect
" "
/> />
</div> </div>

View File

@ -49,12 +49,12 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
this.value.nativeElement.innerHTML = ''; this.value.nativeElement.innerHTML = '';
} }
} else { } else {
if (isNumber(this.performance?.currentValue)) { if (isNumber(this.performance?.currentValueInBaseCurrency)) {
new CountUp('value', this.performance?.currentValue, { new CountUp('value', this.performance?.currentValueInBaseCurrency, {
decimal: getNumberFormatDecimal(this.locale), decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: decimalPlaces:
this.deviceType === 'mobile' && this.deviceType === 'mobile' &&
this.performance?.currentValue >= 100000 this.performance?.currentValueInBaseCurrency >= 100000
? 0 ? 0
: 2, : 2,
duration: 1, duration: 1,
@ -63,8 +63,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
} else if (this.showDetails === false) { } else if (this.showDetails === false) {
new CountUp( new CountUp(
'value', 'value',
this.performance?.currentNetPerformancePercentWithCurrencyEffect * this.performance?.netPerformancePercentageWithCurrencyEffect * 100,
100,
{ {
decimal: getNumberFormatDecimal(this.locale), decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: 2, decimalPlaces: 2,

View File

@ -9,9 +9,19 @@
class="flex-nowrap px-3 py-1 row" class="flex-nowrap px-3 py-1 row"
[hidden]="summary?.ordersCount === null" [hidden]="summary?.ordersCount === null"
> >
<div class="flex-grow-1 ml-3 text-truncate" i18n> <div class="d-flex flex-grow-1 ml-3 text-truncate">
{{ summary?.ordersCount }} {{ summary?.ordersCount }}
{summary?.ordersCount, plural, =1 {transaction} other {transactions}} <ng-container i18n>{summary?.ordersCount, plural,
=1 {activity}
other {activities}
}</ng-container>
<span
class="align-items-center d-flex ml-1"
matTooltipPosition="above"
[matTooltip]="buyAndSellActivitiesTooltip"
>
<ion-icon name="information-circle-outline" />
</span>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -65,9 +75,7 @@
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]=" [value]="
isLoading isLoading ? undefined : summary?.grossPerformanceWithCurrencyEffect
? undefined
: summary?.currentGrossPerformanceWithCurrencyEffect
" "
/> />
</div> </div>
@ -91,7 +99,7 @@
[value]=" [value]="
isLoading isLoading
? undefined ? undefined
: summary?.currentGrossPerformancePercentWithCurrencyEffect : summary?.grossPerformancePercentageWithCurrencyEffect
" "
/> />
</div> </div>
@ -121,9 +129,7 @@
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]=" [value]="
isLoading isLoading ? undefined : summary?.netPerformanceWithCurrencyEffect
? undefined
: summary?.currentNetPerformanceWithCurrencyEffect
" "
/> />
</div> </div>
@ -147,7 +153,7 @@
[value]=" [value]="
isLoading isLoading
? undefined ? undefined
: summary?.currentNetPerformancePercentWithCurrencyEffect : summary?.netPerformancePercentageWithCurrencyEffect
" "
/> />
</div> </div>
@ -164,7 +170,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentValue" [value]="isLoading ? undefined : summary?.currentValueInBaseCurrency"
/> />
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper'; import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper';
import { PortfolioSummary } from '@ghostfolio/common/interfaces'; import { PortfolioSummary } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -28,6 +29,9 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
@Output() emergencyFundChanged = new EventEmitter<number>(); @Output() emergencyFundChanged = new EventEmitter<number>();
public buyAndSellActivitiesTooltip = translate(
'BUY_AND_SELL_ACTIVITIES_TOOLTIP'
);
public timeInMarket: string; public timeInMarket: string;
public constructor() {} public constructor() {}

View File

@ -2,13 +2,14 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
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 { MatTooltipModule } from '@angular/material/tooltip';
import { PortfolioSummaryComponent } from './portfolio-summary.component'; import { PortfolioSummaryComponent } from './portfolio-summary.component';
@NgModule({ @NgModule({
declarations: [PortfolioSummaryComponent], declarations: [PortfolioSummaryComponent],
exports: [PortfolioSummaryComponent], exports: [PortfolioSummaryComponent],
imports: [CommonModule, GfValueComponent], imports: [CommonModule, GfValueComponent, MatTooltipModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfPortfolioSummaryModule {} export class GfPortfolioSummaryModule {}

View File

@ -56,6 +56,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
public netPerformance: number;
public netPerformancePercent: number;
public netPerformancePercentWithCurrencyEffect: number; public netPerformancePercentWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffect: number; public netPerformanceWithCurrencyEffect: number;
public quantity: number; public quantity: number;
@ -104,6 +106,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
netPerformance,
netPerformancePercent,
netPerformancePercentWithCurrencyEffect, netPerformancePercentWithCurrencyEffect,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
orders, orders,
@ -126,15 +130,15 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
(historicalDataItem) => { ({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
date: historicalDataItem.date, date,
value: historicalDataItem.averagePrice value: averagePrice
}); });
return { return {
date: historicalDataItem.date, date,
value: historicalDataItem.marketPrice value: marketPrice
}; };
} }
); );
@ -142,6 +146,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.maxPrice = maxPrice; this.maxPrice = maxPrice;
this.minPrice = minPrice; this.minPrice = minPrice;
this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent;
this.netPerformancePercentWithCurrencyEffect = this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect; netPerformancePercentWithCurrencyEffect;
this.netPerformanceWithCurrencyEffect = this.netPerformanceWithCurrencyEffect =

View File

@ -37,27 +37,58 @@
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value @if (
i18n SymbolProfile?.currency &&
size="medium" data.baseCurrency !== SymbolProfile?.currency
[colorizeSign]="true" ) {
[isCurrency]="true" <gf-value
[locale]="data.locale" i18n
[unit]="data.baseCurrency" size="medium"
[value]="netPerformanceWithCurrencyEffect" [colorizeSign]="true"
>Change</gf-value [isCurrency]="true"
> [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformanceWithCurrencyEffect"
>Change with currency effect</gf-value
>
} @else {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformance"
>Change</gf-value
>
}
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value @if (
i18n SymbolProfile?.currency &&
size="medium" data.baseCurrency !== SymbolProfile?.currency
[colorizeSign]="true" ) {
[isPercent]="true" <gf-value
[locale]="data.locale" i18n
[value]="netPerformancePercentWithCurrencyEffect" size="medium"
>Performance</gf-value [colorizeSign]="true"
> [isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercentWithCurrencyEffect"
>Performance with currency effect</gf-value
>
} @else {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercent"
>Performance</gf-value
>
}
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
@ -304,6 +335,7 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]=" [hasPermissionToExportActivities]="
!data.hasImpersonationId && !user?.settings?.isRestrictedView !data.hasImpersonationId && !user?.settings?.isRestrictedView
" "

View File

@ -1,72 +0,0 @@
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex p-3 w-100"
[ngClass]="{ 'cursor-default': isLoading }"
[queryParams]="{
dataSource: position?.dataSource,
positionDetailDialog: true,
symbol: position?.symbol
}"
[routerLink]="[]"
>
<div class="d-flex mr-2">
<gf-trend-indicator
class="d-flex"
size="large"
[isLoading]="isLoading"
[marketState]="position?.marketState"
[range]="range"
[value]="position?.netPerformancePercentageWithCurrencyEffect"
/>
</div>
<div *ngIf="isLoading" class="flex-grow-1">
<ngx-skeleton-loader
animation="pulse"
class="mb-1"
[theme]="{
height: '1.2rem',
width: '12rem'
}"
/>
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1rem',
width: '8rem'
}"
/>
</div>
<div *ngIf="!isLoading" class="flex-grow-1 text-truncate">
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
<div class="d-flex">
<small class="text-muted">{{ position?.symbol | gfSymbol }}</small>
</div>
<div class="d-flex mt-1">
<gf-value
class="mr-3"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="position?.netPerformanceWithCurrencyEffect"
/>
<gf-value
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="position?.netPerformancePercentageWithCurrencyEffect"
/>
</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
*ngIf="!isLoading"
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
/>
</div>
</a>
</div>
</div>

View File

@ -1,13 +0,0 @@
:host {
display: block;
.container {
gf-trend-indicator {
padding-top: 0.15rem;
}
.chevron {
opacity: 0.33;
}
}
}

View File

@ -1,40 +0,0 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getLocale } from '@ghostfolio/common/helper';
import { Position } from '@ghostfolio/common/interfaces';
import {
ChangeDetectionStrategy,
Component,
Input,
OnDestroy,
OnInit
} from '@angular/core';
import { Subject } from 'rxjs';
@Component({
selector: 'gf-position',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './position.component.html',
styleUrls: ['./position.component.scss']
})
export class PositionComponent implements OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() isLoading: boolean;
@Input() locale = getLocale();
@Input() position: Position;
@Input() range: string;
public unknownKey = UNKNOWN_KEY;
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public ngOnInit() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,29 +0,0 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfTrendIndicatorComponent } from '@ghostfolio/ui/trend-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatDialogModule } from '@angular/material/dialog';
import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfPositionDetailDialogModule } from './position-detail-dialog/position-detail-dialog.module';
import { PositionComponent } from './position.component';
@NgModule({
declarations: [PositionComponent],
exports: [PositionComponent],
imports: [
CommonModule,
GfPositionDetailDialogModule,
GfSymbolModule,
GfTrendIndicatorComponent,
GfValueComponent,
MatDialogModule,
NgxSkeletonLoaderModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionModule {}

View File

@ -1,35 +0,0 @@
<div class="container p-0">
<div class="row no-gutters">
<div class="col">
<ng-container *ngIf="positions === undefined">
<gf-position [isLoading]="true" />
</ng-container>
<ng-container *ngIf="positions !== undefined">
<ng-container *ngIf="hasPositions">
<gf-position
*ngFor="let position of positionsWithPriority"
[baseCurrency]="baseCurrency"
[deviceType]="deviceType"
[locale]="locale"
[position]="position"
[range]="range"
/>
<gf-position
*ngFor="let position of positionsRest"
[baseCurrency]="baseCurrency"
[deviceType]="deviceType"
[locale]="locale"
[position]="position"
[range]="range"
/>
</ng-container>
<div
*ngIf="hasPermissionToCreateOrder && !hasPositions"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator [hasBorder]="false" />
</div>
</ng-container>
</div>
</div>
</div>

View File

@ -1,17 +0,0 @@
:host {
display: block;
gf-position {
&:nth-child(even) {
background-color: rgba(0, 0, 0, var(--gf-theme-alpha-hover));
}
}
}
:host-context(.is-dark-theme) {
gf-position {
&:nth-child(even) {
background-color: rgba(255, 255, 255, var(--gf-theme-alpha-hover));
}
}
}

View File

@ -1,70 +0,0 @@
import { getLocale } from '@ghostfolio/common/helper';
import { Position } from '@ghostfolio/common/interfaces';
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
@Component({
selector: 'gf-positions',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './positions.component.html',
styleUrls: ['./positions.component.scss']
})
export class PositionsComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale = getLocale();
@Input() positions: Position[];
@Input() range: string;
public hasPositions: boolean;
public positionsRest: Position[] = [];
public positionsWithPriority: Position[] = [];
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.positions) {
this.hasPositions = this.positions.length > 0;
if (!this.hasPositions) {
return;
}
this.positionsRest = [];
this.positionsWithPriority = [];
for (const portfolioPosition of this.positions) {
if (portfolioPosition.marketState === 'open' || this.range !== '1d') {
// Only show positions where the market is open in today's view
this.positionsWithPriority.push(portfolioPosition);
} else {
this.positionsRest.push(portfolioPosition);
}
}
this.positionsRest.sort((a, b) =>
(a.name || a.symbol)?.toLowerCase() >
(b.name || b.symbol)?.toLowerCase()
? 1
: -1
);
this.positionsWithPriority.sort((a, b) =>
(a.name || a.symbol)?.toLowerCase() >
(b.name || b.symbol)?.toLowerCase()
? 1
: -1
);
} else {
this.hasPositions = false;
}
}
}

View File

@ -1,21 +0,0 @@
import { GfNoTransactionsInfoComponent } from '@ghostfolio/ui/no-transactions-info';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { GfPositionModule } from '../position/position.module';
import { PositionsComponent } from './positions.component';
@NgModule({
declarations: [PositionsComponent],
exports: [PositionsComponent],
imports: [
CommonModule,
GfNoTransactionsInfoComponent,
GfPositionModule,
MatButtonModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionsModule {}

View File

@ -6,7 +6,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfPositionModule } from '../position/position.module';
import { RulesComponent } from './rules.component'; import { RulesComponent } from './rules.component';
@NgModule({ @NgModule({
@ -15,7 +14,6 @@ import { RulesComponent } from './rules.component';
imports: [ imports: [
CommonModule, CommonModule,
GfNoTransactionsInfoComponent, GfNoTransactionsInfoComponent,
GfPositionModule,
GfRuleModule, GfRuleModule,
MatButtonModule, MatButtonModule,
MatCardModule MatCardModule

View File

@ -1,5 +1,6 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -40,22 +41,22 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
alias: [this.data.access.alias], alias: [this.data.access.alias],
permissions: [this.data.access.permissions[0], Validators.required], permissions: [this.data.access.permissions[0], Validators.required],
type: [this.data.access.type, Validators.required], type: [this.data.access.type, Validators.required],
userId: [this.data.access.grantee, Validators.required] granteeUserId: [this.data.access.grantee, Validators.required]
}); });
this.accessForm.get('type').valueChanges.subscribe((accessType) => { this.accessForm.get('type').valueChanges.subscribe((accessType) => {
const granteeUserIdControl = this.accessForm.get('granteeUserId');
const permissionsControl = this.accessForm.get('permissions'); const permissionsControl = this.accessForm.get('permissions');
const userIdControl = this.accessForm.get('userId');
if (accessType === 'PRIVATE') { if (accessType === 'PRIVATE') {
granteeUserIdControl.setValidators(Validators.required);
permissionsControl.setValidators(Validators.required); permissionsControl.setValidators(Validators.required);
userIdControl.setValidators(Validators.required);
} else { } else {
userIdControl.clearValidators(); granteeUserIdControl.clearValidators();
} }
granteeUserIdControl.updateValueAndValidity();
permissionsControl.updateValueAndValidity(); permissionsControl.updateValueAndValidity();
userIdControl.updateValueAndValidity();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -65,28 +66,38 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
this.dialogRef.close(); this.dialogRef.close();
} }
public onSubmit() { public async onSubmit() {
const access: CreateAccessDto = { const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value, alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('userId').value, granteeUserId: this.accessForm.get('granteeUserId').value,
permissions: [this.accessForm.get('permissions').value] permissions: [this.accessForm.get('permissions').value]
}; };
this.dataService try {
.postAccess(access) await validateObjectForForm({
.pipe( classDto: CreateAccessDto,
catchError((error) => { form: this.accessForm,
if (error.status === StatusCodes.BAD_REQUEST) { object: access
alert($localize`Oops! Could not grant access.`);
}
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close({ access });
}); });
this.dataService
.postAccess(access)
.pipe(
catchError((error) => {
if (error.status === StatusCodes.BAD_REQUEST) {
alert($localize`Oops! Could not grant access.`);
}
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close(access);
});
} catch (error) {
console.error(error);
}
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -45,7 +45,7 @@
Ghostfolio <ng-container i18n>User ID</ng-container> Ghostfolio <ng-container i18n>User ID</ng-container>
</mat-label> </mat-label>
<input <input
formControlName="userId" formControlName="granteeUserId"
matInput matInput
type="text" type="text"
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"

View File

@ -1,3 +1,4 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
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 { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
@ -20,6 +21,7 @@ import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/cre
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
selector: 'gf-user-account-access', selector: 'gf-user-account-access',
styleUrls: ['./user-account-access.scss'], styleUrls: ['./user-account-access.scss'],
templateUrl: './user-account-access.html' templateUrl: './user-account-access.html'
@ -113,7 +115,7 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef.afterClosed().subscribe((access) => { dialogRef.afterClosed().subscribe((access: CreateAccessDto | null) => {
if (access) { if (access) {
this.update(); this.update();
} }

View File

@ -54,9 +54,10 @@ export class AuthGuard {
this.router.navigate(['/' + $localize`register`]); this.router.navigate(['/' + $localize`register`]);
resolve(false); resolve(false);
} else if ( } else if (
AuthGuard.PUBLIC_PAGE_ROUTES.filter((publicPageRoute) => AuthGuard.PUBLIC_PAGE_ROUTES.filter((publicPageRoute) => {
state.url.startsWith(publicPageRoute) const [, url] = state.url.split('/');
)?.length > 0 return `/${url}` === publicPageRoute;
})?.length > 0
) { ) {
resolve(true); resolve(true);
return EMPTY; return EMPTY;

View File

@ -77,7 +77,7 @@
mat-icon-button mat-icon-button
title="Follow Ghostfolio on X (formerly Twitter)" title="Follow Ghostfolio on X (formerly Twitter)"
> >
<span class="line-height-1 text-center w-100">𝕏</span> <ion-icon name="logo-x" />
</a> </a>
<a <a
*ngIf="user?.subscription?.type === 'Premium'" *ngIf="user?.subscription?.type === 'Premium'"

View File

@ -21,7 +21,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/c
import { TransferBalanceDialog } from './transfer-balance/transfer-balance-dialog.component'; import { TransferBalanceDialog } from './transfer-balance/transfer-balance-dialog.component';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'has-fab page' },
selector: 'gf-accounts-page', selector: 'gf-accounts-page',
styleUrls: ['./accounts-page.scss'], styleUrls: ['./accounts-page.scss'],
templateUrl: './accounts-page.html' templateUrl: './accounts-page.html'
@ -189,9 +189,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => { .subscribe((account: UpdateAccountDto | null) => {
const account: UpdateAccountDto = data?.account;
if (account) { if (account) {
this.dataService this.dataService
.putAccount(account) .putAccount(account)
@ -258,9 +256,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => { .subscribe((account: CreateAccountDto | null) => {
const account: CreateAccountDto = data?.account;
if (account) { if (account) {
this.dataService this.dataService
.postAccount(account) .postAccount(account)

View File

@ -123,6 +123,8 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
form: this.accountForm, form: this.accountForm,
object: account object: account
}); });
this.dialogRef.close(account as UpdateAccountDto);
} else { } else {
delete (account as CreateAccountDto).id; delete (account as CreateAccountDto).id;
@ -131,9 +133,9 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
form: this.accountForm, form: this.accountForm,
object: account object: account
}); });
}
this.dialogRef.close({ account }); this.dialogRef.close(account as CreateAccountDto);
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }

View File

@ -30,8 +30,10 @@
systems, including systems, including
<a href="https://github.com/bigbeartechworld/big-bear-casaos" <a href="https://github.com/bigbeartechworld/big-bear-casaos"
>CasaOS</a >CasaOS</a
>, Home Assistant, >,
<a href="https://www.runtipi.io/docs/apps-available">Runtipi</a>, <a href="https://github.com/lildude/ha-addon-ghostfolio"
>Home Assistant</a
>, <a href="https://www.runtipi.io/docs/apps-available">Runtipi</a>,
<a href="https://truecharts.org/charts/stable/ghostfolio" <a href="https://truecharts.org/charts/stable/ghostfolio"
>TrueCharts</a >TrueCharts</a
>, <a href="https://apps.umbrel.com/app/ghostfolio">Umbrel</a>, and >, <a href="https://apps.umbrel.com/app/ghostfolio">Umbrel</a>, and

View File

@ -22,6 +22,11 @@ const routes: Routes = [
component: HomeHoldingsComponent, component: HomeHoldingsComponent,
title: $localize`Holdings` title: $localize`Holdings`
}, },
{
path: 'holdings',
component: HomeHoldingsComponent,
title: $localize`Holdings`
},
{ {
path: 'summary', path: 'summary',
component: HomeSummaryComponent, component: HomeSummaryComponent,

View File

@ -1,3 +1,4 @@
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 { TabConfiguration, User } from '@ghostfolio/common/interfaces'; import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
@ -14,6 +15,7 @@ import { takeUntil } from 'rxjs/operators';
}) })
export class HomePageComponent implements OnDestroy, OnInit { export class HomePageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean;
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
public user: User; public user: User;
@ -22,6 +24,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
@ -59,6 +62,13 @@ export class HomePageComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -1,8 +1,6 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service'; import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -18,7 +16,7 @@ import { PageEvent } from '@angular/material/paginator';
import { Sort, SortDirection } from '@angular/material/sort'; import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { DataSource, Order as OrderModel } from '@prisma/client'; import { Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@ -29,6 +27,7 @@ import { ImportActivitiesDialog } from './import-activities-dialog/import-activi
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces'; import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
@Component({ @Component({
host: { class: 'has-fab' },
selector: 'gf-activities-page', selector: 'gf-activities-page',
styleUrls: ['./activities-page.scss'], styleUrls: ['./activities-page.scss'],
templateUrl: './activities-page.html' templateUrl: './activities-page.html'
@ -82,15 +81,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
} else { } else {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
} else if (
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
} }
}); });
} }
@ -152,32 +142,24 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
this.openCreateActivityDialog(aActivity); this.openCreateActivityDialog(aActivity);
} }
public onDeleteActivity(aId: string) { public onDeleteActivities() {
this.dataService this.dataService
.deleteOrder(aId) .deleteActivities({
filters: this.userService.getFilters()
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe({ .subscribe(() => {
next: () => { this.fetchActivities();
this.fetchActivities();
}
}); });
} }
public onDeleteAllActivities() { public onDeleteActivity(aId: string) {
const confirmation = confirm( this.dataService
$localize`Do you really want to delete all your activities?` .deleteActivity(aId)
); .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
if (confirmation) { this.fetchActivities();
this.dataService });
.deleteAllOrders()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchActivities();
}
});
}
} }
public onExport(activityIds?: string[]) { public onExport(activityIds?: string[]) {
@ -287,9 +269,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => { .subscribe((transaction: UpdateOrderDto | null) => {
const transaction: UpdateOrderDto = data?.activity;
if (transaction) { if (transaction) {
this.dataService this.dataService
.putOrder(transaction) .putOrder(transaction)
@ -338,9 +318,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => { .subscribe((transaction: CreateOrderDto | null) => {
const transaction: CreateOrderDto = data?.activity;
if (transaction) { if (transaction) {
this.dataService.postOrder(transaction).subscribe({ this.dataService.postOrder(transaction).subscribe({
next: () => { next: () => {
@ -354,47 +332,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
}); });
} }
private openPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.updateUser(user);
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private updateUser(aUser: User) { private updateUser(aUser: User) {
this.user = aUser; this.user = aUser;

View File

@ -7,6 +7,7 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity" [hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity"
[hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToExportActivities]="!hasImpersonationId"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
@ -19,10 +20,10 @@
[sortColumn]="sortColumn" [sortColumn]="sortColumn"
[sortDirection]="sortDirection" [sortDirection]="sortDirection"
[totalItems]="totalItems" [totalItems]="totalItems"
(activitiesDeleted)="onDeleteActivities()"
(activityDeleted)="onDeleteActivity($event)" (activityDeleted)="onDeleteActivity($event)"
(activityToClone)="onCloneActivity($event)" (activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)" (activityToUpdate)="onUpdateActivity($event)"
(deleteAllActivities)="onDeleteAllActivities()"
(export)="onExport()" (export)="onExport()"
(exportDrafts)="onExportDrafts($event)" (exportDrafts)="onExportDrafts($event)"
(import)="onImport()" (import)="onImport()"

View File

@ -475,6 +475,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
ignoreFields: ['dataSource', 'date'], ignoreFields: ['dataSource', 'date'],
object: activity as UpdateOrderDto object: activity as UpdateOrderDto
}); });
this.dialogRef.close(activity as UpdateOrderDto);
} else { } else {
(activity as CreateOrderDto).updateAccountBalance = (activity as CreateOrderDto).updateAccountBalance =
this.activityForm.get('updateAccountBalance').value; this.activityForm.get('updateAccountBalance').value;
@ -485,9 +487,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
ignoreFields: ['dataSource', 'date'], ignoreFields: ['dataSource', 'date'],
object: activity object: activity
}); });
}
this.dialogRef.close({ activity }); this.dialogRef.close(activity as CreateOrderDto);
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }

View File

@ -2,7 +2,7 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { Position } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { import {
StepperOrientation, StepperOrientation,
@ -43,7 +43,7 @@ export class ImportActivitiesDialog implements OnDestroy {
public deviceType: string; public deviceType: string;
public dialogTitle = $localize`Import Activities`; public dialogTitle = $localize`Import Activities`;
public errorMessages: string[] = []; public errorMessages: string[] = [];
public holdings: Position[] = []; public holdings: PortfolioPosition[] = [];
public importStep: ImportStep = ImportStep.UPLOAD_FILE; public importStep: ImportStep = ImportStep.UPLOAD_FILE;
public isLoading = false; public isLoading = false;
public maxSafeInteger = Number.MAX_SAFE_INTEGER; public maxSafeInteger = Number.MAX_SAFE_INTEGER;
@ -88,7 +88,7 @@ export class ImportActivitiesDialog implements OnDestroy {
this.uniqueAssetForm.get('uniqueAsset').disable(); this.uniqueAssetForm.get('uniqueAsset').disable();
this.dataService this.dataService
.fetchPositions({ .fetchPortfolioHoldings({
filters: [ filters: [
{ {
id: AssetClass.EQUITY, id: AssetClass.EQUITY,
@ -98,8 +98,8 @@ export class ImportActivitiesDialog implements OnDestroy {
range: 'max' range: 'max'
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => { .subscribe(({ holdings }) => {
this.holdings = sortBy(positions, ({ name }) => { this.holdings = sortBy(holdings, ({ name }) => {
return name.toLowerCase(); return name.toLowerCase();
}); });
this.uniqueAssetForm.get('uniqueAsset').enable(); this.uniqueAssetForm.get('uniqueAsset').enable();

View File

@ -126,6 +126,7 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data?.deviceType" [deviceType]="data?.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="false" [hasPermissionToExportActivities]="false"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"

View File

@ -1,7 +1,5 @@
import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
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';
@ -13,7 +11,6 @@ import {
UniqueAsset, UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, MarketAdvanced } from '@ghostfolio/common/types'; import { Market, MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -103,20 +100,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
private router: Router, private router: Router,
private userService: UserService private userService: UserService
) { ) {
route.queryParams this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if (params['accountId'] && params['accountDetailDialog']) { if (params['accountId'] && params['accountDetailDialog']) {
this.openAccountDetailDialog(params['accountId']); this.openAccountDetailDialog(params['accountId']);
} else if (
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
} }
}); });
} }
@ -178,7 +166,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public onSymbolChartClicked({ dataSource, symbol }: UniqueAsset) { public onSymbolChartClicked({ dataSource, symbol }: UniqueAsset) {
if (dataSource && symbol) { if (dataSource && symbol) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }
}); });
} }
} }
@ -551,45 +539,4 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
}); });
} }
private openPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
} }

View File

@ -1,5 +1,3 @@
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component'; import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
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';
@ -8,18 +6,15 @@ import {
HistoricalDataItem, HistoricalDataItem,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformance, PortfolioPerformance,
Position, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GroupBy, ToggleOption } from '@ghostfolio/common/types'; import { GroupBy, ToggleOption } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { SymbolProfile } from '@prisma/client';
import { ActivatedRoute, Router } from '@angular/router';
import { DataSource, SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { isNumber, sortBy } from 'lodash'; import { isNumber, sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -35,7 +30,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public benchmark: Partial<SymbolProfile>; public benchmark: Partial<SymbolProfile>;
public benchmarkDataItems: HistoricalDataItem[] = []; public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
public bottom3: Position[]; public bottom3: PortfolioPosition[];
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public daysInMarket: number; public daysInMarket: number;
public deviceType: string; public deviceType: string;
@ -60,7 +55,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public performanceDataItemsInPercentage: HistoricalDataItem[]; public performanceDataItemsInPercentage: HistoricalDataItem[];
public portfolioEvolutionDataLabel = $localize`Investment`; public portfolioEvolutionDataLabel = $localize`Investment`;
public streaks: PortfolioInvestments['streaks']; public streaks: PortfolioInvestments['streaks'];
public top3: Position[]; public top3: PortfolioPosition[];
public unitCurrentStreak: string; public unitCurrentStreak: string;
public unitLongestStreak: string; public unitLongestStreak: string;
public user: User; public user: User;
@ -70,30 +65,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private dialog: MatDialog,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService private userService: UserService
) { ) {
const { benchmarks } = this.dataService.fetchInfo(); const { benchmarks } = this.dataService.fetchInfo();
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
});
} }
get savingsRate() { get savingsRate() {
@ -212,47 +189,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}); });
} }
private openPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private update() { private update() {
this.isLoadingInvestmentChart = true; this.isLoadingInvestmentChart = true;
@ -308,23 +244,23 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}); });
this.dataService this.dataService
.fetchPositions({ .fetchPortfolioHoldings({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => { .subscribe(({ holdings }) => {
const positionsSorted = sortBy( const holdingsSorted = sortBy(
positions.filter(({ netPerformancePercentageWithCurrencyEffect }) => { holdings.filter(({ netPerformancePercentWithCurrencyEffect }) => {
return isNumber(netPerformancePercentageWithCurrencyEffect); return isNumber(netPerformancePercentWithCurrencyEffect);
}), }),
'netPerformancePercentageWithCurrencyEffect' 'netPerformancePercentWithCurrencyEffect'
).reverse(); ).reverse();
this.top3 = positionsSorted.slice(0, 3); this.top3 = holdingsSorted.slice(0, 3);
if (positions?.length > 3) { if (holdings?.length > 3) {
this.bottom3 = positionsSorted.slice(-3).reverse(); this.bottom3 = holdingsSorted.slice(-3).reverse();
} else { } else {
this.bottom3 = []; this.bottom3 = [];
} }

View File

@ -42,7 +42,7 @@
[value]=" [value]="
isLoadingInvestmentChart isLoadingInvestmentChart
? undefined ? undefined
: performance?.currentNetPerformance : performance?.netPerformance
" "
/> />
</div> </div>
@ -61,7 +61,7 @@
[value]=" [value]="
isLoadingInvestmentChart isLoadingInvestmentChart
? undefined ? undefined
: performance?.currentNetPerformancePercent : performance?.netPerformancePercentage
" "
/> />
</div> </div>
@ -86,10 +86,10 @@
[value]=" [value]="
isLoadingInvestmentChart isLoadingInvestmentChart
? undefined ? undefined
: performance?.currentNetPerformance === null : performance?.netPerformance === null
? null ? null
: performance?.currentNetPerformanceWithCurrencyEffect - : performance?.netPerformanceWithCurrencyEffect -
performance?.currentNetPerformance performance?.netPerformance
" "
/> />
</div> </div>
@ -108,10 +108,10 @@
[value]=" [value]="
isLoadingInvestmentChart isLoadingInvestmentChart
? undefined ? undefined
: performance?.currentNetPerformancePercent === null : performance?.netPerformancePercentage === null
? null ? null
: performance?.currentNetPerformancePercentWithCurrencyEffect - : performance?.netPerformancePercentageWithCurrencyEffect -
performance?.currentNetPerformancePercent performance?.netPerformancePercentage
" "
/> />
</div> </div>
@ -131,7 +131,7 @@
[value]=" [value]="
isLoadingInvestmentChart isLoadingInvestmentChart
? undefined ? undefined
: performance?.currentNetPerformanceWithCurrencyEffect : performance?.netPerformanceWithCurrencyEffect
" "
/> />
</div> </div>
@ -150,7 +150,7 @@
[value]=" [value]="
isLoadingInvestmentChart isLoadingInvestmentChart
? undefined ? undefined
: performance?.currentNetPerformancePercentWithCurrencyEffect : performance?.netPerformancePercentageWithCurrencyEffect
" "
/> />
</div> </div>
@ -170,17 +170,17 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<ol class="mb-0 ml-1 pl-3"> <ol class="mb-0 ml-1 pl-3">
<li *ngFor="let position of top3" class="py-1"> <li *ngFor="let holding of top3" class="py-1">
<a <a
class="d-flex" class="d-flex"
[queryParams]="{ [queryParams]="{
dataSource: position.dataSource, dataSource: holding.dataSource,
positionDetailDialog: true, holdingDetailDialog: true,
symbol: position.symbol symbol: holding.symbol
}" }"
[routerLink]="[]" [routerLink]="[]"
> >
<div class="flex-grow-1 mr-2">{{ position.name }}</div> <div class="flex-grow-1 mr-2">{{ holding.name }}</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -188,9 +188,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]=" [value]="holding.netPerformancePercentWithCurrencyEffect"
position.netPerformancePercentageWithCurrencyEffect
"
/> />
</div> </div>
</a> </a>
@ -218,17 +216,17 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<ol class="mb-0 ml-1 pl-3"> <ol class="mb-0 ml-1 pl-3">
<li *ngFor="let position of bottom3" class="py-1"> <li *ngFor="let holding of bottom3" class="py-1">
<a <a
class="d-flex" class="d-flex"
[queryParams]="{ [queryParams]="{
dataSource: position.dataSource, dataSource: holding.dataSource,
positionDetailDialog: true, holdingDetailDialog: true,
symbol: position.symbol symbol: holding.symbol
}" }"
[routerLink]="[]" [routerLink]="[]"
> >
<div class="flex-grow-1 mr-2">{{ position.name }}</div> <div class="flex-grow-1 mr-2">{{ holding.name }}</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -236,9 +234,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]=" [value]="holding.netPerformancePercentWithCurrencyEffect"
position.netPerformancePercentageWithCurrencyEffect
"
/> />
</div> </div>
</a> </a>

View File

@ -1,21 +0,0 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HoldingsPageComponent } from './holdings-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: HoldingsPageComponent,
path: '',
title: $localize`Holdings`
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HoldingsPageRoutingModule {}

View File

@ -1,171 +0,0 @@
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HoldingType, ToggleOption } from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-holdings-page',
styleUrls: ['./holdings-page.scss'],
templateUrl: './holdings-page.html'
})
export class HoldingsPageComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public holdings: PortfolioPosition[];
public holdingType: HoldingType = 'ACTIVE';
public holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' }
];
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
public onChangeHoldingType(aHoldingType: HoldingType) {
this.holdingType = aHoldingType;
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchHoldings() {
const filters = this.userService.getFilters();
if (this.holdingType === 'CLOSED') {
filters.push({ id: 'CLOSED', type: 'HOLDING_TYPE' });
}
return this.dataService.fetchPortfolioHoldings({
filters,
range: this.user?.settings?.dateRange
});
}
private openPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
}

View File

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

View File

@ -1,22 +0,0 @@
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { HoldingsPageRoutingModule } from './holdings-page-routing.module';
import { HoldingsPageComponent } from './holdings-page.component';
@NgModule({
declarations: [HoldingsPageComponent],
imports: [
CommonModule,
GfHoldingsTableComponent,
GfToggleModule,
HoldingsPageRoutingModule,
MatButtonModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HoldingsPageModule {}

View File

@ -1,3 +0,0 @@
:host {
display: block;
}

View File

@ -16,13 +16,6 @@ const routes: Routes = [
(m) => m.AnalysisPageModule (m) => m.AnalysisPageModule
) )
}, },
{
path: 'holdings',
loadChildren: () =>
import('./holdings/holdings-page.module').then(
(m) => m.HoldingsPageModule
)
},
{ {
path: 'activities', path: 'activities',
loadChildren: () => loadChildren: () =>

View File

@ -34,11 +34,6 @@ export class PortfolioPageComponent implements OnDestroy, OnInit {
label: $localize`Analysis`, label: $localize`Analysis`,
path: ['/portfolio'] path: ['/portfolio']
}, },
{
iconName: 'wallet-outline',
label: $localize`Holdings`,
path: ['/portfolio', 'holdings']
},
{ {
iconName: 'swap-vertical-outline', iconName: 'swap-vertical-outline',
label: $localize`Activities`, label: $localize`Activities`,

View File

@ -66,7 +66,8 @@ export const products: Product[] = [
'Français', 'Français',
'Italiano', 'Italiano',
'Nederlands', 'Nederlands',
'Português' 'Português',
'Türkçe'
], ],
name: 'Ghostfolio', name: 'Ghostfolio',
origin: $localize`Switzerland`, origin: $localize`Switzerland`,

View File

@ -6,7 +6,6 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioPositionDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; import { PortfolioPositionDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface'; import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface';
@ -257,18 +256,20 @@ export class DataService {
return this.http.delete<any>(`/api/v1/account-balance/${aId}`); return this.http.delete<any>(`/api/v1/account-balance/${aId}`);
} }
public deleteAllOrders() { public deleteActivities({ filters }) {
return this.http.delete<any>(`/api/v1/order/`); let params = this.buildFiltersAsQueryParams({ filters });
return this.http.delete<any>(`/api/v1/order`, { params });
}
public deleteActivity(aId: string) {
return this.http.delete<any>(`/api/v1/order/${aId}`);
} }
public deleteBenchmark({ dataSource, symbol }: UniqueAsset) { public deleteBenchmark({ dataSource, symbol }: UniqueAsset) {
return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`); return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`);
} }
public deleteOrder(aId: string) {
return this.http.delete<any>(`/api/v1/order/${aId}`);
}
public deleteUser(aId: string) { public deleteUser(aId: string) {
return this.http.delete<any>(`/api/v1/user/${aId}`); return this.http.delete<any>(`/api/v1/user/${aId}`);
} }
@ -376,21 +377,6 @@ export class DataService {
}); });
} }
public fetchPositions({
filters,
range
}: {
filters?: Filter[];
range: DateRange;
}): Observable<PortfolioPositions> {
let params = this.buildFiltersAsQueryParams({ filters });
params = params.append('range', range);
return this.http.get<PortfolioPositions>('/api/v1/portfolio/positions', {
params
});
}
public fetchSymbols({ public fetchSymbols({
includeIndices = false, includeIndices = false,
query query

View File

@ -51,21 +51,21 @@ export class UserService extends ObservableStore<UserStoreState> {
const filters: Filter[] = []; const filters: Filter[] = [];
const user = this.getState().user; const user = this.getState().user;
if (user.settings['filters.accounts']) { if (user?.settings['filters.accounts']) {
filters.push({ filters.push({
id: user.settings['filters.accounts'][0], id: user.settings['filters.accounts'][0],
type: 'ACCOUNT' type: 'ACCOUNT'
}); });
} }
if (user.settings['filters.assetClasses']) { if (user?.settings['filters.assetClasses']) {
filters.push({ filters.push({
id: user.settings['filters.assetClasses'][0], id: user.settings['filters.assetClasses'][0],
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}); });
} }
if (user.settings['filters.tags']) { if (user?.settings['filters.tags']) {
filters.push({ filters.push({
id: user.settings['filters.tags'][0], id: user.settings['filters.tags'][0],
type: 'TAG' type: 'TAG'
@ -75,6 +75,10 @@ export class UserService extends ObservableStore<UserStoreState> {
return filters; return filters;
} }
public hasFilters() {
return this.getFilters().length > 0;
}
public remove() { public remove() {
this.setState({ user: null }, UserStoreActions.RemoveUser); this.setState({ user: null }, UserStoreActions.RemoveUser);
} }

View File

@ -32,6 +32,14 @@ export async function validateObjectForForm<T>({
validationError: Object.values(constraints)[0] validationError: Object.values(constraints)[0]
}); });
} }
const formControlInCustomCurrency = form.get(`${property}InCustomCurrency`);
if (formControlInCustomCurrency) {
formControlInCustomCurrency.setErrors({
validationError: Object.values(constraints)[0]
});
}
} }
return Promise.reject(nonIgnoredErrors); return Promise.reject(nonIgnoredErrors);

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

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