Feature/improve experimental chart in account detail dialog (#3813)

* Improve chart in account detail dialog

* Update changelog
pull/3816/head
Thomas Kaul 3 months ago committed by GitHub
parent 4a97e2bb54
commit bb445ddf2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the usability of various action menus by introducing horizontal lines to separate the delete action - Improved the usability of various action menus by introducing horizontal lines to separate the delete action
- Improved the chart in the account detail dialog (experimental)
- Aligned the holdings and regions of the public page with the allocations page - Aligned the holdings and regions of the public page with the allocations page
- Considered the users language in the link of the access table to share the portfolio - Considered the users language in the link of the access table to share the portfolio
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)

@ -2,14 +2,18 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper';
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; import {
import { UserWithSettings } from '@ghostfolio/common/types'; AccountBalancesResponse,
Filter,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto'; import { CreateAccountBalanceDto } from './create-account-balance.dto';
@ -91,17 +95,55 @@ export class AccountBalanceService {
return accountBalance; return accountBalance;
} }
public async getAccountBalanceItems({
filters,
userCurrency,
userId
}: {
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<HistoricalDataItem[]> {
const { balances } = await this.getAccountBalances({
filters,
userCurrency,
userId,
withExcludedAccounts: false // TODO
});
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
{};
const lastBalancesByAccount: { [accountId: string]: Big } = {};
for (const { accountId, date, valueInBaseCurrency } of balances) {
const formattedDate = format(date, DATE_FORMAT);
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);
const totalBalance = getSum(Object.values(lastBalancesByAccount));
// Add or update the accumulated balance for this date
accumulatedBalancesByDate[formattedDate] = {
date: formattedDate,
value: totalBalance.toNumber()
};
}
return Object.values(accumulatedBalancesByDate);
}
@LogPerformance @LogPerformance
public async getAccountBalances({ public async getAccountBalances({
filters, filters,
user, userCurrency,
userId,
withExcludedAccounts withExcludedAccounts
}: { }: {
filters?: Filter[]; filters?: Filter[];
user: UserWithSettings; userCurrency: string;
userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<AccountBalancesResponse> { }): Promise<AccountBalancesResponse> {
const where: Prisma.AccountBalanceWhereInput = { userId: user.id }; const where: Prisma.AccountBalanceWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => { const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT'; return type === 'ACCOUNT';
@ -132,10 +174,11 @@ export class AccountBalanceService {
balances: balances.map((balance) => { balances: balances.map((balance) => {
return { return {
...balance, ...balance,
accountId: balance.Account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value, balance.value,
balance.Account.currency, balance.Account.currency,
user.Settings.settings.baseCurrency userCurrency
) )
}; };
}) })

@ -137,7 +137,8 @@ export class AccountController {
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
user: this.request.user userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
}); });
} }

@ -104,6 +104,10 @@ export abstract class PortfolioCalculator {
let dateOfFirstActivity = new Date(); let dateOfFirstActivity = new Date();
if (this.accountBalanceItems[0]) {
dateOfFirstActivity = parseDate(this.accountBalanceItems[0].date);
}
this.activities = activities this.activities = activities
.map( .map(
({ ({
@ -269,6 +273,10 @@ export abstract class PortfolioCalculator {
) )
}); });
for (const accountBalanceItem of this.accountBalanceItems) {
chartDateMap[accountBalanceItem.date] = true;
}
const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => { const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => {
return chartDate; return chartDate;
}); });
@ -447,9 +455,28 @@ export abstract class PortfolioCalculator {
} }
} }
let lastDate = chartDates[0]; const accountBalanceItemsMap = this.accountBalanceItems.reduce(
(map, { date, value }) => {
map[date] = new Big(value);
return map;
},
{} as { [date: string]: Big }
);
const accountBalanceMap: { [date: string]: Big } = {};
let lastKnownBalance = new Big(0);
for (const dateString of chartDates) { for (const dateString of chartDates) {
if (accountBalanceItemsMap[dateString] !== undefined) {
// If there's an exact balance for this date, update lastKnownBalance
lastKnownBalance = accountBalanceItemsMap[dateString];
}
// Add the most recent balance to the accountBalanceMap
accountBalanceMap[dateString] = lastKnownBalance;
for (const symbol of Object.keys(valuesBySymbol)) { for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol]; const symbolValues = valuesBySymbol[symbol];
@ -492,18 +519,7 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString] accumulatedValuesByDate[dateString]
?.investmentValueWithCurrencyEffect ?? new Big(0) ?.investmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect), ).add(investmentValueWithCurrencyEffect),
totalAccountBalanceWithCurrencyEffect: this.accountBalanceItems.some( totalAccountBalanceWithCurrencyEffect: accountBalanceMap[dateString],
({ date }) => {
return date === dateString;
}
)
? new Big(
this.accountBalanceItems.find(({ date }) => {
return date === dateString;
}).value
)
: (accumulatedValuesByDate[lastDate]
?.totalAccountBalanceWithCurrencyEffect ?? new Big(0)),
totalCurrentValue: ( totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue), ).add(currentValue),
@ -537,8 +553,6 @@ export abstract class PortfolioCalculator {
).add(timeWeightedInvestmentValueWithCurrencyEffect) ).add(timeWeightedInvestmentValueWithCurrencyEffect)
}; };
} }
lastDate = dateString;
} }
const historicalData: HistoricalDataItem[] = Object.entries( const historicalData: HistoricalDataItem[] = Object.entries(
@ -733,12 +747,12 @@ export abstract class PortfolioCalculator {
timeWeightedInvestmentValue === 0 timeWeightedInvestmentValue === 0
? 0 ? 0
: netPerformanceWithCurrencyEffectSinceStartDate / : netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue, timeWeightedInvestmentValue
// TODO: Add net worth with valuables // TODO: Add net worth with valuables
// netWorth: totalCurrentValueWithCurrencyEffect // netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect) // .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber() // .toNumber()
netWorth: 0 // netWorth: 0
}); });
} }
} }
@ -815,7 +829,7 @@ export abstract class PortfolioCalculator {
endDate: Date; endDate: Date;
startDate: Date; startDate: Date;
step: number; step: number;
}) { }): { [date: string]: true } {
// Create a map of all relevant chart dates: // Create a map of all relevant chart dates:
// 1. Add transaction point dates // 1. Add transaction point dates
let chartDateMap = this.transactionPoints.reduce((result, { date }) => { let chartDateMap = this.transactionPoints.reduce((result, { date }) => {

@ -1058,35 +1058,12 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const accountBalances = await this.accountBalanceService.getAccountBalances( const accountBalanceItems =
{ filters, user, withExcludedAccounts } await this.accountBalanceService.getAccountBalanceItems({
); filters,
userId,
let accountBalanceItems: HistoricalDataItem[] = Object.values( userCurrency
// Reduce the array to a map with unique dates as keys });
accountBalances.balances.reduce(
(
map: { [date: string]: HistoricalDataItem },
{ date, valueInBaseCurrency }
) => {
const formattedDate = format(date, DATE_FORMAT);
if (map[formattedDate]) {
// If the value exists, add the current value to the existing one
map[formattedDate].value += valueInBaseCurrency;
} else {
// Otherwise, initialize the value for that date
map[formattedDate] = {
date: formattedDate,
value: valueInBaseCurrency
};
}
return map;
},
{}
)
);
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.orderService.getOrdersForPortfolioCalculator({

@ -1,3 +1,4 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
@ -17,6 +18,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
@Module({ @Module({
exports: [BullModule, PortfolioSnapshotService], exports: [BullModule, PortfolioSnapshotService],
imports: [ imports: [
AccountBalanceModule,
BullModule.registerQueue({ BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_QUEUE name: PORTFOLIO_SNAPSHOT_QUEUE
}), }),

@ -1,3 +1,4 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { import {
PerformanceCalculationType, PerformanceCalculationType,
@ -24,6 +25,7 @@ import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queu
@Processor(PORTFOLIO_SNAPSHOT_QUEUE) @Processor(PORTFOLIO_SNAPSHOT_QUEUE)
export class PortfolioSnapshotProcessor { export class PortfolioSnapshotProcessor {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@ -56,7 +58,15 @@ export class PortfolioSnapshotProcessor {
userId: job.data.userId userId: job.data.userId
}); });
const accountBalanceItems =
await this.accountBalanceService.getAccountBalanceItems({
filters: job.data.filters,
userCurrency: job.data.userCurrency,
userId: job.data.userId
});
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: job.data.userCurrency, currency: job.data.userCurrency,

@ -2,7 +2,7 @@ import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/cre
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 { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
HistoricalDataItem, HistoricalDataItem,
@ -27,7 +27,7 @@ import { Router } from '@angular/router';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { Subject } from 'rxjs'; import { forkJoin, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces'; import { AccountDetailDialogParams } from './interfaces/interfaces';
@ -87,11 +87,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.fetchAccount(); this.initialize();
this.fetchAccountBalances();
this.fetchActivities();
this.fetchPortfolioHoldings();
this.fetchPortfolioPerformance();
} }
public onCloneActivity(aActivity: Activity) { public onCloneActivity(aActivity: Activity) {
@ -111,9 +107,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.postAccountBalance(accountBalance) .postAccountBalance(accountBalance)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.fetchAccount(); this.initialize();
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
}); });
} }
@ -122,9 +116,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.deleteAccountBalance(aId) .deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.fetchAccount(); this.initialize();
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
}); });
} }
@ -198,17 +190,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
); );
} }
private fetchAccountBalances() {
this.dataService
.fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ balances }) => {
this.accountBalances = balances;
this.changeDetectorRef.markForCheck();
});
}
private fetchActivities() { private fetchActivities() {
this.isLoadingActivities = true; this.isLoadingActivities = true;
@ -229,28 +210,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}); });
} }
private fetchPortfolioHoldings() { private fetchChart() {
this.dataService
.fetchPortfolioHoldings({
filters: [
{
type: 'ACCOUNT',
id: this.data.accountId
}
]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
}
private fetchPortfolioPerformance() {
this.isLoadingChart = true; this.isLoadingChart = true;
this.dataService forkJoin({
accountBalances: this.dataService
.fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)),
portfolioPerformance: this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
filters: [ filters: [
{ {
@ -263,22 +230,63 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
withItems: true withItems: true
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => { }).subscribe({
this.historicalDataItems = chart.map( error: () => {
({ date, netWorth, netWorthInPercentage }) => { this.isLoadingChart = false;
return { },
next: ({ accountBalances, portfolioPerformance }) => {
this.accountBalances = accountBalances.balances;
if (portfolioPerformance.chart.length > 0) {
this.historicalDataItems = portfolioPerformance.chart.map(
({ date, netWorth, netWorthInPercentage }) => ({
date, date,
value: isNumber(netWorth) ? netWorth : netWorthInPercentage value: isNumber(netWorth) ? netWorth : netWorthInPercentage
})
);
} else {
this.historicalDataItems = this.accountBalances.map(
({ date, valueInBaseCurrency }) => {
return {
date: format(date, DATE_FORMAT),
value: valueInBaseCurrency
}; };
} }
); );
}
this.isLoadingChart = false; this.isLoadingChart = false;
this.changeDetectorRef.markForCheck();
}
});
}
private fetchPortfolioHoldings() {
this.dataService
.fetchPortfolioHoldings({
filters: [
{
type: 'ACCOUNT',
id: this.data.accountId
}
]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
private initialize() {
this.fetchAccount();
this.fetchActivities();
this.fetchChart();
this.fetchPortfolioHoldings();
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

@ -20,7 +20,7 @@
</div> </div>
</div> </div>
<!-- TODO @if (user?.settings?.isExperimentalFeatures) {
<div class="chart-container mb-3"> <div class="chart-container mb-3">
<gf-investment-chart <gf-investment-chart
class="h-100" class="h-100"
@ -33,7 +33,7 @@
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
</div> </div>
--> }
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">

@ -1,7 +1,7 @@
import { AccountBalance } from '@prisma/client'; import { AccountBalance } from '@prisma/client';
export interface AccountBalancesResponse { export interface AccountBalancesResponse {
balances: (Pick<AccountBalance, 'date' | 'id' | 'value'> & { balances: (Pick<AccountBalance, 'accountId' | 'date' | 'id' | 'value'> & {
valueInBaseCurrency: number; valueInBaseCurrency: number;
})[]; })[];
} }

Loading…
Cancel
Save