Browse Source

Merge branch 'main' into refactor/enable-unused-rules

pull/3895/head
Thomas Kaul 11 months ago
committed by GitHub
parent
commit
7ec3225f07
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 33
      CHANGELOG.md
  2. 2
      apps/api/src/app/endpoints/public/public.controller.ts
  3. 2
      apps/api/src/app/info/info.module.ts
  4. 9
      apps/api/src/app/info/info.service.ts
  5. 6
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  6. 56
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts
  7. 4
      apps/api/src/app/portfolio/portfolio.controller.ts
  8. 7
      apps/api/src/app/portfolio/rules.service.ts
  9. 56
      apps/api/src/app/user/user.service.ts
  10. 10
      apps/api/src/models/rule.ts
  11. 15
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  12. 6
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  13. 6
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  14. 15
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  15. 6
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  16. 15
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  17. 22
      apps/api/src/services/configuration/configuration.service.ts
  18. 4
      apps/api/src/services/interfaces/environment.interface.ts
  19. 12
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  20. 14
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  21. 4
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  22. 40
      apps/api/src/services/tag/tag.service.ts
  23. 13
      apps/client/src/app/components/header/header.component.ts
  24. 17
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  25. 3
      apps/client/src/app/components/home-holdings/home-holdings.html
  26. 2
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  27. 8
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  28. 14
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  29. 2
      apps/client/src/app/components/rule/rule.component.html
  30. 15
      apps/client/src/app/components/rule/rule.component.ts
  31. 1
      apps/client/src/app/components/rules/rules.component.html
  32. 4
      apps/client/src/app/components/rules/rules.component.ts
  33. 16
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  34. 5
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  35. 5
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  36. 7
      libs/common/src/lib/config.ts
  37. 3
      libs/common/src/lib/interfaces/info-item.interface.ts
  38. 13
      libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts
  39. 2
      libs/common/src/lib/interfaces/user.interface.ts
  40. 6
      libs/common/src/lib/types/x-ray-rules-settings.type.ts
  41. 27
      libs/ui/src/lib/assistant/assistant.component.ts
  42. 72
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  43. 2
      package.json
  44. 4
      test/import/ok-novn-buy-and-sell.json

33
CHANGELOG.md

@ -9,14 +9,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Extended the _Public API_ with the health check endpoint (experimental) - Added the name to the tooltip of the chart of the holdings tab on the home page (experimental)
### Changed ### Changed
- Improved the backgrounds of the chart of the holdings tab on the home page (experimental)
- Improved the labels of the chart of the holdings tab on the home page (experimental)
- Refactored the rule thresholds in the _X-ray_ section (experimental)
- Exposed the timeout of the portfolio snapshot computation as an environment variable (`PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT`)
- Harmonized the processor concurrency environment variables
- Improved the portfolio unit tests to work with exported activity files
- Enabled the `noUnusedLocals` compiler option in the `tsconfig` - Enabled the `noUnusedLocals` compiler option in the `tsconfig`
- Enabled the `noUnusedParameters` compiler option in the `tsconfig` - Enabled the `noUnusedParameters` compiler option in the `tsconfig`
### Fixed
- Considered the language of the user settings on login with _Security Token_
### Todo
- Rename the environment variable from `PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE` to `PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY`
- Rename the environment variable from `PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA` to `PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY`
- Rename the environment variable from `PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT` to `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY`
## 2.114.0 - 2024-10-10
### Added
- Added a tooltip to the chart of the holdings tab on the home page (experimental)
- Extended the _Public API_ with the health check endpoint (experimental)
### Changed
- Moved the tags from the info to the user service
- Switched the `prefer-const` rule from `warn` to `error` in the `eslint` configuration - Switched the `prefer-const` rule from `warn` to `error` in the `eslint` configuration
### Fixed
- Fixed an exception in the portfolio details endpoint caused by a calculation of the allocations by market
## 2.113.0 - 2024-10-06 ## 2.113.0 - 2024-10-06
### Added ### Added

2
apps/api/src/app/endpoints/public/public.controller.ts

@ -76,7 +76,7 @@ export class PublicController {
}) })
]); ]);
Object.values(markets).forEach((market) => { Object.values(markets ?? {}).forEach((market) => {
delete market.valueInBaseCurrency; delete market.valueInBaseCurrency;
}); });

2
apps/api/src/app/info/info.module.ts

@ -9,7 +9,6 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -33,7 +32,6 @@ import { InfoService } from './info.service';
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TagModule,
TransformDataSourceInResponseModule, TransformDataSourceInResponseModule,
UserModule UserModule
], ],

9
apps/api/src/app/info/info.service.ts

@ -5,7 +5,6 @@ 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 { 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 { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_BETTER_UPTIME_MONITOR_ID,
@ -47,7 +46,6 @@ export class InfoService {
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly tagService: TagService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -103,8 +101,7 @@ export class InfoService {
isUserSignupEnabled, isUserSignupEnabled,
platforms, platforms,
statistics, statistics,
subscriptions, subscriptions
tags
] = await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
@ -113,8 +110,7 @@ export class InfoService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getStatistics(), this.getStatistics(),
this.getSubscriptions(), this.getSubscriptions()
this.tagService.getPublic()
]); ]);
if (isUserSignupEnabled) { if (isUserSignupEnabled) {
@ -130,7 +126,6 @@ export class InfoService {
platforms, platforms,
statistics, statistics,
subscriptions, subscriptions,
tags,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies()
}; };

6
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -1,3 +1,5 @@
import { readFileSync } from 'fs';
export const activityDummyData = { export const activityDummyData = {
accountId: undefined, accountId: undefined,
accountUserId: undefined, accountUserId: undefined,
@ -29,3 +31,7 @@ export const symbolProfileDummyData = {
export const userDummyData = { export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
}; };
export function loadActivityExportFile(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')).activities;
}

56
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -1,6 +1,8 @@
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 { import {
activityDummyData, activityDummyData,
loadActivityExportFile,
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -20,6 +22,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash'; import { last } from 'lodash';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -52,6 +55,8 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
@ -59,6 +64,15 @@ describe('PortfolioCalculator', () => {
let portfolioSnapshotService: PortfolioSnapshotService; let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell.json'
)
);
});
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
@ -88,38 +102,18 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell', async () => { it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = [ const activities: Activity[] = activityDtos.map((activity) => ({
{ ...activityDummyData,
...activityDummyData, ...activity,
date: new Date('2022-03-07'), date: parseDate(activity.date),
fee: 0, SymbolProfile: {
quantity: 2, ...symbolProfileDummyData,
SymbolProfile: { currency: activity.currency,
...symbolProfileDummyData, dataSource: activity.dataSource,
currency: 'CHF', name: 'Novartis AG',
dataSource: 'YAHOO', symbol: activity.symbol
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'BUY',
unitPrice: 75.8
},
{
...activityDummyData,
date: new Date('2022-04-08'),
fee: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'SELL',
unitPrice: 85.73
} }
]; }));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,

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

@ -172,10 +172,10 @@ export class PortfolioController {
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
Object.values(markets).forEach((market) => { Object.values(markets ?? {}).forEach((market) => {
delete market.valueInBaseCurrency; delete market.valueInBaseCurrency;
}); });
Object.values(marketsAdvanced).forEach((market) => { Object.values(marketsAdvanced ?? {}).forEach((market) => {
delete market.valueInBaseCurrency; delete market.valueInBaseCurrency;
}); });

7
apps/api/src/app/portfolio/rules.service.ts

@ -24,13 +24,10 @@ export class RulesService {
return { return {
evaluation, evaluation,
value, value,
configuration: rule.getConfiguration(),
isActive: true, isActive: true,
key: rule.getKey(), key: rule.getKey(),
name: rule.getName(), name: rule.getName()
settings: <PortfolioReportRule['settings']>{
thresholdMax: settings['thresholdMax'],
thresholdMin: settings['thresholdMin']
}
}; };
} else { } else {
return { return {

56
apps/api/src/app/user/user.service.ts

@ -2,6 +2,12 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -56,7 +62,7 @@ export class UserService {
{ Account, id, permissions, Settings, subscription }: UserWithSettings, { Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale aLocale = locale
): Promise<IUser> { ): Promise<IUser> {
const accessesResult = await Promise.all([ const userData = await Promise.all([
this.prismaService.access.findMany({ this.prismaService.access.findMany({
include: { include: {
User: true User: true
@ -70,11 +76,11 @@ export class UserService {
}, },
where: { userId: id } where: { userId: id }
}), }),
this.tagService.getInUseByUser(id) this.tagService.getTagsForUser(id)
]); ]);
const access = accessesResult[0]; const access = userData[0];
const firstActivity = accessesResult[1]; const firstActivity = userData[1];
let tags = accessesResult[2]; let tags = userData[2];
let systemMessage: SystemMessage; let systemMessage: SystemMessage;
@ -200,17 +206,35 @@ export class UserService {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT'; (user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
} }
// Set default values for X-ray rules (user.Settings.settings as UserSettings).xRayRules = {
if (!(user.Settings.settings as UserSettings).xRayRules) { AccountClusterRiskCurrentInvestment:
(user.Settings.settings as UserSettings).xRayRules = { new AccountClusterRiskCurrentInvestment(undefined, {}).getSettings(
AccountClusterRiskCurrentInvestment: { isActive: true }, user.Settings.settings
AccountClusterRiskSingleAccount: { isActive: true }, ),
CurrencyClusterRiskBaseCurrencyCurrentInvestment: { isActive: true }, AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount(
CurrencyClusterRiskCurrentInvestment: { isActive: true }, undefined,
EmergencyFundSetup: { isActive: true }, {}
FeeRatioInitialInvestment: { isActive: true } ).getSettings(user.Settings.settings),
}; CurrencyClusterRiskBaseCurrencyCurrentInvestment:
} new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
undefined,
undefined
).getSettings(user.Settings.settings),
CurrencyClusterRiskCurrentInvestment:
new CurrencyClusterRiskCurrentInvestment(
undefined,
undefined
).getSettings(user.Settings.settings),
EmergencyFundSetup: new EmergencyFundSetup(
undefined,
undefined
).getSettings(user.Settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings)
};
let currentPermissions = getPermissions(user.role); let currentPermissions = getPermissions(user.role);

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

@ -1,7 +1,11 @@
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 { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; import {
PortfolioPosition,
PortfolioReportRule,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -65,5 +69,9 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
public abstract evaluate(aRuleSettings: T): EvaluationResult; public abstract evaluate(aRuleSettings: T): EvaluationResult;
public abstract getConfiguration(): Partial<
PortfolioReportRule['configuration']
>;
public abstract getSettings(aUserSettings: UserSettings): T; public abstract getSettings(aUserSettings: UserSettings): T;
} }

15
apps/api/src/models/rules/account-cluster-risk/current-investment.ts

@ -76,11 +76,22 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01
},
thresholdMax: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules[this.getKey()].isActive, isActive: xRayRules?.[this.getKey()].isActive ?? true,
thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.5 thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5
}; };
} }
} }

6
apps/api/src/models/rules/account-cluster-risk/single-account.ts

@ -34,9 +34,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
}; };
} }
public getConfiguration() {
return undefined;
}
public getSettings({ xRayRules }: UserSettings): RuleSettings { public getSettings({ xRayRules }: UserSettings): RuleSettings {
return { return {
isActive: xRayRules[this.getKey()].isActive isActive: xRayRules?.[this.getKey()].isActive ?? true
}; };
} }
} }

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

@ -61,10 +61,14 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
}; };
} }
public getConfiguration() {
return undefined;
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules[this.getKey()].isActive isActive: xRayRules?.[this.getKey()].isActive ?? true
}; };
} }
} }

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

@ -61,11 +61,22 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01
},
thresholdMax: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules[this.getKey()].isActive, isActive: xRayRules?.[this.getKey()].isActive ?? true,
thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.5 thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5
}; };
} }
} }

6
apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts

@ -32,10 +32,14 @@ export class EmergencyFundSetup extends Rule<Settings> {
}; };
} }
public getConfiguration() {
return undefined;
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules[this.getKey()].isActive isActive: xRayRules?.[this.getKey()].isActive ?? true
}; };
} }
} }

15
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts

@ -43,11 +43,22 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
}; };
} }
public getConfiguration() {
return {
threshold: {
max: 0.1,
min: 0,
step: 0.005
},
thresholdMax: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules[this.getKey()].isActive, isActive: xRayRules?.[this.getKey()].isActive ?? true,
thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.01 thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.01
}; };
} }
} }

22
apps/api/src/services/configuration/configuration.service.ts

@ -1,9 +1,10 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { import {
CACHE_TTL_NO_CACHE, CACHE_TTL_NO_CACHE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE, DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA, DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT,
DEFAULT_ROOT_URL DEFAULT_ROOT_URL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -50,14 +51,17 @@ export class ConfigurationService {
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }), MAX_CHART_ITEMS: num({ default: 365 }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE: num({ PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY
}), }),
PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA: num({ PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA default: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY
}), }),
PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT: num({ PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY
}),
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: num({
default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT
}), }),
REDIS_DB: num({ default: 0 }), REDIS_DB: num({ default: 0 }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),

4
apps/api/src/services/interfaces/environment.interface.ts

@ -30,6 +30,10 @@ export interface Environment extends CleanedEnvAccessors {
MAX_ACTIVITIES_TO_IMPORT: number; MAX_ACTIVITIES_TO_IMPORT: number;
MAX_CHART_ITEMS: number; MAX_CHART_ITEMS: number;
PORT: number; PORT: number;
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number;
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: number;
REDIS_DB: number; REDIS_DB: number;
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PASSWORD: string; REDIS_PASSWORD: string;

12
apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -3,8 +3,8 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE, DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA, DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -38,8 +38,8 @@ export class DataGatheringProcessor {
@Process({ @Process({
concurrency: parseInt( concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE ?? process.env.PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE.toString(), DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY.toString(),
10 10
), ),
name: GATHER_ASSET_PROFILE_PROCESS name: GATHER_ASSET_PROFILE_PROCESS
@ -69,8 +69,8 @@ export class DataGatheringProcessor {
@Process({ @Process({
concurrency: parseInt( concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ?? process.env.PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA.toString(), DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY.toString(),
10 10
), ),
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME

14
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -8,7 +8,10 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config'; import {
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT,
PORTFOLIO_SNAPSHOT_QUEUE
} from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -20,7 +23,14 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
imports: [ imports: [
AccountBalanceModule, AccountBalanceModule,
BullModule.registerQueue({ BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_QUEUE name: PORTFOLIO_SNAPSHOT_QUEUE,
settings: {
lockDuration: parseInt(
process.env.PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT ??
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT.toString(),
10
)
}
}), }),
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,

4
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -9,7 +9,7 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
CACHE_TTL_INFINITE, CACHE_TTL_INFINITE,
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_QUEUE PORTFOLIO_SNAPSHOT_QUEUE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -35,7 +35,7 @@ export class PortfolioSnapshotProcessor {
@Process({ @Process({
concurrency: parseInt( concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT ?? process.env.PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT ??
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT.toString(), DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY.toString(),
10 10
), ),
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME

40
apps/api/src/services/tag/tag.service.ts

@ -6,29 +6,39 @@ import { Injectable } from '@nestjs/common';
export class TagService { export class TagService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async getPublic() { public async getTagsForUser(userId: string) {
return this.prismaService.tag.findMany({ const tags = await this.prismaService.tag.findMany({
orderBy: { include: {
name: 'asc' _count: {
select: {
orders: {
where: {
userId
}
}
}
}
}, },
where: {
userId: null
}
});
}
public async getInUseByUser(userId: string) {
return this.prismaService.tag.findMany({
orderBy: { orderBy: {
name: 'asc' name: 'asc'
}, },
where: { where: {
orders: { OR: [
some: { {
userId userId
},
{
userId: null
} }
} ]
} }
}); });
return tags.map(({ _count, id, name, userId }) => ({
id,
name,
userId,
isUsed: _count.orders > 0
}));
} }
} }

13
apps/client/src/app/components/header/header.component.ts

@ -261,7 +261,18 @@ export class HeaderComponent implements OnChanges {
this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true' this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true'
); );
this.router.navigate(['/']); this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
const userLanguage = user?.settings?.language;
if (userLanguage && document.documentElement.lang !== userLanguage) {
window.location.href = `../${userLanguage}`;
} else {
this.router.navigate(['/']);
}
});
} }
public ngOnDestroy() { public ngOnDestroy() {

17
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -147,8 +147,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
const { tags } = this.dataService.fetchInfo();
this.activityForm = this.formBuilder.group({ this.activityForm = this.formBuilder.group({
tags: <string[]>[] tags: <string[]>[]
}); });
@ -158,13 +156,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
{ id: this.data.symbol, type: 'SYMBOL' } { id: this.data.symbol, type: 'SYMBOL' }
]; ];
this.tagsAvailable = tags.map((tag) => {
return {
...tag,
name: translate(tag.name)
};
});
this.activityForm this.activityForm
.get('tags') .get('tags')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) .valueChanges.pipe(takeUntil(this.unsubscribeSubject))
@ -434,6 +425,14 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.tagsAvailable =
this.user?.tags?.map((tag) => {
return {
...tag,
name: translate(tag.name)
};
}) ?? [];
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });

3
apps/client/src/app/components/home-holdings/home-holdings.html

@ -38,8 +38,11 @@
<gf-treemap-chart <gf-treemap-chart
class="mt-3" class="mt-3"
cursor="pointer" cursor="pointer"
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[dateRange]="user?.settings?.dateRange" [dateRange]="user?.settings?.dateRange"
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale"
(treemapChartClicked)="onHoldingClicked($event)" (treemapChartClicked)="onHoldingClicked($event)"
/> />
} }

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

@ -1,5 +1,7 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { XRayRulesSettings } from '@ghostfolio/common/types';
export interface IRuleSettingsDialogParams { export interface IRuleSettingsDialogParams {
rule: PortfolioReportRule; rule: PortfolioReportRule;
settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
} }

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

@ -1,4 +1,4 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import { XRayRulesSettings } from '@ghostfolio/common/types';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
@ -29,12 +29,10 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces';
templateUrl: './rule-settings-dialog.html' templateUrl: './rule-settings-dialog.html'
}) })
export class GfRuleSettingsDialogComponent { export class GfRuleSettingsDialogComponent {
public settings: PortfolioReportRule['settings']; public settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: IRuleSettingsDialogParams, @Inject(MAT_DIALOG_DATA) public data: IRuleSettingsDialogParams,
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent> public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>
) { ) {}
this.settings = this.data.rule.settings;
}
} }

14
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -4,34 +4,38 @@
<mat-form-field <mat-form-field
appearance="outline" appearance="outline"
class="w-100" class="w-100"
[ngClass]="{ 'd-none': settings.thresholdMin === undefined }" [ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }"
> >
<mat-label i18n>Threshold Min</mat-label> <mat-label i18n>Threshold Min</mat-label>
<input <input
matInput matInput
name="thresholdMin" name="thresholdMin"
type="number" type="number"
[(ngModel)]="settings.thresholdMin" [(ngModel)]="data.settings.thresholdMin"
/> />
</mat-form-field> </mat-form-field>
<mat-form-field <mat-form-field
appearance="outline" appearance="outline"
class="w-100" class="w-100"
[ngClass]="{ 'd-none': settings.thresholdMax === undefined }" [ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }"
> >
<mat-label i18n>Threshold Max</mat-label> <mat-label i18n>Threshold Max</mat-label>
<input <input
matInput matInput
name="thresholdMax" name="thresholdMax"
type="number" type="number"
[(ngModel)]="settings.thresholdMax" [(ngModel)]="data.settings.thresholdMax"
/> />
</mat-form-field> </mat-form-field>
</div> </div>
<div align="end" mat-dialog-actions> <div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button> <button i18n mat-button (click)="dialogRef.close()">Close</button>
<button color="primary" mat-flat-button (click)="dialogRef.close(settings)"> <button
color="primary"
mat-flat-button
(click)="dialogRef.close(data.settings)"
>
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>
</div> </div>

2
apps/client/src/app/components/rule/rule.component.html

@ -62,7 +62,7 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #rulesMenu="matMenu" xPosition="before"> <mat-menu #rulesMenu="matMenu" xPosition="before">
@if (rule?.isActive && !isEmpty(rule.settings)) { @if (rule?.isActive && rule?.configuration) {
<button mat-menu-item (click)="onCustomizeRule(rule)"> <button mat-menu-item (click)="onCustomizeRule(rule)">
<ng-container i18n>Customize</ng-container>... <ng-container i18n>Customize</ng-container>...
</button> </button>

15
apps/client/src/app/components/rule/rule.component.ts

@ -1,5 +1,7 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { XRayRulesSettings } from '@ghostfolio/common/types';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -10,7 +12,6 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { isEmpty } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -27,11 +28,10 @@ export class RuleComponent implements OnInit {
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() rule: PortfolioReportRule; @Input() rule: PortfolioReportRule;
@Input() settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
@Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>(); @Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>();
public isEmpty = isEmpty;
private deviceType: string; private deviceType: string;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -46,16 +46,17 @@ export class RuleComponent implements OnInit {
public onCustomizeRule(rule: PortfolioReportRule) { public onCustomizeRule(rule: PortfolioReportRule) {
const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, { const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, {
data: <IRuleSettingsDialogParams>{ data: {
rule rule,
}, settings: this.settings
} as IRuleSettingsDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((settings: PortfolioReportRule['settings']) => { .subscribe((settings: RuleSettings) => {
if (settings) { if (settings) {
this.ruleUpdated.emit({ this.ruleUpdated.emit({
xRayRules: { xRayRules: {

1
apps/client/src/app/components/rules/rules.component.html

@ -12,6 +12,7 @@
hasPermissionToUpdateUserSettings hasPermissionToUpdateUserSettings
" "
[rule]="rule" [rule]="rule"
[settings]="settings?.[rule.key]"
(ruleUpdated)="onRuleUpdated($event)" (ruleUpdated)="onRuleUpdated($event)"
/> />
} }

4
apps/client/src/app/components/rules/rules.component.ts

@ -1,5 +1,6 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { XRayRulesSettings } from '@ghostfolio/common/types';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -19,11 +20,10 @@ export class RulesComponent {
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() rules: PortfolioReportRule[]; @Input() rules: PortfolioReportRule[];
@Input() settings: XRayRulesSettings;
@Output() rulesUpdated = new EventEmitter<UpdateUserSettingDto>(); @Output() rulesUpdated = new EventEmitter<UpdateUserSettingDto>();
public constructor() {}
public onRuleUpdated(event: UpdateUserSettingDto) { public onRuleUpdated(event: UpdateUserSettingDto) {
this.rulesUpdated.emit(event); this.rulesUpdated.emit(event);
} }

16
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -76,17 +76,19 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.locale = this.data.user?.settings?.locale; this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);
const { currencies, platforms, tags } = this.dataService.fetchInfo(); const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies; this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.locale); this.defaultDateFormat = getDateFormatString(this.locale);
this.platforms = platforms; this.platforms = platforms;
this.tagsAvailable = tags.map((tag) => {
return { this.tagsAvailable =
...tag, this.data.user?.tags?.map((tag) => {
name: translate(tag.name) return {
}; ...tag,
}); name: translate(tag.name)
};
}) ?? [];
Object.keys(Type).forEach((type) => { Object.keys(Type).forEach((type) => {
this.typesTranslationMap[Type[type]] = translate(Type[type]); this.typesTranslationMap[Type[type]] = translate(Type[type]);

5
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -138,6 +138,11 @@ export class FirePageComponent implements OnDestroy, OnInit {
.putUserSetting(event) .putUserSetting(event)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.initializePortfolioReport(); this.initializePortfolioReport();
}); });
} }

5
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -132,6 +132,7 @@
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoadingPortfolioReport"
[rules]="emergencyFundRules" [rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
/> />
</div> </div>
@ -150,6 +151,7 @@
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoadingPortfolioReport"
[rules]="currencyClusterRiskRules" [rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
/> />
</div> </div>
@ -168,6 +170,7 @@
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoadingPortfolioReport"
[rules]="accountClusterRiskRules" [rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
/> />
</div> </div>
@ -186,6 +189,7 @@
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoadingPortfolioReport"
[rules]="feeRules" [rules]="feeRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
/> />
</div> </div>
@ -200,6 +204,7 @@
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoadingPortfolioReport"
[rules]="inactiveRules" [rules]="inactiveRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
/> />
</div> </div>

7
libs/common/src/lib/config.ts

@ -48,9 +48,10 @@ export const DEFAULT_CURRENCY = 'USD';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const DEFAULT_LANGUAGE_CODE = 'en'; export const DEFAULT_LANGUAGE_CODE = 'en';
export const DEFAULT_PAGE_SIZE = 50; export const DEFAULT_PAGE_SIZE = 50;
export const DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE = 1; export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA = 1; export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT = 1; export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT = 30000;
export const DEFAULT_ROOT_URL = 'https://localhost:4200'; export const DEFAULT_ROOT_URL = 'https://localhost:4200';
// USX is handled separately // USX is handled separately

3
libs/common/src/lib/interfaces/info-item.interface.ts

@ -1,6 +1,6 @@
import { SubscriptionOffer } from '@ghostfolio/common/types'; import { SubscriptionOffer } from '@ghostfolio/common/types';
import { Platform, SymbolProfile, Tag } from '@prisma/client'; import { Platform, SymbolProfile } from '@prisma/client';
import { Statistics } from './statistics.interface'; import { Statistics } from './statistics.interface';
import { Subscription } from './subscription.interface'; import { Subscription } from './subscription.interface';
@ -19,5 +19,4 @@ export interface InfoItem {
statistics: Statistics; statistics: Statistics;
stripePublicKey?: string; stripePublicKey?: string;
subscriptions: { [offer in SubscriptionOffer]: Subscription }; subscriptions: { [offer in SubscriptionOffer]: Subscription };
tags: Tag[];
} }

13
libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts

@ -1,11 +1,16 @@
export interface PortfolioReportRule { export interface PortfolioReportRule {
configuration?: {
threshold?: {
max: number;
min: number;
step: number;
};
thresholdMax?: boolean;
thresholdMin?: boolean;
};
evaluation?: string; evaluation?: string;
isActive: boolean; isActive: boolean;
key: string; key: string;
name: string; name: string;
settings?: {
thresholdMax?: number;
thresholdMin?: number;
};
value?: boolean; value?: boolean;
} }

2
libs/common/src/lib/interfaces/user.interface.ts

@ -23,5 +23,5 @@ export interface User {
offer: SubscriptionOffer; offer: SubscriptionOffer;
type: SubscriptionType; type: SubscriptionType;
}; };
tags: Tag[]; tags: (Tag & { isUsed: boolean })[];
} }

6
libs/common/src/lib/types/x-ray-rules-settings.type.ts

@ -1,5 +1,3 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
export type XRayRulesSettings = { export type XRayRulesSettings = {
AccountClusterRiskCurrentInvestment?: RuleSettings; AccountClusterRiskCurrentInvestment?: RuleSettings;
AccountClusterRiskSingleAccount?: RuleSettings; AccountClusterRiskSingleAccount?: RuleSettings;
@ -9,6 +7,8 @@ export type XRayRulesSettings = {
FeeRatioInitialInvestment?: RuleSettings; FeeRatioInitialInvestment?: RuleSettings;
}; };
interface RuleSettings extends Pick<PortfolioReportRule, 'settings'> { interface RuleSettings {
isActive: boolean; isActive: boolean;
thresholdMax?: number;
thresholdMin?: number;
} }

27
libs/ui/src/lib/assistant/assistant.component.ts

@ -156,7 +156,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.accounts = this.user?.accounts;
this.assetClasses = Object.keys(AssetClass).map((assetClass) => { this.assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { return {
id: assetClass, id: assetClass,
@ -164,13 +163,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}; };
}); });
this.tags = this.user?.tags.map(({ id, name }) => {
return {
id,
label: translate(name),
type: 'TAG'
};
});
this.searchFormControl.valueChanges this.searchFormControl.valueChanges
.pipe( .pipe(
@ -212,6 +204,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
public ngOnChanges() { public ngOnChanges() {
this.accounts = this.user?.accounts ?? [];
this.dateRangeOptions = [ this.dateRangeOptions = [
{ label: $localize`Today`, value: '1d' }, { label: $localize`Today`, value: '1d' },
{ {
@ -279,6 +273,23 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
emitEvent: false emitEvent: false
} }
); );
this.tags =
this.user?.tags
?.filter(({ isUsed }) => {
return isUsed;
})
.map(({ id, name }) => {
return {
id,
label: translate(name),
type: 'TAG'
};
}) ?? [];
if (this.tags.length === 0) {
this.filterForm.get('tag').disable({ emitEvent: false });
}
} }
public hasFilter(aFormValue: { [key: string]: string }) { public hasFilter(aFormValue: { [key: string]: string }) {

72
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -2,11 +2,13 @@ import {
getAnnualizedPerformancePercent, getAnnualizedPerformancePercent,
getIntervalFromDateRange getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper'; } from '@ghostfolio/common/calculation-helper';
import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
import { getLocale } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
PortfolioPosition PortfolioPosition
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types'; import { ColorScheme, DateRange } from '@ghostfolio/common/types';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
@ -25,7 +27,7 @@ import { DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { ChartConfiguration } from 'chart.js'; import { ChartConfiguration } from 'chart.js';
import { LinearScale } from 'chart.js'; import { LinearScale } from 'chart.js';
import { Chart } from 'chart.js'; import { Chart, Tooltip } from 'chart.js';
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
import { differenceInDays, max } from 'date-fns'; import { differenceInDays, max } from 'date-fns';
import { orderBy } from 'lodash'; import { orderBy } from 'lodash';
@ -44,9 +46,12 @@ const { gray, green, red } = require('open-color');
export class GfTreemapChartComponent export class GfTreemapChartComponent
implements AfterViewInit, OnChanges, OnDestroy implements AfterViewInit, OnChanges, OnDestroy
{ {
@Input() baseCurrency: string;
@Input() colorScheme: ColorScheme;
@Input() cursor: string; @Input() cursor: string;
@Input() dateRange: DateRange; @Input() dateRange: DateRange;
@Input() holdings: PortfolioPosition[]; @Input() holdings: PortfolioPosition[];
@Input() locale = getLocale();
@Output() treemapChartClicked = new EventEmitter<AssetProfileIdentifier>(); @Output() treemapChartClicked = new EventEmitter<AssetProfileIdentifier>();
@ -58,7 +63,7 @@ export class GfTreemapChartComponent
public isLoading = true; public isLoading = true;
public constructor() { public constructor() {
Chart.register(LinearScale, TreemapController, TreemapElement); Chart.register(LinearScale, Tooltip, TreemapController, TreemapElement);
} }
public ngAfterViewInit() { public ngAfterViewInit() {
@ -86,7 +91,7 @@ export class GfTreemapChartComponent
datasets: [ datasets: [
{ {
backgroundColor(ctx) { backgroundColor(ctx) {
const annualizedNetPerformancePercentWithCurrencyEffect = let annualizedNetPerformancePercentWithCurrencyEffect =
getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays( daysInMarket: differenceInDays(
endDate, endDate,
@ -100,6 +105,12 @@ export class GfTreemapChartComponent
) )
}).toNumber(); }).toNumber();
// Round to 2 decimal places
annualizedNetPerformancePercentWithCurrencyEffect =
Math.round(
annualizedNetPerformancePercentWithCurrencyEffect * 100
) / 100;
if ( if (
annualizedNetPerformancePercentWithCurrencyEffect > annualizedNetPerformancePercentWithCurrencyEffect >
0.03 * GfTreemapChartComponent.HEAT_MULTIPLIER 0.03 * GfTreemapChartComponent.HEAT_MULTIPLIER
@ -118,8 +129,11 @@ export class GfTreemapChartComponent
} else if (annualizedNetPerformancePercentWithCurrencyEffect > 0) { } else if (annualizedNetPerformancePercentWithCurrencyEffect > 0) {
return green[3]; return green[3];
} else if ( } else if (
annualizedNetPerformancePercentWithCurrencyEffect === 0 Math.abs(annualizedNetPerformancePercentWithCurrencyEffect) === 0
) { ) {
annualizedNetPerformancePercentWithCurrencyEffect = Math.abs(
annualizedNetPerformancePercentWithCurrencyEffect
);
return gray[3]; return gray[3];
} else if ( } else if (
annualizedNetPerformancePercentWithCurrencyEffect > annualizedNetPerformancePercentWithCurrencyEffect >
@ -146,13 +160,12 @@ export class GfTreemapChartComponent
align: 'left', align: 'left',
color: ['white'], color: ['white'],
display: true, display: true,
font: [{ size: 14 }, { size: 11 }, { lineHeight: 2, size: 14 }], font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }],
formatter(ctx) { formatter(ctx) {
const netPerformancePercentWithCurrencyEffect = const netPerformancePercentWithCurrencyEffect =
ctx.raw._data.netPerformancePercentWithCurrencyEffect; ctx.raw._data.netPerformancePercentWithCurrencyEffect;
return [ return [
ctx.raw._data.name,
ctx.raw._data.symbol, ctx.raw._data.symbol,
`${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(ctx.raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%` `${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(ctx.raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`
]; ];
@ -168,6 +181,9 @@ export class GfTreemapChartComponent
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration()
);
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
@ -199,9 +215,7 @@ export class GfTreemapChartComponent
} }
}, },
plugins: { plugins: {
tooltip: { tooltip: this.getTooltipPluginConfiguration()
enabled: false
}
} }
}, },
type: 'treemap' type: 'treemap'
@ -211,4 +225,42 @@ export class GfTreemapChartComponent
this.isLoading = false; this.isLoading = false;
} }
private getTooltipPluginConfiguration() {
return {
...getTooltipOptions({
colorScheme: this.colorScheme,
currency: this.baseCurrency,
locale: this.locale
}),
callbacks: {
label: (context) => {
const name = context.raw._data.name;
const symbol = context.raw._data.symbol;
if (context.raw._data.valueInBaseCurrency !== null) {
const value = <number>context.raw._data.valueInBaseCurrency;
return [
`${name ?? symbol}`,
`${value.toLocaleString(this.locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})} ${this.baseCurrency}`
];
} else {
const percentage =
<number>context.raw._data.allocationInPercentage * 100;
return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`];
}
},
title: () => {
return '';
}
},
xAlign: 'center',
yAlign: 'center'
};
}
} }

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.113.0", "version": "2.114.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",

4
test/import/ok-novn-buy-and-sell.json

@ -11,7 +11,7 @@
"unitPrice": 85.73, "unitPrice": 85.73,
"currency": "CHF", "currency": "CHF",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2022-04-07T22:00:00.000Z", "date": "2022-04-08T00:00:00.000Z",
"symbol": "NOVN.SW" "symbol": "NOVN.SW"
}, },
{ {
@ -21,7 +21,7 @@
"unitPrice": 75.8, "unitPrice": 75.8,
"currency": "CHF", "currency": "CHF",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2022-03-06T23:00:00.000Z", "date": "2022-03-07T00:00:00.000Z",
"symbol": "NOVN.SW" "symbol": "NOVN.SW"
} }
] ]

Loading…
Cancel
Save