Browse Source

Feature/modernize rules implementation (#3869)

* Modernize rules implementation

* Include markets in details
main
Thomas Kaul 14 hours ago
committed by GitHub
parent
commit
bcc8cb1e5b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  2. 172
      apps/api/src/app/portfolio/portfolio.service.ts
  3. 19
      apps/api/src/models/rule.ts
  4. 19
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  5. 17
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  6. 2
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  7. 7
      libs/common/src/lib/interfaces/portfolio-details.interface.ts

2
apps/api/src/app/portfolio/portfolio.controller.ts

@ -1,4 +1,3 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
@ -61,7 +60,6 @@ import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
public constructor( public constructor(
private readonly accessService: AccessService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,

172
apps/api/src/app/portfolio/portfolio.service.ts

@ -45,6 +45,7 @@ import type {
AccountWithValue, AccountWithValue,
DateRange, DateRange,
GroupBy, GroupBy,
Market,
RequestWithUser, RequestWithUser,
UserWithSettings UserWithSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -581,6 +582,17 @@ export class PortfolioService {
}; };
} }
let markets: {
[key in Market]: {
name: string;
value: number;
};
};
if (withMarkets) {
markets = this.getAggregatedMarkets(holdings);
}
let summary: PortfolioSummary; let summary: PortfolioSummary;
if (withSummary) { if (withSummary) {
@ -602,6 +614,7 @@ export class PortfolioService {
accounts, accounts,
hasErrors, hasErrors,
holdings, holdings,
markets,
platforms, platforms,
summary summary
}; };
@ -1148,74 +1161,49 @@ export class PortfolioService {
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(impersonationId: string): Promise<PortfolioReport> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId }); const userSettings = <UserSettings>this.request.user.Settings.settings;
const userCurrency = this.getUserCurrency(user);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
userCurrency,
userId
});
const portfolioCalculator = this.calculatorFactory.createCalculator({ const { accounts, holdings, summary } = await this.getDetails({
activities, impersonationId,
userId, userId,
calculationType: PerformanceCalculationType.TWR, withMarkets: true,
currency: this.request.user.Settings.settings.baseCurrency withSummary: true
});
let { totalFeesWithCurrencyEffect, positions, totalInvestment } =
await portfolioCalculator.getSnapshot();
positions = positions.filter((item) => !item.quantity.eq(0));
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
for (const position of positions) {
portfolioItemsNow[position.symbol] = position;
}
const { accounts } = await this.getValueOfAccountsAndPlatforms({
activities,
portfolioItemsNow,
userCurrency,
userId
}); });
const userSettings = <UserSettings>this.request.user.Settings.settings;
return { return {
rules: { rules: {
accountClusterRisk: isEmpty(activities) accountClusterRisk:
? undefined summary.ordersCount > 0
: await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new AccountClusterRiskCurrentInvestment( new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
accounts accounts
), ),
new AccountClusterRiskSingleAccount( new AccountClusterRiskSingleAccount(
this.exchangeRateDataService, this.exchangeRateDataService,
accounts accounts
) )
], ],
userSettings userSettings
), )
currencyClusterRisk: isEmpty(activities) : undefined,
? undefined currencyClusterRisk:
: await this.rulesService.evaluate( summary.ordersCount > 0
[ ? await this.rulesService.evaluate(
new CurrencyClusterRiskBaseCurrencyCurrentInvestment( [
this.exchangeRateDataService, new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
positions this.exchangeRateDataService,
), Object.values(holdings)
new CurrencyClusterRiskCurrentInvestment( ),
this.exchangeRateDataService, new CurrencyClusterRiskCurrentInvestment(
positions this.exchangeRateDataService,
) Object.values(holdings)
], )
userSettings ],
), userSettings
)
: undefined,
emergencyFund: await this.rulesService.evaluate( emergencyFund: await this.rulesService.evaluate(
[ [
new EmergencyFundSetup( new EmergencyFundSetup(
@ -1229,8 +1217,8 @@ export class PortfolioService {
[ [
new FeeRatioInitialInvestment( new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
totalInvestment.toNumber(), summary.committedFunds,
totalFeesWithCurrencyEffect.toNumber() summary.fees
) )
], ],
userSettings userSettings
@ -1257,6 +1245,62 @@ export class PortfolioService {
await this.orderService.assignTags({ dataSource, symbol, tags, userId }); await this.orderService.assignTags({ dataSource, symbol, tags, userId });
} }
private getAggregatedMarkets(holdings: {
[symbol: string]: PortfolioPosition;
}): {
[key in Market]: { name: string; value: number };
} {
const markets = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
},
developedMarkets: {
name: 'developedMarkets',
value: 0
},
emergingMarkets: {
name: 'emergingMarkets',
value: 0
},
otherMarkets: {
name: 'otherMarkets',
value: 0
}
};
for (const [symbol, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency;
if (position.assetClass !== AssetClass.LIQUIDITY) {
if (position.countries.length > 0) {
markets.developedMarkets.value +=
position.markets.developedMarkets * value;
markets.emergingMarkets.value +=
position.markets.emergingMarkets * value;
markets.otherMarkets.value += position.markets.otherMarkets * value;
} else {
markets[UNKNOWN_KEY].value += value;
}
}
}
const marketsTotal =
markets.developedMarkets.value +
markets.emergingMarkets.value +
markets.otherMarkets.value +
markets[UNKNOWN_KEY].value;
markets.developedMarkets.value =
markets.developedMarkets.value / marketsTotal;
markets.emergingMarkets.value =
markets.emergingMarkets.value / marketsTotal;
markets.otherMarkets.value = markets.otherMarkets.value / marketsTotal;
markets[UNKNOWN_KEY].value = markets[UNKNOWN_KEY].value / marketsTotal;
return markets;
}
private async getCashPositions({ private async getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,

19
apps/api/src/models/rule.ts

@ -1,8 +1,9 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
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 { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
import { Big } from 'big.js';
import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface'; import { RuleInterface } from './interfaces/rule.interface';
@ -33,24 +34,26 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
return this.name; return this.name;
} }
public groupCurrentPositionsByAttribute( public groupCurrentHoldingsByAttribute(
positions: TimelinePosition[], holdings: PortfolioPosition[],
attribute: keyof TimelinePosition, attribute: keyof PortfolioPosition,
baseCurrency: string baseCurrency: string
) { ) {
return Array.from(groupBy(attribute, positions).entries()).map( return Array.from(groupBy(attribute, holdings).entries()).map(
([attributeValue, objs]) => ({ ([attributeValue, objs]) => ({
groupKey: attributeValue, groupKey: attributeValue,
investment: objs.reduce( investment: objs.reduce(
(previousValue, currentValue) => (previousValue, currentValue) =>
previousValue + currentValue.investment.toNumber(), previousValue + currentValue.investment,
0 0
), ),
value: objs.reduce( value: objs.reduce(
(previousValue, currentValue) => (previousValue, currentValue) =>
previousValue + previousValue +
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
currentValue.quantity.mul(currentValue.marketPrice).toNumber(), new Big(currentValue.quantity)
.mul(currentValue.marketPrice)
.toNumber(),
currentValue.currency, currentValue.currency,
baseCurrency baseCurrency
), ),

19
apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts

@ -1,35 +1,34 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
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 { UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[]; private holdings: PortfolioPosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
positions: TimelinePosition[] holdings: PortfolioPosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name, key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name,
name: 'Investment: Base Currency' name: 'Investment: Base Currency'
}); });
this.positions = positions; this.holdings = holdings;
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( const holdingsGroupedByCurrency = this.groupCurrentHoldingsByAttribute(
this.positions, this.holdings,
'currency', 'currency',
ruleSettings.baseCurrency ruleSettings.baseCurrency
); );
let maxItem = positionsGroupedByCurrency[0]; let maxItem = holdingsGroupedByCurrency[0];
let totalValue = 0; let totalValue = 0;
positionsGroupedByCurrency.forEach((groupItem) => { holdingsGroupedByCurrency.forEach((groupItem) => {
// Calculate total value // Calculate total value
totalValue += groupItem.value; totalValue += groupItem.value;
@ -39,7 +38,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
} }
}); });
const baseCurrencyItem = positionsGroupedByCurrency.find((item) => { const baseCurrencyItem = holdingsGroupedByCurrency.find((item) => {
return item.groupKey === ruleSettings.baseCurrency; return item.groupKey === ruleSettings.baseCurrency;
}); });

17
apps/api/src/models/rules/currency-cluster-risk/current-investment.ts

@ -1,35 +1,34 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
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 { UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[]; private holdings: PortfolioPosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
positions: TimelinePosition[] holdings: PortfolioPosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: CurrencyClusterRiskCurrentInvestment.name, key: CurrencyClusterRiskCurrentInvestment.name,
name: 'Investment' name: 'Investment'
}); });
this.positions = positions; this.holdings = holdings;
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( const holdingsGroupedByCurrency = this.groupCurrentHoldingsByAttribute(
this.positions, this.holdings,
'currency', 'currency',
ruleSettings.baseCurrency ruleSettings.baseCurrency
); );
let maxItem = positionsGroupedByCurrency[0]; let maxItem = holdingsGroupedByCurrency[0];
let totalValue = 0; let totalValue = 0;
positionsGroupedByCurrency.forEach((groupItem) => { holdingsGroupedByCurrency.forEach((groupItem) => {
// Calculate total value // Calculate total value
totalValue += groupItem.value; totalValue += groupItem.value;

2
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -35,8 +35,6 @@ export class GfRuleSettingsDialogComponent {
@Inject(MAT_DIALOG_DATA) public data: IRuleSettingsDialogParams, @Inject(MAT_DIALOG_DATA) public data: IRuleSettingsDialogParams,
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent> public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>
) { ) {
console.log(this.data.rule);
this.settings = this.data.rule.settings; this.settings = this.data.rule.settings;
} }
} }

7
libs/common/src/lib/interfaces/portfolio-details.interface.ts

@ -2,6 +2,7 @@ import {
PortfolioPosition, PortfolioPosition,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Market } from '@ghostfolio/common/types';
export interface PortfolioDetails { export interface PortfolioDetails {
accounts: { accounts: {
@ -14,6 +15,12 @@ export interface PortfolioDetails {
}; };
}; };
holdings: { [symbol: string]: PortfolioPosition }; holdings: { [symbol: string]: PortfolioPosition };
markets?: {
[key in Market]: {
name: string;
value: number;
};
};
platforms: { platforms: {
[id: string]: { [id: string]: {
balance: number; balance: number;

Loading…
Cancel
Save