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. 17
      apps/api/src/app/admin/admin.service.ts
  3. 2
      apps/api/src/app/admin/queue/queue.service.ts
  4. 18
      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. 187
      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. 2
      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. 10
      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. 40
      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. 163
      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 ## Unreleased
### Added
- Enabled automatic data gathering for custom currencies added via the currency management in the admin control panel
### Changed ### 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 - 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 benchmark comparator component to standalone
- Refactored the portfolio summary component to standalone
- Refactored the world map chart 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`) - Improved the language localization for German (`de`)
- Upgraded the _Stripe_ dependencies - Upgraded the _Stripe_ dependencies
- Upgraded `ngx-device-detector` from version `10.0.2` to `10.1.0` - 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 `ngx-skeleton-loader` from version `11.2.1` to `11.3.0`
- Upgraded `yahoo-finance2` from version `3.6.4` to `3.8.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 ## 2.195.0 - 2025-08-29
### Changed ### Changed

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

@ -136,7 +136,9 @@ export class AdminService {
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
const dataSources = Object.values(DataSource); const dataSources = Object.values(DataSource);
const [settings, transactionCount, userCount] = await Promise.all([ const [enabledDataSources, settings, transactionCount, userCount] =
await Promise.all([
this.dataProviderService.getDataSources(),
this.propertyService.get(), this.propertyService.get(),
this.prismaService.order.count(), this.prismaService.order.count(),
this.countUsersWithAnalytics() this.countUsersWithAnalytics()
@ -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 const dataProviderInfo = this.dataProviderService
.getDataProvider(dataSource) .getDataProvider(dataSource)
.getDataProviderInfo(); .getDataProviderInfo();
return { return {
...dataProviderInfo, ...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) .slice(0, limit)
.map(async (job) => { .map(async (job) => {
return { return {
attemptsMade: job.attemptsMade + 1, attemptsMade: job.attemptsMade,
data: job.data, data: job.data,
finishedOn: job.finishedOn, finishedOn: job.finishedOn,
id: job.id, id: job.id,

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

@ -927,13 +927,25 @@ export abstract class PortfolioCalculator {
.plus(oldAccumulatedSymbol.quantity); .plus(oldAccumulatedSymbol.quantity);
if (type === 'BUY') { if (type === 'BUY') {
if (oldAccumulatedSymbol.investment.gte(0)) {
investment = oldAccumulatedSymbol.investment.plus( investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(unitPrice) quantity.mul(unitPrice)
); );
} else {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
}
} else if (type === 'SELL') { } else if (type === 'SELL') {
if (oldAccumulatedSymbol.investment.gt(0)) {
investment = oldAccumulatedSymbol.investment.minus( investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(oldAccumulatedSymbol.averagePrice) quantity.mul(oldAccumulatedSymbol.averagePrice)
); );
} else {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(unitPrice)
);
}
} }
currentTransactionPointItem = { currentTransactionPointItem = {
@ -942,9 +954,9 @@ export abstract class PortfolioCalculator {
investment, investment,
skipErrors, skipErrors,
symbol, symbol,
averagePrice: newQuantity.gt(0) averagePrice: newQuantity.eq(0)
? investment.div(newQuantity) ? new Big(0)
: new Big(0), : investment.div(newQuantity).abs(),
dividend: new Big(0), dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee), fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, 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.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic' this.request.user.subscription.type === 'Basic'
) { ) {
for (const rule in report.xRay.rules) { for (const category of report.xRay.categories) {
report.xRay.rules[rule] = null; category.rules = null;
} }
report.xRay.statistics = { report.xRay.statistics = {

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

@ -50,6 +50,7 @@ import {
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
PortfolioReportResponse, PortfolioReportResponse,
PortfolioReportRule,
PortfolioSummary, PortfolioSummary,
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -1231,48 +1232,54 @@ export class PortfolioService {
}) })
).toNumber(); ).toNumber();
const rules: PortfolioReportResponse['xRay']['rules'] = { const categories: PortfolioReportResponse['xRay']['categories'] = [
accountClusterRisk: {
summary.activityCount > 0 key: 'liquidity',
? await this.rulesService.evaluate( name: this.i18nService.getTranslation({
id: 'rule.liquidity.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[ [
new AccountClusterRiskCurrentInvestment( new BuyingPower(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
),
new AccountClusterRiskSingleAccount(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
userSettings.language, summary.cash,
accounts userSettings.language
) )
], ],
userSettings userSettings
) )
: undefined, },
assetClassClusterRisk: {
summary.activityCount > 0 key: 'emergencyFund',
? await this.rulesService.evaluate( name: this.i18nService.getTranslation({
id: 'rule.emergencyFund.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[ [
new AssetClassClusterRiskEquity( new EmergencyFundSetup(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
),
new AssetClassClusterRiskFixedIncome(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
userSettings.language, userSettings.language,
Object.values(holdings) this.getTotalEmergencyFund({
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings })
}).toNumber()
) )
], ],
userSettings userSettings
) )
: undefined, },
currencyClusterRisk: {
key: 'currencyClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.currencyClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
@ -1291,68 +1298,98 @@ export class PortfolioService {
], ],
userSettings userSettings
) )
: undefined, : undefined
economicMarketClusterRisk: },
{
key: 'assetClassClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.assetClassClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new EconomicMarketClusterRiskDevelopedMarkets( new AssetClassClusterRiskEquity(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
marketsTotalInBaseCurrency, userSettings.language,
markets.developedMarkets.valueInBaseCurrency, Object.values(holdings)
userSettings.language
), ),
new EconomicMarketClusterRiskEmergingMarkets( new AssetClassClusterRiskFixedIncome(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
marketsTotalInBaseCurrency, userSettings.language,
markets.emergingMarkets.valueInBaseCurrency, Object.values(holdings)
userSettings.language
) )
], ],
userSettings userSettings
) )
: undefined, : undefined
emergencyFund: await this.rulesService.evaluate( },
{
key: 'accountClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.accountClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[ [
new EmergencyFundSetup( new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
userSettings.language, userSettings.language,
this.getTotalEmergencyFund({ accounts
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings })
}).toNumber()
)
],
userSettings
), ),
fees: await this.rulesService.evaluate( new AccountClusterRiskSingleAccount(
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
userSettings.language, userSettings.language,
summary.committedFunds, accounts
summary.fees
) )
], ],
userSettings userSettings
), )
liquidity: await this.rulesService.evaluate( : undefined
},
{
key: 'economicMarketClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.economicMarketClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[ [
new BuyingPower( new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
summary.cash, marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency,
userSettings.language
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency,
userSettings.language userSettings.language
) )
], ],
userSettings userSettings
), )
regionalMarketClusterRisk: : undefined
},
{
key: 'regionalMarketClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.regionalMarketClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
@ -1395,12 +1432,36 @@ export class PortfolioService {
userSettings userSettings
) )
: undefined : 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
)
],
userSettings
)
}
];
return { return {
xRay: { xRay: {
rules, categories,
statistics: this.getReportStatistics(rules) statistics: this.getReportStatistics(
categories.flatMap(({ rules }) => {
return rules ?? [];
})
)
} }
}; };
} }
@ -1822,7 +1883,7 @@ export class PortfolioService {
} }
private getReportStatistics( private getReportStatistics(
evaluatedRules: PortfolioReportResponse['xRay']['rules'] evaluatedRules: PortfolioReportRule[]
): PortfolioReportResponse['xRay']['statistics'] { ): PortfolioReportResponse['xRay']['statistics'] {
const rulesActiveCount = Object.values(evaluatedRules) const rulesActiveCount = Object.values(evaluatedRules)
.flat() .flat()

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

@ -22,7 +22,6 @@ export class RulesService {
return { return {
evaluation, evaluation,
value, value,
categoryName: rule.getCategoryName(),
configuration: rule.getConfiguration(), configuration: rule.getConfiguration(),
isActive: true, isActive: true,
key: rule.getKey(), key: rule.getKey(),
@ -30,7 +29,6 @@ export class RulesService {
}; };
} else { } else {
return { return {
categoryName: rule.getCategoryName(),
isActive: false, isActive: false,
key: rule.getKey(), key: rule.getKey(),
name: rule.getName() 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 value: false
}; };
} }
return { return {
evaluation: this.i18nService.getTranslation({ evaluation: this.i18nService.getTranslation({
id: 'rule.assetClassClusterRiskEquity.true', 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 value: false
}; };
} }
return { return {
evaluation: this.i18nService.getTranslation({ evaluation: this.i18nService.getTranslation({
id: 'rule.assetClassClusterRiskFixedIncome.true', 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) { public evaluate(ruleSettings: Settings) {
if (this.buyingPower < ruleSettings.thresholdMin) { if (this.buyingPower === 0) {
return { return {
evaluation: this.i18nService.getTranslation({ 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(), languageCode: this.getLanguageCode(),
placeholders: { placeholders: {
baseCurrency: ruleSettings.baseCurrency, baseCurrency: ruleSettings.baseCurrency,

3
apps/client/project.json

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

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

@ -111,9 +111,7 @@ const routes: Routes = [
{ {
path: publicRoutes.public.path, path: publicRoutes.public.path,
loadChildren: () => loadChildren: () =>
import('./pages/public/public-page.module').then( import('./pages/public/public-page.routes').then((m) => m.routes)
(m) => m.PublicPageModule
)
}, },
{ {
path: publicRoutes.register.path, 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 { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; 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 { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config';
@ -13,7 +16,12 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { OrderWithAccount } from '@ghostfolio/common/types'; 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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
@ -22,10 +30,15 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort'; import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
@ -35,20 +48,36 @@ import {
walletOutline walletOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin, Subject } from 'rxjs'; import { forkJoin, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces'; import { AccountDetailDialogParams } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' }, 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', selector: 'gf-account-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'account-detail-dialog.html',
styleUrls: ['./account-detail-dialog.component.scss'], 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 accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[]; public activities: OrderWithAccount[];
public balance: number; public balance: number;
@ -81,7 +110,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>, public dialogRef: MatDialogRef<GfAccountDetailDialogComponent>,
private router: Router, private router: Router,
private userService: UserService private userService: UserService
) { ) {

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

@ -1,5 +1,4 @@
<gf-dialog-header <gf-dialog-header
mat-dialog-title
position="center" position="center"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[title]="name" [title]="name"
@ -166,7 +165,6 @@
</div> </div>
<gf-dialog-footer <gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
(closeButtonClicked)="onClose()" (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 { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { import {
DEFAULT_CURRENCY,
ghostfolioPrefix, ghostfolioPrefix,
PROPERTY_CURRENCIES PROPERTY_CURRENCIES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -29,8 +30,9 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { DataSource } from '@prisma/client';
import { isISO4217CurrencyCode } from 'class-validator'; import { isISO4217CurrencyCode } from 'class-validator';
import { Subject, takeUntil } from 'rxjs'; import { Subject, switchMap, takeUntil } from 'rxjs';
import { CreateAssetProfileDialogMode } from './interfaces/interfaces'; import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
@ -56,6 +58,7 @@ export class GfCreateAssetProfileDialogComponent implements OnInit, OnDestroy {
public mode: CreateAssetProfileDialogMode; public mode: CreateAssetProfileDialogMode;
private customCurrencies: string[]; private customCurrencies: string[];
private dataSourceForExchangeRates: DataSource;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@ -67,7 +70,7 @@ export class GfCreateAssetProfileDialogComponent implements OnInit, OnDestroy {
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.initializeCustomCurrencies(); this.initialize();
this.createAssetProfileForm = this.formBuilder.group( this.createAssetProfileForm = this.formBuilder.group(
{ {
@ -115,7 +118,15 @@ export class GfCreateAssetProfileDialogComponent implements OnInit, OnDestroy {
.putAdminSetting(PROPERTY_CURRENCIES, { .putAdminSetting(PROPERTY_CURRENCIES, {
value: JSON.stringify(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(() => { .subscribe(() => {
this.dialogRef.close(); this.dialogRef.close();
}); });
@ -170,13 +181,19 @@ export class GfCreateAssetProfileDialogComponent implements OnInit, OnDestroy {
return { atLeastOneValid: true }; return { atLeastOneValid: true };
} }
private initializeCustomCurrencies() { private initialize() {
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ settings }) => { .subscribe(({ dataProviders, settings }) => {
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[]; this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
const { dataSource } = dataProviders.find(({ useForExchangeRates }) => {
return useForExchangeRates;
});
this.dataSourceForExchangeRates = dataSource;
this.changeDetectorRef.markForCheck(); 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 this.status$ = this.dataService
.fetchDataProviderHealth(this.dataSource) .fetchDataProviderHealth(this.dataSource)
.pipe( .pipe(
catchError(() => {
return of({ isHealthy: false });
}),
map(() => { map(() => {
return { isHealthy: true }; return { isHealthy: true };
}), }),
catchError(() => {
return of({ isHealthy: false });
}),
takeUntil(this.unsubscribeSubject) takeUntil(this.unsubscribeSubject)
); );
} }

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

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

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

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

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

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

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

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

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

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

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

@ -7,6 +7,7 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { close } from 'ionicons/icons'; import { close } from 'ionicons/icons';
@ -14,7 +15,7 @@ import { close } from 'ionicons/icons';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'justify-content-center' }, host: { class: 'justify-content-center' },
imports: [CommonModule, IonIcon, MatButtonModule], imports: [CommonModule, IonIcon, MatButtonModule, MatDialogModule],
selector: 'gf-dialog-header', selector: 'gf-dialog-header',
styleUrls: ['./dialog-header.component.scss'], styleUrls: ['./dialog-header.component.scss'],
templateUrl: './dialog-header.component.html' 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 <gf-dialog-header
mat-dialog-title
position="center" position="center"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[title]="SymbolProfile?.name ?? SymbolProfile?.symbol" [title]="SymbolProfile?.name ?? SymbolProfile?.symbol"
@ -459,7 +458,6 @@
</div> </div>
<gf-dialog-footer <gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
(closeButtonClicked)="onClose()" (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 { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -22,7 +22,7 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
imports: [GfPortfolioSummaryModule, MatCardModule], imports: [GfPortfolioSummaryComponent, MatCardModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-home-summary', selector: 'gf-home-summary',
styleUrls: ['./home-summary.scss'], 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 <gf-dialog-header [title]="data.title" (closeButtonClicked)="onClose()" />
mat-dialog-title
[title]="data.title"
(closeButtonClicked)="onClose()"
/>
<div class="py-3" mat-dialog-content> <div class="py-3" mat-dialog-content>
<div class="align-items-center d-flex flex-column"> <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 { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper';
import { PortfolioSummary, User } from '@ghostfolio/common/interfaces'; import { PortfolioSummary, User } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -11,6 +13,8 @@ import {
OnChanges, OnChanges,
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { IonIcon } from '@ionic/angular/standalone';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
@ -19,13 +23,13 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
@Component({ @Component({
selector: 'gf-portfolio-summary',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-summary.component.html', imports: [CommonModule, GfValueComponent, IonIcon, MatTooltipModule],
selector: 'gf-portfolio-summary',
styleUrls: ['./portfolio-summary.component.scss'], 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() baseCurrency: string;
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: 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'; } from '@ghostfolio/common/interfaces';
export interface IRuleSettingsDialogParams { export interface IRuleSettingsDialogParams {
categoryName: string;
rule: PortfolioReportRule; rule: PortfolioReportRule;
settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment']; 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> <div class="py-3" mat-dialog-content>
@if ( @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 standalone: false
}) })
export class RuleComponent implements OnInit { export class RuleComponent implements OnInit {
@Input() categoryName: string;
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() rule: PortfolioReportRule; @Input() rule: PortfolioReportRule;
@ -69,6 +70,7 @@ export class RuleComponent implements OnInit {
const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, { const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, {
data: { data: {
rule, rule,
categoryName: this.categoryName,
settings: this.settings settings: this.settings
} as IRuleSettingsDialogParams, } as IRuleSettingsDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' 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) { @if (rules !== null && rules !== undefined) {
@for (rule of rules; track rule.key) { @for (rule of rules; track rule.key) {
<gf-rule <gf-rule
[categoryName]="categoryName"
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
hasPermissionToUpdateUserSettings hasPermissionToUpdateUserSettings
" "

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

@ -20,6 +20,7 @@ import {
standalone: false standalone: false
}) })
export class RulesComponent { export class RulesComponent {
@Input() categoryName: string;
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() rules: PortfolioReportRule[]; @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, Inject,
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; 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 { StatusCodes } from 'http-status-codes';
import { EMPTY, Subject, catchError, takeUntil } from 'rxjs'; import { EMPTY, Subject, catchError, takeUntil } from 'rxjs';
@ -20,12 +34,20 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' }, host: { class: 'h-100' },
imports: [
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule
],
selector: 'gf-create-or-update-access-dialog', selector: 'gf-create-or-update-access-dialog',
styleUrls: ['./create-or-update-access-dialog.scss'], styleUrls: ['./create-or-update-access-dialog.scss'],
templateUrl: 'create-or-update-access-dialog.html', templateUrl: 'create-or-update-access-dialog.html'
standalone: false
}) })
export class CreateOrUpdateAccessDialog implements OnDestroy { export class GfCreateOrUpdateAccessDialog implements OnDestroy {
public accessForm: FormGroup; public accessForm: FormGroup;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -33,7 +55,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams, @Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>, public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialog>,
private dataService: DataService, private dataService: DataService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService 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 { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; import { GfCreateOrUpdateAccessDialog } 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';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' }, host: { class: 'has-fab' },
imports: [ imports: [
GfAccessTableComponent, GfAccessTableComponent,
GfCreateOrUpdateAccessDialogModule,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
@ -181,7 +179,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
} }
private openCreateAccessDialog() { private openCreateAccessDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, { const dialogRef = this.dialog.open(GfCreateOrUpdateAccessDialog, {
data: { data: {
access: { access: {
alias: '', 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 { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto'; import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.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 { GfAccountDetailDialogComponent } 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 { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
@ -28,12 +27,7 @@ import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-ba
@Component({ @Component({
host: { class: 'has-fab page' }, host: { class: 'has-fab page' },
imports: [ imports: [GfAccountsTableComponent, MatButtonModule, RouterModule],
GfAccountDetailDialogModule,
GfAccountsTableComponent,
MatButtonModule,
RouterModule
],
selector: 'gf-accounts-page', selector: 'gf-accounts-page',
styleUrls: ['./accounts-page.scss'], styleUrls: ['./accounts-page.scss'],
templateUrl: './accounts-page.html' templateUrl: './accounts-page.html'
@ -233,7 +227,7 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
} }
private openAccountDetailDialog(aAccountId: string) { private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open(AccountDetailDialog, { const dialogRef = this.dialog.open(GfAccountDetailDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
accountId: aAccountId, accountId: aAccountId,

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

@ -69,10 +69,13 @@
</li> </li>
<li i18n="@@rule.liquidity.category">Liquidity</li> <li i18n="@@rule.liquidity.category">Liquidity</li>
<li i18n="@@rule.liquidityBuyingPower">Buying Power</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; Your buying power is below $&#123;thresholdMin&#125;
$&#123;baseCurrency&#125; $&#123;baseCurrency&#125;
</li> </li>
<li i18n="@@rule.liquidityBuyingPower.false.zero">
Your buying power is 0 $&#123;baseCurrency&#125;
</li>
<li i18n="@@rule.liquidityBuyingPower.true"> <li i18n="@@rule.liquidityBuyingPower.true">
Your buying power exceeds $&#123;thresholdMin&#125; Your buying power exceeds $&#123;thresholdMin&#125;
$&#123;baseCurrency&#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, deviceType: this.deviceType,
user: this.user user: this.user
} as ImportActivitiesDialogParams, } as ImportActivitiesDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
@ -273,6 +274,7 @@ export class GfActivitiesPageComponent implements OnDestroy, OnInit {
deviceType: this.deviceType, deviceType: this.deviceType,
user: this.user user: this.user
} as ImportActivitiesDialogParams, } as ImportActivitiesDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' 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({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' },
imports: [ imports: [
GfActivitiesTableComponent, GfActivitiesTableComponent,
GfDialogFooterComponent, GfDialogFooterComponent,

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

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

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

@ -1,6 +1,9 @@
:host { :host {
display: block; display: block;
.mat-mdc-dialog-content {
max-height: unset;
a { a {
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
} }
@ -15,24 +18,6 @@
} }
} }
.mat-expansion-panel {
background: none;
box-shadow: none;
.mat-expansion-panel-header {
color: inherit;
&[aria-disabled='true'] {
cursor: default;
}
}
}
.mat-mdc-progress-spinner {
right: 1.5rem;
top: calc(50% - 10px);
}
.drop-area { .drop-area {
background-color: rgba(var(--palette-foreground-base), 0.02); background-color: rgba(var(--palette-foreground-base), 0.02);
border: 1px dashed border: 1px dashed
@ -51,6 +36,25 @@
font-size: 2.5rem; font-size: 2.5rem;
} }
} }
.mat-mdc-progress-spinner {
right: 1.5rem;
top: calc(50% - 10px);
}
.mat-expansion-panel {
background: none;
box-shadow: none;
.mat-expansion-panel-header {
color: inherit;
&[aria-disabled='true'] {
cursor: default;
}
}
}
}
} }
:host-context(.theme-dark) { :host-context(.theme-dark) {

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

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

@ -59,182 +59,29 @@
</div> </div>
} }
</div> </div>
@for (category of categories; track category.key) {
<div <div
class="mb-4" class="mb-4"
[ngClass]="{ [ngClass]="{ 'd-none': category.rules?.length === 0 }"
'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"> <h4 class="align-items-center d-flex m-0">
<span i18n>Account Cluster Risks</span> <span>{{ category.name }}</span>
@if (user?.subscription?.type === 'Basic') { @if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" /> <gf-premium-indicator class="ml-1" />
} }
</h4> </h4>
<gf-rules <gf-rules
[categoryName]="category.name"
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings !hasImpersonationId && hasPermissionToUpdateUserSettings
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="accountClusterRiskRules" [rules]="category.rules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
/> />
</div> </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>
@if (inactiveRules?.length > 0) { @if (inactiveRules?.length > 0) {
<div> <div>
<h4 class="m-0" i18n>Inactive</h4> <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' templateUrl: './x-ray-page.component.html'
}) })
export class GfXRayPageComponent { export class GfXRayPageComponent {
public accountClusterRiskRules: PortfolioReportRule[]; public categories: {
public assetClassClusterRiskRules: PortfolioReportRule[]; key: string;
public currencyClusterRiskRules: PortfolioReportRule[]; name: string;
public economicMarketClusterRiskRules: PortfolioReportRule[]; rules: PortfolioReportRule[];
public emergencyFundRules: PortfolioReportRule[]; }[];
public feeRules: PortfolioReportRule[];
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[]; public inactiveRules: PortfolioReportRule[];
public isLoading = false; public isLoading = false;
public liquidityRules: PortfolioReportRule[];
public regionalMarketClusterRiskRules: PortfolioReportRule[];
public statistics: PortfolioReportResponse['xRay']['statistics']; public statistics: PortfolioReportResponse['xRay']['statistics'];
public user: User; public user: User;
@ -116,50 +113,11 @@ export class GfXRayPageComponent {
this.dataService this.dataService
.fetchPortfolioReport() .fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ xRay: { rules, statistics } }) => { .subscribe(({ xRay: { categories, statistics } }) => {
this.inactiveRules = this.mergeInactiveRules(rules); this.categories = categories;
this.inactiveRules = this.mergeInactiveRules(categories);
this.statistics = statistics; 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.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
@ -167,20 +125,14 @@ export class GfXRayPageComponent {
} }
private mergeInactiveRules( private mergeInactiveRules(
rules: PortfolioReportResponse['xRay']['rules'] categories: PortfolioReportResponse['xRay']['categories']
): PortfolioReportRule[] { ): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = []; return categories.flatMap(({ rules }) => {
return (
for (const category in rules) { rules?.filter(({ isActive }) => {
const rulesArray = rules[category] || [];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {
return !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 { DataService } from '@ghostfolio/client/services/data.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper'; import { prettifySymbol } from '@ghostfolio/common/helper';
@ -6,8 +7,19 @@ import {
PublicPortfolioResponse PublicPortfolioResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Market } from '@ghostfolio/common/types'; 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 { ActivatedRoute, Router } from '@angular/router';
import { AssetClass } from '@prisma/client'; import { AssetClass } from '@prisma/client';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
@ -18,12 +30,21 @@ import { catchError, takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [
CommonModule,
GfHoldingsTableComponent,
GfPortfolioProportionChartComponent,
GfValueComponent,
GfWorldMapChartComponent,
MatButtonModule,
MatCardModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-public-page', selector: 'gf-public-page',
styleUrls: ['./public-page.scss'], styleUrls: ['./public-page.scss'],
templateUrl: './public-page.html', templateUrl: './public-page.html'
standalone: false
}) })
export class PublicPageComponent implements OnInit { export class GfPublicPageComponent implements OnInit {
public continents: { public continents: {
[code: string]: { name: string; value: number }; [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'; import { DataProviderInfo } from './data-provider-info.interface';
export interface AdminData { export interface AdminData {
dataProviders: (DataProviderInfo & { assetProfileCount: number })[]; dataProviders: (DataProviderInfo & {
assetProfileCount: number;
useForExchangeRates: boolean;
})[];
settings: { [key: string]: boolean | object | string | string[] }; settings: { [key: string]: boolean | object | string | string[] };
transactionCount: number; transactionCount: number;
userCount: number; userCount: number;

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

@ -1,5 +1,4 @@
export interface PortfolioReportRule { export interface PortfolioReportRule {
categoryName: string;
configuration?: { configuration?: {
threshold?: { threshold?: {
max: number; 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 { export interface PortfolioReportResponse {
xRay: { xRay: {
rules: { [group: string]: PortfolioReportRule[] }; categories: {
key: string;
name: string;
rules: PortfolioReportRule[];
}[];
statistics: { statistics: {
rulesActiveCount: number; rulesActiveCount: number;
rulesFulfilledCount: number; rulesFulfilledCount: number;

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

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

94
package-lock.json

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

8
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.195.0", "version": "2.196.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",
@ -90,7 +90,7 @@
"@nestjs/schedule": "6.0.0", "@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3", "@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2", "@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.14.0", "@prisma/client": "6.15.0",
"@simplewebauthn/browser": "13.1.0", "@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1", "@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0", "@stripe/stripe-js": "7.9.0",
@ -112,7 +112,7 @@
"countries-list": "3.1.1", "countries-list": "3.1.1",
"countup.js": "2.8.2", "countup.js": "2.8.2",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"envalid": "8.0.0", "envalid": "8.1.0",
"fuse.js": "7.1.0", "fuse.js": "7.1.0",
"google-spreadsheet": "3.2.0", "google-spreadsheet": "3.2.0",
"helmet": "7.0.0", "helmet": "7.0.0",
@ -195,7 +195,7 @@
"nx": "21.3.9", "nx": "21.3.9",
"prettier": "3.6.2", "prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.14.0", "prisma": "6.15.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "8.3.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