Browse Source

Merge branch 'main' into feat/fix

pull/5453/head
Thomas Kaul 2 months ago
committed by GitHub
parent
commit
5238009a63
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 32
      CHANGELOG.md
  2. 25
      apps/api/src/app/admin/admin.service.ts
  3. 2
      apps/api/src/app/admin/queue/queue.service.ts
  4. 30
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  5. 208
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  6. 132
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  7. 4
      apps/api/src/app/portfolio/portfolio.controller.ts
  8. 391
      apps/api/src/app/portfolio/portfolio.service.ts
  9. 2
      apps/api/src/app/portfolio/rules.service.ts
  10. 1
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  11. 1
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  12. 15
      apps/api/src/models/rules/liquidity/buying-power.ts
  13. 3
      apps/client/project.json
  14. 4
      apps/client/src/app/app-routing.module.ts
  15. 39
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  16. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  17. 38
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts
  18. 27
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  19. 6
      apps/client/src/app/components/data-provider-status/data-provider-status.component.ts
  20. 8
      apps/client/src/app/components/dialog-footer/dialog-footer.component.html
  21. 8
      apps/client/src/app/components/dialog-footer/dialog-footer.component.scss
  22. 3
      apps/client/src/app/components/dialog-footer/dialog-footer.component.ts
  23. 22
      apps/client/src/app/components/dialog-header/dialog-header.component.html
  24. 3
      apps/client/src/app/components/dialog-header/dialog-header.component.scss
  25. 3
      apps/client/src/app/components/dialog-header/dialog-header.component.ts
  26. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  27. 4
      apps/client/src/app/components/home-summary/home-summary.component.ts
  28. 6
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html
  29. 12
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  30. 15
      apps/client/src/app/components/portfolio-summary/portfolio-summary.module.ts
  31. 1
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  32. 2
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  33. 2
      apps/client/src/app/components/rule/rule.component.ts
  34. 1
      apps/client/src/app/components/rules/rules.component.html
  35. 1
      apps/client/src/app/components/rules/rules.component.ts
  36. 34
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  37. 25
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.module.ts
  38. 6
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  39. 12
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  40. 5
      apps/client/src/app/pages/i18n/i18n-page.html
  41. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  42. 1
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  43. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  44. 76
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss
  45. 4
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  46. 199
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  47. 76
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
  48. 20
      apps/client/src/app/pages/public/public-page-routing.module.ts
  49. 29
      apps/client/src/app/pages/public/public-page.component.ts
  50. 28
      apps/client/src/app/pages/public/public-page.module.ts
  51. 13
      apps/client/src/app/pages/public/public-page.routes.ts
  52. 760
      apps/client/src/locales/messages.ca.xlf
  53. 812
      apps/client/src/locales/messages.de.xlf
  54. 766
      apps/client/src/locales/messages.es.xlf
  55. 814
      apps/client/src/locales/messages.fr.xlf
  56. 810
      apps/client/src/locales/messages.it.xlf
  57. 736
      apps/client/src/locales/messages.nl.xlf
  58. 776
      apps/client/src/locales/messages.pl.xlf
  59. 816
      apps/client/src/locales/messages.pt.xlf
  60. 768
      apps/client/src/locales/messages.tr.xlf
  61. 810
      apps/client/src/locales/messages.uk.xlf
  62. 552
      apps/client/src/locales/messages.xlf
  63. 688
      apps/client/src/locales/messages.zh.xlf
  64. 5
      libs/common/src/lib/interfaces/admin-data.interface.ts
  65. 1
      libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts
  66. 6
      libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts
  67. 2
      libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html
  68. 94
      package-lock.json
  69. 8
      package.json
  70. 42
      test/import/ok/btcusd-short.json

32
CHANGELOG.md

@ -7,18 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Enabled automatic data gathering for custom currencies added via the currency management in the admin control panel
### Changed
- Localized the content of the about page
- Restructured the response of the portfolio report endpoint (_X-ray_)
- Modernized the templates with untagged template literals
- Refactored the create or update access dialog component to standalone
- Improved the language localization for German (`de`)
- Upgraded `envalid` from version `8.0.0` to `8.1.0`
- Upgraded `prisma` from version `6.14.0` to `6.15.0`
### Fixed
- Improved the handling of `0` buying power in the static portfolio analysis rule: _Liquidity_ (Buying Power)
- Fixed an issue related to the error handling in the data provider status component
## 2.196.0 - 2025-09-04
### Changed
- Localized the content of the about page
- Refactored the public page to standalone
- Refactored the dialog footer component
- Refactored the dialog header component
- Refactored the account detail dialog component to standalone
- Refactored the benchmark comparator component to standalone
- Refactored the portfolio summary component to standalone
- Refactored the world map chart component to standalone
- Enabled the trim option in the `extract-i18n` configuration
- Improved the language localization for German (`de`)
- Upgraded the _Stripe_ dependencies
- Upgraded `ngx-device-detector` from version `10.0.2` to `10.1.0`
- Upgraded `ngx-skeleton-loader` from version `11.2.1` to `11.3.0`
- Upgraded `yahoo-finance2` from version `3.6.4` to `3.8.0`
### Fixed
- Fixed an issue in the average price calculation for buy and sell activities of short positions
- Fixed the number of attempts in the queue jobs view of the admin control panel
## 2.195.0 - 2025-08-29
### Changed

25
apps/api/src/app/admin/admin.service.ts

@ -136,11 +136,13 @@ export class AdminService {
public async get(): Promise<AdminData> {
const dataSources = Object.values(DataSource);
const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(),
this.prismaService.order.count(),
this.countUsersWithAnalytics()
]);
const [enabledDataSources, settings, transactionCount, userCount] =
await Promise.all([
this.dataProviderService.getDataSources(),
this.propertyService.get(),
this.prismaService.order.count(),
this.countUsersWithAnalytics()
]);
const dataProviders = (
await Promise.all(
@ -152,14 +154,23 @@ export class AdminService {
}
});
if (assetProfileCount > 0 || dataSource === 'GHOSTFOLIO') {
const isEnabled = enabledDataSources.includes(dataSource);
if (
assetProfileCount > 0 ||
dataSource === 'GHOSTFOLIO' ||
isEnabled
) {
const dataProviderInfo = this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo();
return {
...dataProviderInfo,
assetProfileCount
assetProfileCount,
useForExchangeRates:
dataSource ===
this.dataProviderService.getDataSourceForExchangeRates()
};
}

2
apps/api/src/app/admin/queue/queue.service.ts

@ -71,7 +71,7 @@ export class QueueService {
.slice(0, limit)
.map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
attemptsMade: job.attemptsMade,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,

30
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -927,13 +927,25 @@ export abstract class PortfolioCalculator {
.plus(oldAccumulatedSymbol.quantity);
if (type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(unitPrice)
);
if (oldAccumulatedSymbol.investment.gte(0)) {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(unitPrice)
);
} else {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
}
} else if (type === 'SELL') {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
if (oldAccumulatedSymbol.investment.gt(0)) {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
} else {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(unitPrice)
);
}
}
currentTransactionPointItem = {
@ -942,9 +954,9 @@ export abstract class PortfolioCalculator {
investment,
skipErrors,
symbol,
averagePrice: newQuantity.gt(0)
? investment.div(newQuantity)
: new Big(0),
averagePrice: newQuantity.eq(0)
? new Big(0)
: investment.div(newQuantity).abs(),
dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,

208
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -0,0 +1,208 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('595.6'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('139.75'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('36.6'),
grossPerformancePercentage: new Big('0.07706261539956593567'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.07706261539956593567'
),
grossPerformanceWithCurrencyEffect: new Big('36.6'),
investment: new Big('559'),
investmentWithCurrencyEffect: new Big('559'),
netPerformance: new Big('33.4'),
netPerformancePercentage: new Big('0.07032490039195361342'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.06986689805847808234')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('33.4')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('4'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('474.93846153846153846154'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'474.93846153846153846154'
),
transactionCount: 2,
valueInBaseCurrency: new Big('595.6')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('559'),
totalInvestmentWithCurrencyEffect: new Big('559'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 33.4,
netPerformanceInPercentage: 0.07032490039195362,
netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362,
netPerformanceWithCurrencyEffect: 33.4,
totalInvestmentValueWithCurrencyEffect: 559
})
);
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('559') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 559 },
{ date: '2021-12-01', investment: 0 }
]);
});
});
});

132
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -0,0 +1,132 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json')
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BTCUSD short sell (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot.positions[0].averagePrice).toEqual(
Big(45647.95)
);
});
});
});

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

@ -655,8 +655,8 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
for (const rule in report.xRay.rules) {
report.xRay.rules[rule] = null;
for (const category of report.xRay.categories) {
category.rules = null;
}
report.xRay.statistics = {

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

@ -50,6 +50,7 @@ import {
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioReportResponse,
PortfolioReportRule,
PortfolioSummary,
UserSettings
} from '@ghostfolio/common/interfaces';
@ -1231,176 +1232,236 @@ export class PortfolioService {
})
).toNumber();
const rules: PortfolioReportResponse['xRay']['rules'] = {
accountClusterRisk:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
),
new AccountClusterRiskSingleAccount(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
)
],
userSettings
const categories: PortfolioReportResponse['xRay']['categories'] = [
{
key: 'liquidity',
name: this.i18nService.getTranslation({
id: 'rule.liquidity.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[
new BuyingPower(
this.exchangeRateDataService,
this.i18nService,
summary.cash,
userSettings.language
)
: undefined,
assetClassClusterRisk:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new AssetClassClusterRiskEquity(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
),
new AssetClassClusterRiskFixedIncome(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
)
],
userSettings
)
: undefined,
currencyClusterRisk:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
Object.values(holdings),
userSettings.language
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
Object.values(holdings),
userSettings.language
)
],
userSettings
)
: undefined,
economicMarketClusterRisk:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency,
userSettings.language
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency,
userSettings.language
)
],
userSettings
],
userSettings
)
},
{
key: 'emergencyFund',
name: this.i18nService.getTranslation({
id: 'rule.emergencyFund.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[
new EmergencyFundSetup(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
this.getTotalEmergencyFund({
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings })
}).toNumber()
)
: undefined,
emergencyFund: await this.rulesService.evaluate(
[
new EmergencyFundSetup(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
this.getTotalEmergencyFund({
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings })
}).toNumber()
)
],
userSettings
),
fees: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds,
summary.fees
)
],
userSettings
),
liquidity: await this.rulesService.evaluate(
[
new BuyingPower(
this.exchangeRateDataService,
this.i18nService,
summary.cash,
userSettings.language
)
],
userSettings
),
regionalMarketClusterRisk:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new RegionalMarketClusterRiskAsiaPacific(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.asiaPacific.valueInBaseCurrency
),
new RegionalMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.emergingMarkets.valueInBaseCurrency
),
new RegionalMarketClusterRiskEurope(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.europe.valueInBaseCurrency
),
new RegionalMarketClusterRiskJapan(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.japan.valueInBaseCurrency
),
new RegionalMarketClusterRiskNorthAmerica(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.northAmerica.valueInBaseCurrency
)
],
userSettings
],
userSettings
)
},
{
key: 'currencyClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.currencyClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
Object.values(holdings),
userSettings.language
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
Object.values(holdings),
userSettings.language
)
],
userSettings
)
: undefined
},
{
key: 'assetClassClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.assetClassClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new AssetClassClusterRiskEquity(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
),
new AssetClassClusterRiskFixedIncome(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
)
],
userSettings
)
: undefined
},
{
key: 'accountClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.accountClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
),
new AccountClusterRiskSingleAccount(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
)
],
userSettings
)
: undefined
},
{
key: 'economicMarketClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.economicMarketClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency,
userSettings.language
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency,
userSettings.language
)
],
userSettings
)
: undefined
},
{
key: 'regionalMarketClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.regionalMarketClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new RegionalMarketClusterRiskAsiaPacific(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.asiaPacific.valueInBaseCurrency
),
new RegionalMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.emergingMarkets.valueInBaseCurrency
),
new RegionalMarketClusterRiskEurope(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.europe.valueInBaseCurrency
),
new RegionalMarketClusterRiskJapan(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.japan.valueInBaseCurrency
),
new RegionalMarketClusterRiskNorthAmerica(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.northAmerica.valueInBaseCurrency
)
],
userSettings
)
: undefined
},
{
key: 'fees',
name: this.i18nService.getTranslation({
id: 'rule.fees.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds,
summary.fees
)
: undefined
};
],
userSettings
)
}
];
return {
xRay: {
rules,
statistics: this.getReportStatistics(rules)
categories,
statistics: this.getReportStatistics(
categories.flatMap(({ rules }) => {
return rules ?? [];
})
)
}
};
}
@ -1822,7 +1883,7 @@ export class PortfolioService {
}
private getReportStatistics(
evaluatedRules: PortfolioReportResponse['xRay']['rules']
evaluatedRules: PortfolioReportRule[]
): PortfolioReportResponse['xRay']['statistics'] {
const rulesActiveCount = Object.values(evaluatedRules)
.flat()

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

@ -22,7 +22,6 @@ export class RulesService {
return {
evaluation,
value,
categoryName: rule.getCategoryName(),
configuration: rule.getConfiguration(),
isActive: true,
key: rule.getKey(),
@ -30,7 +29,6 @@ export class RulesService {
};
} else {
return {
categoryName: rule.getCategoryName(),
isActive: false,
key: rule.getKey(),
name: rule.getName()

1
apps/api/src/models/rules/asset-class-cluster-risk/equity.ts

@ -67,6 +67,7 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
value: false
};
}
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.assetClassClusterRiskEquity.true',

1
apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts

@ -67,6 +67,7 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
value: false
};
}
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.assetClassClusterRiskFixedIncome.true',

15
apps/api/src/models/rules/liquidity/buying-power.ts

@ -22,10 +22,21 @@ export class BuyingPower extends Rule<Settings> {
}
public evaluate(ruleSettings: Settings) {
if (this.buyingPower < ruleSettings.thresholdMin) {
if (this.buyingPower === 0) {
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.liquidityBuyingPower.false',
id: 'rule.liquidityBuyingPower.false.zero',
languageCode: this.getLanguageCode(),
placeholders: {
baseCurrency: ruleSettings.baseCurrency
}
}),
value: false
};
} else if (this.buyingPower < ruleSettings.thresholdMin) {
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.liquidityBuyingPower.false.min',
languageCode: this.getLanguageCode(),
placeholders: {
baseCurrency: ruleSettings.baseCurrency,

3
apps/client/project.json

@ -271,7 +271,8 @@
"messages.tr.xlf",
"messages.uk.xlf",
"messages.zh.xlf"
]
],
"trim": true
}
},
"lint": {

4
apps/client/src/app/app-routing.module.ts

@ -111,9 +111,7 @@ const routes: Routes = [
{
path: publicRoutes.public.path,
loadChildren: () =>
import('./pages/public/public-page.module').then(
(m) => m.PublicPageModule
)
import('./pages/public/public-page.routes').then((m) => m.routes)
},
{
path: publicRoutes.register.path,

39
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -1,5 +1,8 @@
import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config';
@ -13,7 +16,12 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@ -22,10 +30,15 @@ import {
OnDestroy,
OnInit
} from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
@ -35,20 +48,36 @@ import {
walletOutline
} from 'ionicons/icons';
import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' },
imports: [
CommonModule,
GfAccountBalancesComponent,
GfActivitiesTableComponent,
GfDialogFooterComponent,
GfDialogHeaderComponent,
GfHoldingsTableComponent,
GfInvestmentChartModule,
GfValueComponent,
IonIcon,
MatButtonModule,
MatDialogModule,
MatTabsModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-account-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'account-detail-dialog.html',
styleUrls: ['./account-detail-dialog.component.scss'],
standalone: false
templateUrl: 'account-detail-dialog.html'
})
export class AccountDetailDialog implements OnDestroy, OnInit {
export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[];
public balance: number;
@ -81,7 +110,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>,
public dialogRef: MatDialogRef<GfAccountDetailDialogComponent>,
private router: Router,
private userService: UserService
) {

2
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -1,5 +1,4 @@
<gf-dialog-header
mat-dialog-title
position="center"
[deviceType]="data.deviceType"
[title]="name"
@ -166,7 +165,6 @@
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
/>

38
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts

@ -1,38 +0,0 @@
import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { IonIcon } from '@ionic/angular/standalone';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AccountDetailDialog } from './account-detail-dialog.component';
@NgModule({
declarations: [AccountDetailDialog],
imports: [
CommonModule,
GfAccountBalancesComponent,
GfActivitiesTableComponent,
GfDialogFooterComponent,
GfDialogHeaderComponent,
GfHoldingsTableComponent,
GfInvestmentChartModule,
GfValueComponent,
IonIcon,
MatButtonModule,
MatDialogModule,
MatTabsModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAccountDetailDialogModule {}

27
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts

@ -1,6 +1,7 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
DEFAULT_CURRENCY,
ghostfolioPrefix,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
@ -29,8 +30,9 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { DataSource } from '@prisma/client';
import { isISO4217CurrencyCode } from 'class-validator';
import { Subject, takeUntil } from 'rxjs';
import { Subject, switchMap, takeUntil } from 'rxjs';
import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
@ -56,6 +58,7 @@ export class GfCreateAssetProfileDialogComponent implements OnInit, OnDestroy {
public mode: CreateAssetProfileDialogMode;
private customCurrencies: string[];
private dataSourceForExchangeRates: DataSource;
private unsubscribeSubject = new Subject<void>();
public constructor(
@ -67,7 +70,7 @@ export class GfCreateAssetProfileDialogComponent implements OnInit, OnDestroy {
) {}
public ngOnInit() {
this.initializeCustomCurrencies();
this.initialize();
this.createAssetProfileForm = this.formBuilder.group(
{
@ -115,7 +118,15 @@ export class GfCreateAssetProfileDialogComponent implements OnInit, OnDestroy {
.putAdminSetting(PROPERTY_CURRENCIES, {
value: JSON.stringify(currencies)
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(
switchMap(() => {
return this.adminService.gatherSymbol({
dataSource: this.dataSourceForExchangeRates,
symbol: `${DEFAULT_CURRENCY}${currency}`
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close();
});
@ -170,13 +181,19 @@ export class GfCreateAssetProfileDialogComponent implements OnInit, OnDestroy {
return { atLeastOneValid: true };
}
private initializeCustomCurrencies() {
private initialize() {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ settings }) => {
.subscribe(({ dataProviders, settings }) => {
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
const { dataSource } = dataProviders.find(({ useForExchangeRates }) => {
return useForExchangeRates;
});
this.dataSourceForExchangeRates = dataSource;
this.changeDetectorRef.markForCheck();
});
}

6
apps/client/src/app/components/data-provider-status/data-provider-status.component.ts

@ -33,12 +33,12 @@ export class GfDataProviderStatusComponent implements OnDestroy, OnInit {
this.status$ = this.dataService
.fetchDataProviderHealth(this.dataSource)
.pipe(
catchError(() => {
return of({ isHealthy: false });
}),
map(() => {
return { isHealthy: true };
}),
catchError(() => {
return of({ isHealthy: false });
}),
takeUntil(this.unsubscribeSubject)
);
}

8
apps/client/src/app/components/dialog-footer/dialog-footer.component.html

@ -1,5 +1,7 @@
@if (deviceType === 'mobile') {
<button mat-button (click)="onClickCloseButton()">
<ion-icon name="close" size="large" />
</button>
<div class="d-flex justify-content-center" mat-dialog-actions>
<button mat-button (click)="onClickCloseButton()">
<ion-icon name="close" size="large" />
</button>
</div>
}

8
apps/client/src/app/components/dialog-footer/dialog-footer.component.scss

@ -1,9 +1,3 @@
:host {
display: flex;
flex: 0 0 auto;
min-height: 0;
@media (min-width: 576px) {
padding: 0 !important;
}
display: block;
}

3
apps/client/src/app/components/dialog-footer/dialog-footer.component.ts

@ -6,6 +6,7 @@ import {
Output
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { close } from 'ionicons/icons';
@ -13,7 +14,7 @@ import { close } from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'justify-content-center' },
imports: [IonIcon, MatButtonModule],
imports: [IonIcon, MatButtonModule, MatDialogModule],
selector: 'gf-dialog-footer',
styleUrls: ['./dialog-footer.component.scss'],
templateUrl: './dialog-footer.component.html'

22
apps/client/src/app/components/dialog-header/dialog-header.component.html

@ -1,10 +1,12 @@
<span
class="flex-grow-1 text-truncate"
[ngClass]="{ 'text-center': position === 'center' }"
>{{ title }}</span
>
@if (deviceType !== 'mobile') {
<button class="no-min-width px-0" mat-button (click)="onClickCloseButton()">
<ion-icon name="close" size="large" />
</button>
}
<div class="d-flex" mat-dialog-title>
<span
class="flex-grow-1 text-truncate"
[ngClass]="{ 'text-center': position === 'center' }"
>{{ title }}</span
>
@if (deviceType !== 'mobile') {
<button class="no-min-width px-0" mat-button (click)="onClickCloseButton()">
<ion-icon name="close" size="large" />
</button>
}
</div>

3
apps/client/src/app/components/dialog-header/dialog-header.component.scss

@ -1,4 +1,3 @@
:host {
align-items: center;
display: flex;
display: block;
}

3
apps/client/src/app/components/dialog-header/dialog-header.component.ts

@ -7,6 +7,7 @@ import {
Output
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { close } from 'ionicons/icons';
@ -14,7 +15,7 @@ import { close } from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'justify-content-center' },
imports: [CommonModule, IonIcon, MatButtonModule],
imports: [CommonModule, IonIcon, MatButtonModule, MatDialogModule],
selector: 'gf-dialog-header',
styleUrls: ['./dialog-header.component.scss'],
templateUrl: './dialog-header.component.html'

2
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -1,5 +1,4 @@
<gf-dialog-header
mat-dialog-title
position="center"
[deviceType]="data.deviceType"
[title]="SymbolProfile?.name ?? SymbolProfile?.symbol"
@ -459,7 +458,6 @@
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
/>

4
apps/client/src/app/components/home-summary/home-summary.component.ts

@ -1,4 +1,4 @@
import { GfPortfolioSummaryModule } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.module';
import { GfPortfolioSummaryComponent } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -22,7 +22,7 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
imports: [GfPortfolioSummaryModule, MatCardModule],
imports: [GfPortfolioSummaryComponent, MatCardModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-home-summary',
styleUrls: ['./home-summary.scss'],

6
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html

@ -1,8 +1,4 @@
<gf-dialog-header
mat-dialog-title
[title]="data.title"
(closeButtonClicked)="onClose()"
/>
<gf-dialog-header [title]="data.title" (closeButtonClicked)="onClose()" />
<div class="py-3" mat-dialog-content>
<div class="align-items-center d-flex flex-column">

12
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -2,7 +2,9 @@ import { NotificationService } from '@ghostfolio/client/core/notification/notifi
import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper';
import { PortfolioSummary, User } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@ -11,6 +13,8 @@ import {
OnChanges,
Output
} from '@angular/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { IonIcon } from '@ionic/angular/standalone';
import { formatDistanceToNow } from 'date-fns';
import { addIcons } from 'ionicons';
import {
@ -19,13 +23,13 @@ import {
} from 'ionicons/icons';
@Component({
selector: 'gf-portfolio-summary',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-summary.component.html',
imports: [CommonModule, GfValueComponent, IonIcon, MatTooltipModule],
selector: 'gf-portfolio-summary',
styleUrls: ['./portfolio-summary.component.scss'],
standalone: false
templateUrl: './portfolio-summary.component.html'
})
export class PortfolioSummaryComponent implements OnChanges {
export class GfPortfolioSummaryComponent implements OnChanges {
@Input() baseCurrency: string;
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean;

15
apps/client/src/app/components/portfolio-summary/portfolio-summary.module.ts

@ -1,15 +0,0 @@
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { PortfolioSummaryComponent } from './portfolio-summary.component';
@NgModule({
declarations: [PortfolioSummaryComponent],
exports: [PortfolioSummaryComponent],
imports: [CommonModule, GfValueComponent, MatTooltipModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPortfolioSummaryModule {}

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

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/common/interfaces';
export interface IRuleSettingsDialogParams {
categoryName: string;
rule: PortfolioReportRule;
settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
}

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

@ -1,4 +1,4 @@
<div mat-dialog-title>{{ data.rule.categoryName }} › {{ data.rule.name }}</div>
<div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content>
@if (

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

@ -37,6 +37,7 @@ import { GfRuleSettingsDialogComponent } from './rule-settings-dialog/rule-setti
standalone: false
})
export class RuleComponent implements OnInit {
@Input() categoryName: string;
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean;
@Input() rule: PortfolioReportRule;
@ -69,6 +70,7 @@ export class RuleComponent implements OnInit {
const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, {
data: {
rule,
categoryName: this.categoryName,
settings: this.settings
} as IRuleSettingsDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

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

@ -8,6 +8,7 @@
@if (rules !== null && rules !== undefined) {
@for (rule of rules; track rule.key) {
<gf-rule
[categoryName]="categoryName"
[hasPermissionToUpdateUserSettings]="
hasPermissionToUpdateUserSettings
"

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

@ -20,6 +20,7 @@ import {
standalone: false
})
export class RulesComponent {
@Input() categoryName: string;
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean;
@Input() rules: PortfolioReportRule[];

34
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -10,8 +10,22 @@ import {
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { StatusCodes } from 'http-status-codes';
import { EMPTY, Subject, catchError, takeUntil } from 'rxjs';
@ -20,12 +34,20 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
imports: [
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule
],
selector: 'gf-create-or-update-access-dialog',
styleUrls: ['./create-or-update-access-dialog.scss'],
templateUrl: 'create-or-update-access-dialog.html',
standalone: false
templateUrl: 'create-or-update-access-dialog.html'
})
export class CreateOrUpdateAccessDialog implements OnDestroy {
export class GfCreateOrUpdateAccessDialog implements OnDestroy {
public accessForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
@ -33,7 +55,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialog>,
private dataService: DataService,
private formBuilder: FormBuilder,
private notificationService: NotificationService

25
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.module.ts

@ -1,25 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component';
@NgModule({
declarations: [CreateOrUpdateAccessDialog],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule
]
})
export class GfCreateOrUpdateAccessDialogModule {}

6
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -30,15 +30,13 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
import { GfCreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
imports: [
GfAccessTableComponent,
GfCreateOrUpdateAccessDialogModule,
GfPremiumIndicatorComponent,
IonIcon,
MatButtonModule,
@ -181,7 +179,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}
private openCreateAccessDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
const dialogRef = this.dialog.open(GfCreateOrUpdateAccessDialog, {
data: {
access: {
alias: '',

12
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -1,8 +1,7 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { GfAccountDetailDialogModule } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.module';
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -28,12 +27,7 @@ import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-ba
@Component({
host: { class: 'has-fab page' },
imports: [
GfAccountDetailDialogModule,
GfAccountsTableComponent,
MatButtonModule,
RouterModule
],
imports: [GfAccountsTableComponent, MatButtonModule, RouterModule],
selector: 'gf-accounts-page',
styleUrls: ['./accounts-page.scss'],
templateUrl: './accounts-page.html'
@ -233,7 +227,7 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
}
private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open(AccountDetailDialog, {
const dialogRef = this.dialog.open(GfAccountDetailDialogComponent, {
autoFocus: false,
data: {
accountId: aAccountId,

5
apps/client/src/app/pages/i18n/i18n-page.html

@ -69,10 +69,13 @@
</li>
<li i18n="@@rule.liquidity.category">Liquidity</li>
<li i18n="@@rule.liquidityBuyingPower">Buying Power</li>
<li i18n="@@rule.liquidityBuyingPower.false">
<li i18n="@@rule.liquidityBuyingPower.false.min">
Your buying power is below $&#123;thresholdMin&#125;
$&#123;baseCurrency&#125;
</li>
<li i18n="@@rule.liquidityBuyingPower.false.zero">
Your buying power is 0 $&#123;baseCurrency&#125;
</li>
<li i18n="@@rule.liquidityBuyingPower.true">
Your buying power exceeds $&#123;thresholdMin&#125;
$&#123;baseCurrency&#125;

2
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -250,6 +250,7 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
deviceType: this.deviceType,
user: this.user
} as ImportActivitiesDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
@ -273,6 +274,7 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
deviceType: this.deviceType,
user: this.user
} as ImportActivitiesDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});

1
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -56,6 +56,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' },
imports: [
GfActivitiesTableComponent,
GfDialogFooterComponent,

2
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html

@ -1,5 +1,4 @@
<gf-dialog-header
mat-dialog-title
[deviceType]="data.deviceType"
[title]="dialogTitle"
(closeButtonClicked)="onCancel()"
@ -193,7 +192,6 @@
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
/>

76
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss

@ -1,54 +1,58 @@
:host {
display: block;
a {
color: rgba(var(--palette-primary-500), 1);
}
.mat-mdc-dialog-content {
max-height: unset;
a {
color: rgba(var(--palette-primary-500), 1);
}
mat-stepper {
::ng-deep {
.mat-step-header {
&[aria-selected='false'] {
pointer-events: none;
mat-stepper {
::ng-deep {
.mat-step-header {
&[aria-selected='false'] {
pointer-events: none;
}
}
}
}
}
.mat-expansion-panel {
background: none;
box-shadow: none;
.drop-area {
background-color: rgba(var(--palette-foreground-base), 0.02);
border: 1px dashed
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
border-radius: 0.25rem;
.mat-expansion-panel-header {
color: inherit;
&:hover {
border-color: rgba(var(--palette-primary-500), 1) !important;
color: rgba(var(--palette-primary-500), 1);
}
&[aria-disabled='true'] {
cursor: default;
.cloud-icon {
font-size: 2.5rem;
}
}
}
.mat-mdc-progress-spinner {
right: 1.5rem;
top: calc(50% - 10px);
}
.drop-area {
background-color: rgba(var(--palette-foreground-base), 0.02);
border: 1px dashed
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
border-radius: 0.25rem;
&:hover {
border-color: rgba(var(--palette-primary-500), 1) !important;
color: rgba(var(--palette-primary-500), 1);
.mat-mdc-progress-spinner {
right: 1.5rem;
top: calc(50% - 10px);
}
.cloud-icon {
font-size: 2.5rem;
.mat-expansion-panel {
background: none;
box-shadow: none;
.mat-expansion-panel-header {
color: inherit;
&[aria-disabled='true'] {
cursor: default;
}
}
}
}
}

4
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -1,4 +1,4 @@
import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { GfWorldMapChartComponent } from '@ghostfolio/client/components/world-map-chart/world-map-chart.component';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -558,7 +558,7 @@ export class GfAllocationsPageComponent implements OnDestroy, OnInit {
}
private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open(AccountDetailDialog, {
const dialogRef = this.dialog.open(GfAccountDetailDialogComponent, {
autoFocus: false,
data: {
accountId: aAccountId,

199
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html

@ -59,182 +59,29 @@
</div>
}
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': liquidityRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Liquidity</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="liquidityRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': emergencyFundRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': currencyClusterRiskRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Currency Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': assetClassClusterRiskRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Asset Class Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="assetClassClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': accountClusterRiskRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Account Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': economicMarketClusterRiskRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Economic Market Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="economicMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': regionalMarketClusterRiskRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Regional Market Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="regionalMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': feeRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Fees</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="feeRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
@for (category of categories; track category.key) {
<div
class="mb-4"
[ngClass]="{ 'd-none': category.rules?.length === 0 }"
>
<h4 class="align-items-center d-flex m-0">
<span>{{ category.name }}</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[categoryName]="category.name"
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="category.rules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
}
@if (inactiveRules?.length > 0) {
<div>
<h4 class="m-0" i18n>Inactive</h4>

76
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts

@ -36,18 +36,15 @@ import { Subject, takeUntil } from 'rxjs';
templateUrl: './x-ray-page.component.html'
})
export class GfXRayPageComponent {
public accountClusterRiskRules: PortfolioReportRule[];
public assetClassClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public economicMarketClusterRiskRules: PortfolioReportRule[];
public emergencyFundRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public categories: {
key: string;
name: string;
rules: PortfolioReportRule[];
}[];
public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[];
public isLoading = false;
public liquidityRules: PortfolioReportRule[];
public regionalMarketClusterRiskRules: PortfolioReportRule[];
public statistics: PortfolioReportResponse['xRay']['statistics'];
public user: User;
@ -116,50 +113,11 @@ export class GfXRayPageComponent {
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ xRay: { rules, statistics } }) => {
this.inactiveRules = this.mergeInactiveRules(rules);
.subscribe(({ xRay: { categories, statistics } }) => {
this.categories = categories;
this.inactiveRules = this.mergeInactiveRules(categories);
this.statistics = statistics;
this.accountClusterRiskRules =
rules['accountClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.assetClassClusterRiskRules =
rules['assetClassClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.currencyClusterRiskRules =
rules['currencyClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.economicMarketClusterRiskRules =
rules['economicMarketClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.emergencyFundRules =
rules['emergencyFund']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.feeRules =
rules['fees']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.liquidityRules =
rules['liquidity']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.regionalMarketClusterRiskRules =
rules['regionalMarketClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
@ -167,20 +125,14 @@ export class GfXRayPageComponent {
}
private mergeInactiveRules(
rules: PortfolioReportResponse['xRay']['rules']
categories: PortfolioReportResponse['xRay']['categories']
): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = [];
for (const category in rules) {
const rulesArray = rules[category] || [];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {
return categories.flatMap(({ rules }) => {
return (
rules?.filter(({ isActive }) => {
return !isActive;
})
}) ?? []
);
}
return inactiveRules;
});
}
}

20
apps/client/src/app/pages/public/public-page-routing.module.ts

@ -1,20 +0,0 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PublicPageComponent } from './public-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: PublicPageComponent,
path: ':id'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class PublicPageRoutingModule {}

29
apps/client/src/app/pages/public/public-page.component.ts

@ -1,3 +1,4 @@
import { GfWorldMapChartComponent } from '@ghostfolio/client/components/world-map-chart/world-map-chart.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper';
@ -6,8 +7,19 @@ import {
PublicPortfolioResponse
} from '@ghostfolio/common/interfaces';
import { Market } from '@ghostfolio/common/types';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table/holdings-table.component';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.component';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnInit
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { ActivatedRoute, Router } from '@angular/router';
import { AssetClass } from '@prisma/client';
import { StatusCodes } from 'http-status-codes';
@ -18,12 +30,21 @@ import { catchError, takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page' },
imports: [
CommonModule,
GfHoldingsTableComponent,
GfPortfolioProportionChartComponent,
GfValueComponent,
GfWorldMapChartComponent,
MatButtonModule,
MatCardModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-public-page',
styleUrls: ['./public-page.scss'],
templateUrl: './public-page.html',
standalone: false
templateUrl: './public-page.html'
})
export class PublicPageComponent implements OnInit {
export class GfPublicPageComponent implements OnInit {
public continents: {
[code: string]: { name: string; value: number };
};

28
apps/client/src/app/pages/public/public-page.module.ts

@ -1,28 +0,0 @@
import { GfWorldMapChartComponent } from '@ghostfolio/client/components/world-map-chart/world-map-chart.component';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { PublicPageRoutingModule } from './public-page-routing.module';
import { PublicPageComponent } from './public-page.component';
@NgModule({
declarations: [PublicPageComponent],
imports: [
CommonModule,
GfHoldingsTableComponent,
GfPortfolioProportionChartComponent,
GfValueComponent,
GfWorldMapChartComponent,
MatButtonModule,
MatCardModule,
PublicPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class PublicPageModule {}

13
apps/client/src/app/pages/public/public-page.routes.ts

@ -0,0 +1,13 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { Routes } from '@angular/router';
import { GfPublicPageComponent } from './public-page.component';
export const routes: Routes = [
{
canActivate: [AuthGuard],
component: GfPublicPageComponent,
path: ':id'
}
];

760
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

812
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

766
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

814
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

810
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

736
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

776
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

816
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

768
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

810
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

552
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

688
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

5
libs/common/src/lib/interfaces/admin-data.interface.ts

@ -1,7 +1,10 @@
import { DataProviderInfo } from './data-provider-info.interface';
export interface AdminData {
dataProviders: (DataProviderInfo & { assetProfileCount: number })[];
dataProviders: (DataProviderInfo & {
assetProfileCount: number;
useForExchangeRates: boolean;
})[];
settings: { [key: string]: boolean | object | string | string[] };
transactionCount: number;
userCount: number;

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

@ -1,5 +1,4 @@
export interface PortfolioReportRule {
categoryName: string;
configuration?: {
threshold?: {
max: number;

6
libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts

@ -2,7 +2,11 @@ import { PortfolioReportRule } from '../portfolio-report-rule.interface';
export interface PortfolioReportResponse {
xRay: {
rules: { [group: string]: PortfolioReportRule[] };
categories: {
key: string;
name: string;
rules: PortfolioReportRule[];
}[];
statistics: {
rulesActiveCount: number;
rulesFulfilledCount: number;

2
libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html

@ -1,5 +1,4 @@
<gf-dialog-header
mat-dialog-title
position="center"
[deviceType]="data.deviceType"
[title]="assetProfile?.name ?? assetProfile?.symbol"
@ -35,7 +34,6 @@
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
/>

94
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.195.0",
"version": "2.196.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.195.0",
"version": "2.196.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -44,7 +44,7 @@
"@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.14.0",
"@prisma/client": "6.15.0",
"@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0",
@ -66,7 +66,7 @@
"countries-list": "3.1.1",
"countup.js": "2.8.2",
"date-fns": "4.1.0",
"envalid": "8.0.0",
"envalid": "8.1.0",
"fuse.js": "7.1.0",
"google-spreadsheet": "3.2.0",
"helmet": "7.0.0",
@ -149,7 +149,7 @@
"nx": "21.3.9",
"prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.14.0",
"prisma": "6.15.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.3.0",
@ -10494,9 +10494,9 @@
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.14.0.tgz",
"integrity": "sha512-8E/Nk3eL5g7RQIg/LUj1ICyDmhD053STjxrPxUtCRybs2s/2sOEcx9NpITuAOPn07HEpWBfhAVe1T/HYWXUPOw==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.15.0.tgz",
"integrity": "sha512-wR2LXUbOH4cL/WToatI/Y2c7uzni76oNFND7+23ypLllBmIS8e3ZHhO+nud9iXSXKFt1SoM3fTZvHawg63emZw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -10516,9 +10516,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz",
"integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.15.0.tgz",
"integrity": "sha512-KMEoec9b2u6zX0EbSEx/dRpx1oNLjqJEBZYyK0S3TTIbZ7GEGoVyGyFRk4C72+A38cuPLbfQGQvgOD+gBErKlA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@ -10529,53 +10529,53 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz",
"integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.15.0.tgz",
"integrity": "sha512-y7cSeLuQmyt+A3hstAs6tsuAiVXSnw9T55ra77z0nbNkA8Lcq9rNcQg6PI00by/+WnE/aMRJ/W7sZWn2cgIy1g==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz",
"integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.15.0.tgz",
"integrity": "sha512-opITiR5ddFJ1N2iqa7mkRlohCZqVSsHhRcc29QXeldMljOf4FSellLT0J5goVb64EzRTKcIDeIsJBgmilNcKxA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.14.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"@prisma/fetch-engine": "6.14.0",
"@prisma/get-platform": "6.14.0"
"@prisma/debug": "6.15.0",
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
"@prisma/fetch-engine": "6.15.0",
"@prisma/get-platform": "6.15.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz",
"integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==",
"version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb.tgz",
"integrity": "sha512-a/46aK5j6L3ePwilZYEgYDPrhBQ/n4gYjLxT5YncUTJJNRnTCVjPF86QdzUOLRdYjCLfhtZp9aum90W0J+trrg==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz",
"integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.15.0.tgz",
"integrity": "sha512-xcT5f6b+OWBq6vTUnRCc7qL+Im570CtwvgSj+0MTSGA1o9UDSKZ/WANvwtiRXdbYWECpyC3CukoG3A04VTAPHw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.14.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"@prisma/get-platform": "6.14.0"
"@prisma/debug": "6.15.0",
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
"@prisma/get-platform": "6.15.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz",
"integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.15.0.tgz",
"integrity": "sha512-Jbb+Xbxyp05NSR1x2epabetHiXvpO8tdN2YNoWoA/ZsbYyxxu/CO/ROBauIFuMXs3Ti+W7N7SJtWsHGaWte9Rg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.14.0"
"@prisma/debug": "6.15.0"
}
},
"node_modules/@redis/client": {
@ -19956,23 +19956,17 @@
}
},
"node_modules/envalid": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/envalid/-/envalid-8.0.0.tgz",
"integrity": "sha512-PGeYJnJB5naN0ME6SH8nFcDj9HVbLpYIfg1p5lAyM9T4cH2lwtu2fLbozC/bq+HUUOIFxhX/LP0/GmlqPHT4tQ==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/envalid/-/envalid-8.1.0.tgz",
"integrity": "sha512-OT6+qVhKVyCidaGoXflb2iK1tC8pd0OV2Q+v9n33wNhUJ+lus+rJobUj4vJaQBPxPZ0vYrPGuxdrenyCAIJcow==",
"license": "MIT",
"dependencies": {
"tslib": "2.6.2"
"tslib": "2.8.1"
},
"engines": {
"node": ">=8.12"
"node": ">=18"
}
},
"node_modules/envalid/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD"
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
@ -34537,15 +34531,15 @@
}
},
"node_modules/prisma": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz",
"integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.15.0.tgz",
"integrity": "sha512-E6RCgOt+kUVtjtZgLQDBJ6md2tDItLJNExwI0XJeBc1FKL+Vwb+ovxXxuok9r8oBgsOXBA33fGDuE/0qDdCWqQ==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.14.0",
"@prisma/engines": "6.14.0"
"@prisma/config": "6.15.0",
"@prisma/engines": "6.15.0"
},
"bin": {
"prisma": "build/index.js"

8
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.195.0",
"version": "2.196.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -90,7 +90,7 @@
"@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.14.0",
"@prisma/client": "6.15.0",
"@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0",
@ -112,7 +112,7 @@
"countries-list": "3.1.1",
"countup.js": "2.8.2",
"date-fns": "4.1.0",
"envalid": "8.0.0",
"envalid": "8.1.0",
"fuse.js": "7.1.0",
"google-spreadsheet": "3.2.0",
"helmet": "7.0.0",
@ -195,7 +195,7 @@
"nx": "21.3.9",
"prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.14.0",
"prisma": "6.15.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.3.0",

42
test/import/ok/btcusd-short.json

@ -0,0 +1,42 @@
{
"meta": {
"date": "2021-12-12T00:00:00.000Z",
"version": "dev"
},
"accounts": [],
"platforms": [],
"tags": [],
"activities": [
{
"accountId": null,
"comment": null,
"fee": 4.46,
"quantity": 1,
"type": "SELL",
"unitPrice": 44558.42,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-12-12T00:00:00.000Z",
"symbol": "BTCUSD",
"tags": []
},
{
"accountId": null,
"comment": null,
"fee": 4.46,
"quantity": 1,
"type": "SELL",
"unitPrice": 46737.48,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-12-13T00:00:00.000Z",
"symbol": "BTCUSD",
"tags": []
}
],
"user": {
"settings": {
"currency": "USD"
}
}
}
Loading…
Cancel
Save