Browse Source

Merge branch 'main' into bugfix/date-ranges-in-time-zones-other-than-utc

pull/8099/head
Thomas Kaul 5 days ago
committed by GitHub
parent
commit
f667c5cfee
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 18
      CHANGELOG.md
  2. 2
      README.md
  3. 96
      apps/api/src/app/activities/activities.service.spec.ts
  4. 27
      apps/api/src/app/activities/activities.service.ts
  5. 178
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts
  6. 63
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  7. 162
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  8. 61
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  9. 16
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  10. 48
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  11. 37
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  12. 164
      apps/api/src/app/import/import.service.spec.ts
  13. 21
      apps/api/src/app/import/import.service.ts
  14. 27
      apps/api/src/app/portfolio/portfolio.controller.ts
  15. 1
      apps/api/src/app/portfolio/portfolio.service.ts
  16. 26
      apps/api/src/services/data-provider/data-provider.service.ts
  17. 1
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  18. 209
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  19. 235
      apps/api/src/services/portfolio-table/portfolio-table.service.ts
  20. 2
      apps/client/src/app/app.component.html
  21. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  22. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  23. 8
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html
  24. 18
      apps/client/src/app/components/footer/footer.component.html
  25. 80
      apps/client/src/app/components/footer/footer.component.ts
  26. 10
      apps/client/src/app/components/header/header.component.html
  27. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  28. 1
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  29. 56
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  30. 10
      apps/client/src/app/components/home-holdings/home-holdings.html
  31. 8
      apps/client/src/app/components/home-overview/home-overview.component.ts
  32. 33
      apps/client/src/app/components/home-overview/home-overview.html
  33. 23
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  34. 24
      apps/client/src/app/components/markets/markets.component.ts
  35. 2
      apps/client/src/app/components/markets/markets.html
  36. 4
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  37. 1
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.scss
  38. 14
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  39. 2
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  40. 26
      apps/client/src/app/components/rule/rule.component.ts
  41. 11
      apps/client/src/app/components/rules/rules.component.ts
  42. 16
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  43. 2
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  44. 4
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  45. 8
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  46. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  47. 44
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  48. 29
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  49. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  50. 32
      apps/client/src/app/pages/pricing/pricing-page.html
  51. 7
      apps/client/src/app/util/date.util.ts
  52. 20
      apps/client/tsconfig.json
  53. 45
      libs/common/src/lib/helper.spec.ts
  54. 23
      libs/common/src/lib/helper.ts
  55. 37
      libs/common/src/lib/scopes.spec.ts
  56. 13
      libs/common/src/lib/scopes.ts
  57. 2
      libs/ui/src/lib/line-chart/line-chart.component.ts
  58. 7
      libs/ui/src/lib/services/data.service.ts
  59. 4
      package-lock.json
  60. 2
      package.json

18
CHANGELOG.md

@ -7,17 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Changed
- Extended the access to share the portfolio with the Model Context Protocol (MCP) to support the _View_ and the _View and manage_ permissions (experimental)
- Improved the performance of getting the historical market data by selecting only the required columns
### Fixed
- Fixed the start date of the date ranges in the portfolio performance calculation for instances in time zones with a negative UTC offset
## 3.83.0 - 2026-10-10
### Added ### Added
- Added the performance timeline grouped by year to the analysis page (experimental)
- Extended the `GET api/v2/portfolio/performance` endpoint by the `groupBy` query parameter (`year`) - Extended the `GET api/v2/portfolio/performance` endpoint by the `groupBy` query parameter (`year`)
### Changed ### Changed
- Extended the benchmark comparator to support benchmarks whose market data starts later than the portfolio
- Harmonized the colors in the investment chart component
- Improved the performance of getting the historical market data by loading only the requested asset profiles
- Improved the language localization for Chinese (`zh`) - Improved the language localization for Chinese (`zh`)
### Fixed ### Fixed
- Fixed the start date of the date ranges in the portfolio performance calculation for instances in time zones with a negative UTC offset - Fixed an issue where a user could create a custom asset profile with a symbol of the prefix `GF_`
- Fixed the horizontal overflow on the overview tab of the home page on mobile
## 3.82.0 - 2026-10-09 ## 3.82.0 - 2026-10-09

2
README.md

@ -407,7 +407,7 @@ The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio
- Set `ROOT_URL` to the public URL of your instance if a client calls the endpoint from a browser page. The host name of `ROOT_URL` is the only accepted origin. - Set `ROOT_URL` to the public URL of your instance if a client calls the endpoint from a browser page. The host name of `ROOT_URL` is the only accepted origin.
- Grant an access of the type _MCP_ in _My Ghostfolio_ under _Access_ and copy its token - Grant an access of the type _MCP_ in _My Ghostfolio_ under _Access_ and copy its token
An _MCP_ access has (restricted) read scopes and never reads the monetary values. Grant the _Restricted view and manage_ permission to let the client also import activities. An _MCP_ access with the _Restricted view_ permission reads the portfolio without the monetary values. Grant the _View_ permission to let the client also read the monetary values. Grant the _Restricted view and manage_ or the _View and manage_ permission to let the client also import activities.
### Connect a client ### Connect a client

96
apps/api/src/app/activities/activities.service.spec.ts

@ -8,6 +8,7 @@ import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
INVESTMENT_ACTIVITY_TYPES, INVESTMENT_ACTIVITY_TYPES,
NON_INVESTMENT_ACTIVITY_TYPES NON_INVESTMENT_ACTIVITY_TYPES
@ -15,12 +16,15 @@ import {
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity, Filter } from '@ghostfolio/common/interfaces'; import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { import {
AssetProfileSplit, AssetProfileSplit,
DataSource, DataSource,
Prisma,
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
@ -49,6 +53,98 @@ describe('ActivitiesService', () => {
); );
}); });
describe('createActivity', () => {
it('creates a custom asset profile with a UUID if no asset profile exists for a symbol with the prefix', async () => {
const assetProfile = await getCreatedAssetProfile({ symbol: 'GF_COPX' });
expect(isUUID(assetProfile.symbol)).toBe(true);
expect(assetProfile.name).toBe('GF_COPX');
});
it('connects to the existing asset profile of a symbol with the prefix', async () => {
const assetProfile = await getCreatedAssetProfile({
existingAssetProfile: { id: 'asset-profile-id' },
symbol: 'GF_COPX'
});
expect(assetProfile.symbol).toBe('GF_COPX');
});
it('creates a custom asset profile with the requested UUID if no asset profile exists', async () => {
const symbol = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
const assetProfile = await getCreatedAssetProfile({ symbol });
expect(assetProfile.symbol).toBe(symbol);
});
async function getCreatedAssetProfile({
existingAssetProfile,
symbol
}: {
existingAssetProfile?: { id: string };
symbol: string;
}) {
const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => {
return {
...data,
SymbolProfile: data.SymbolProfile.connectOrCreate.create
};
});
const service = new ActivitiesService(
null,
null,
null,
null,
null,
null,
{ emit: jest.fn() } as unknown as EventEmitter2,
null,
null,
{
order: { create },
symbolProfile: {
findUnique: jest
.fn()
.mockResolvedValue(existingAssetProfile ?? null)
}
} as unknown as PrismaService,
null,
{ validateTagIds: jest.fn() } as unknown as TagService
);
await service.createActivity({
currency: 'USD',
date: parseDate('2024-01-01'),
fee: 0,
quantity: 1,
SymbolProfile: {
connectOrCreate: {
create: { symbol, currency: 'USD', dataSource: DataSource.MANUAL },
where: {
dataSource_symbol: { symbol, dataSource: DataSource.MANUAL }
}
}
},
type: 'BUY',
unitPrice: 100,
user: { connect: { id: 'user-id' } },
userId: 'user-id'
});
const { create: assetProfile, where } =
create.mock.calls[0][0].data.SymbolProfile.connectOrCreate;
expect(where.dataSource_symbol).toEqual({
dataSource: DataSource.MANUAL,
symbol: assetProfile.symbol
});
return assetProfile;
}
});
describe('getActivities', () => { describe('getActivities', () => {
it('returns the activities with the asset profile but without the relation to the symbol profile', async () => { it('returns the activities with the asset profile but without the relation to the symbol profile', async () => {
const assetProfile = { const assetProfile = {

27
apps/api/src/app/activities/activities.service.ts

@ -34,10 +34,11 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
canUseCustomAssetProfileSymbol,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getStartOfUtcDateOfTomorrow, getStartOfUtcDateOfTomorrow,
isDraftActivity, hasGhostfolioPrefix,
isValidCustomAssetProfileSymbol isDraftActivity
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
@ -220,19 +221,31 @@ export class ActivitiesService {
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const requestedSymbol = data.SymbolProfile.connectOrCreate.create.symbol;
let name = data.SymbolProfile.connectOrCreate.create.name; let name = data.SymbolProfile.connectOrCreate.create.name;
let symbol: string; let symbol: string;
const existingAssetProfile = hasGhostfolioPrefix(requestedSymbol)
? await this.prismaService.symbolProfile.findUnique({
select: { id: true },
where: {
dataSource_symbol: { dataSource, symbol: requestedSymbol }
}
})
: null;
if ( if (
isValidCustomAssetProfileSymbol( canUseCustomAssetProfileSymbol({
data.SymbolProfile.connectOrCreate.create.symbol assetProfile: existingAssetProfile,
) symbol: requestedSymbol
})
) { ) {
// Connect custom asset profile (clone) // Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol; symbol = requestedSymbol;
} else { } else {
// Create custom asset profile // Create custom asset profile
name = name ?? data.SymbolProfile.connectOrCreate.create.symbol; name = name ?? requestedSymbol;
symbol = randomUUID(); symbol = randomUUID();
} }

178
apps/api/src/app/endpoints/benchmarks/benchmarks.service.spec.ts

@ -0,0 +1,178 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client';
import { BenchmarksService } from './benchmarks.service';
describe('BenchmarksService', () => {
let benchmarksService: BenchmarksService;
let get: jest.Mock;
let getExchangeRatesByCurrency: jest.Mock;
let getPerformance: jest.Mock;
let getRange: jest.Mock;
let marketDataItems: jest.Mock;
beforeEach(() => {
get = jest.fn().mockResolvedValue({ currency: 'USD', marketPrice: 200 });
getExchangeRatesByCurrency = jest.fn().mockResolvedValue({});
getPerformance = jest.fn().mockResolvedValue({
chart: [
{ date: '2009-01-01' },
{ date: '2009-06-01' },
{ date: '2010-07-18' },
{ date: '2010-07-19' }
]
});
getRange = jest.fn().mockResolvedValue([]);
marketDataItems = jest.fn();
benchmarksService = new BenchmarksService(
new BenchmarkService(null, null, null, null, null, null),
{ getExchangeRatesByCurrency } as unknown as ExchangeRateDataService,
{ getRange, marketDataItems } as unknown as MarketDataService,
{ getPerformance } as unknown as PortfolioService,
{ get } as unknown as SymbolService
);
});
describe('getMarketDataForUser', () => {
const getMarketDataForUser = ({
endDate = parseDate('2010-07-19')
}: { endDate?: Date } = {}) => {
return benchmarksService.getMarketDataForUser({
endDate,
dataSource: DataSource.COINGECKO,
dateRange: 'max',
startDate: parseDate('2009-01-02'),
symbol: 'bitcoin',
userId: 'user-id',
userSettings: { baseCurrency: 'CHF' }
});
};
it('calculates the change since the baseline date', async () => {
marketDataItems.mockResolvedValue([
{ date: new Date('2009-01-01'), marketPrice: 50 },
{ date: new Date('2009-06-01'), marketPrice: 25 },
{ date: new Date('2010-07-18'), marketPrice: 75 },
{ date: new Date('2010-07-19'), marketPrice: 100 }
]);
await expect(getMarketDataForUser()).resolves.toEqual({
marketData: [
{ date: '2009-01-01', value: 0 },
{ date: '2009-06-01', value: -50 },
{ date: '2010-07-18', value: 50 },
{ date: '2010-07-19', value: 100 }
]
});
expect(getRange).not.toHaveBeenCalled();
});
it('returns no values before the market data of the benchmark starts', async () => {
marketDataItems.mockResolvedValue([
{ date: new Date('2010-07-18'), marketPrice: 50 },
{ date: new Date('2010-07-19'), marketPrice: 100 }
]);
await expect(getMarketDataForUser()).resolves.toEqual({
marketData: [
{ date: '2010-07-18', value: 0 },
{ date: '2010-07-19', value: 100 }
]
});
expect(getRange).toHaveBeenCalledWith({
assetProfileIdentifiers: [
{ dataSource: DataSource.COINGECKO, symbol: 'bitcoin' }
],
dateQuery: { lt: new Date('2009-01-01') },
take: 1
});
expect(marketDataItems).toHaveBeenCalledWith(
expect.objectContaining({ orderBy: { date: 'asc' } })
);
});
it('uses the exchange rate at the start of the market data', async () => {
getExchangeRatesByCurrency.mockResolvedValue({
USDCHF: {
'2009-01-01': 2,
'2009-06-01': 2,
'2010-07-18': 1,
'2010-07-19': 1.5
}
});
marketDataItems.mockResolvedValue([
{ date: new Date('2010-07-18'), marketPrice: 50 },
{ date: new Date('2010-07-19'), marketPrice: 100 }
]);
await expect(getMarketDataForUser()).resolves.toEqual({
marketData: [
{ date: '2010-07-18', value: 0 },
{ date: '2010-07-19', value: 200 }
]
});
});
it('calculates the value at the end date since the start of the market data', async () => {
getExchangeRatesByCurrency.mockResolvedValue({
USDCHF: {
'2009-01-01': 2,
'2010-07-18': 1,
'2010-07-19': 1,
'2010-07-20': 1.5
}
});
marketDataItems.mockResolvedValue([
{ date: new Date('2010-07-18'), marketPrice: 50 },
{ date: new Date('2010-07-19'), marketPrice: 100 }
]);
await expect(
getMarketDataForUser({ endDate: parseDate('2010-07-20') })
).resolves.toEqual({
marketData: [
{ date: '2010-07-18', value: 0 },
{ date: '2010-07-19', value: 100 },
{ date: '2010-07-20', value: 500 }
]
});
});
it('returns no market data if the market data at the baseline date is missing', async () => {
getRange.mockResolvedValue([
{ date: new Date('2008-12-31'), marketPrice: 40 }
]);
marketDataItems.mockResolvedValue([
{ date: new Date('2010-07-18'), marketPrice: 50 },
{ date: new Date('2010-07-19'), marketPrice: 100 }
]);
await expect(getMarketDataForUser()).resolves.toEqual({
marketData: []
});
expect(getExchangeRatesByCurrency).not.toHaveBeenCalled();
});
it('returns no market data if the benchmark has none', async () => {
marketDataItems.mockResolvedValue([]);
await expect(getMarketDataForUser()).resolves.toEqual({
marketData: []
});
});
});
});

63
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -94,25 +94,15 @@ export class BenchmarksService {
const baselineDate = resetHours(parseDate(chart[0].date)); const baselineDate = resetHours(parseDate(chart[0].date));
const exchangeRates = // The market data of the benchmark can start after the baseline date.
await this.exchangeRateDataService.getExchangeRatesByCurrency({ // In this case, the first market data item is the start and there is no
currencies: [currentSymbolItem.currency], // value before it.
startDate: min([baselineDate, startDate]), const [firstMarketDataItem] = marketDataItems;
targetCurrency: userCurrency const marketPriceAtStartDate = firstMarketDataItem?.marketPrice;
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(baselineDate, DATE_FORMAT)
];
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, baselineDate);
})?.marketPrice;
if (!marketPriceAtStartDate) { if (!marketPriceAtStartDate) {
this.logger.error( this.logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format( `No historical market data has been found for ${symbol} (${dataSource}) since ${format(
baselineDate, baselineDate,
DATE_FORMAT DATE_FORMAT
)}` )}`
@ -121,6 +111,47 @@ export class BenchmarksService {
return { marketData }; return { marketData };
} }
if (!isSameDay(firstMarketDataItem.date, baselineDate)) {
// If market data exists before the baseline date, the market data at
// the baseline date is missing and the benchmark does not start later
const [marketDataItemBeforeBaselineDate] =
await this.marketDataService.getRange({
assetProfileIdentifiers: [{ dataSource, symbol }],
dateQuery: { lt: baselineDate },
take: 1
});
if (marketDataItemBeforeBaselineDate) {
this.logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
baselineDate,
DATE_FORMAT
)}`
);
return { marketData };
}
this.logger.warn(
`The market data of ${symbol} (${dataSource}) starts at ${format(
firstMarketDataItem.date,
DATE_FORMAT
)}, after the baseline date ${format(baselineDate, DATE_FORMAT)}`
);
}
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: [currentSymbolItem.currency],
startDate: min([baselineDate, startDate]),
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(firstMarketDataItem.date, DATE_FORMAT)
];
for (const marketDataItem of marketDataItems) { for (const marketDataItem of marketDataItems) {
const exchangeRate = const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[

162
apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts

@ -2,12 +2,17 @@ import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.de
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { AccessGuard } from '@ghostfolio/api/guards/access.guard'; import { AccessGuard } from '@ghostfolio/api/guards/access.guard';
import { getMcpUserOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper'; import { getMcpUserOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
getScopesOfAccess, getScopesOfAccess,
getScopesOfAccessLevel, getScopesOfAccessLevel,
Scope, Scope,
scopes scopes
} from '@ghostfolio/common/scopes'; } from '@ghostfolio/common/scopes';
import type {
AccessLevel,
ImpersonationContext
} from '@ghostfolio/common/types';
import { import {
EXCEPTION_FILTERS_METADATA, EXCEPTION_FILTERS_METADATA,
@ -23,6 +28,7 @@ import {
} from '@rekog/mcp-nest'; } from '@rekog/mcp-nest';
import { GhostfolioMcpController } from './mcp.controller'; import { GhostfolioMcpController } from './mcp.controller';
import { McpService } from './mcp.service';
/** /**
* Gives the metadata which a decorator sets on the method of a tool. The * Gives the metadata which a decorator sets on the method of a tool. The
@ -83,6 +89,86 @@ function getNamesOfListedTools(request: unknown) {
}); });
} }
/**
* Gives for each tool which reads the portfolio whether the controller asks
* the service for the monetary values
*/
async function getWithValuesOfReadTools(accessLevel: AccessLevel) {
const mcpService = {
getAccounts: jest.fn(),
getActivities: jest.fn(),
getPerformance: jest.fn(),
getPortfolio: jest.fn()
};
const controller = new GhostfolioMcpController(
mcpService as unknown as McpService
);
const impersonationContext = {
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel(accessLevel),
type: 'MCP'
}),
userId: 'user-id',
userSettings: { baseCurrency: 'CHF' }
} as ImpersonationContext;
await controller.getAccounts(impersonationContext, {});
await controller.getActivities(
impersonationContext,
{} as Parameters<GhostfolioMcpController['getActivities']>[1]
);
await controller.getPerformance(
impersonationContext,
{} as Parameters<GhostfolioMcpController['getPerformance']>[1]
);
await controller.getPortfolio(impersonationContext);
return Object.values(mcpService).map((method) => {
return (method.mock.calls[0][0] as { withValues: boolean }).withValues;
});
}
/**
* Gives whether the controller asks the service for the asset performance in
* base currency of an access with the permission "View"
*/
async function getWithAssetPerformanceInBaseCurrency(
subscriptionType?: SubscriptionType
) {
const mcpService = { getPerformance: jest.fn() };
const controller = new GhostfolioMcpController(
mcpService as unknown as McpService
);
await controller.getPerformance(
{
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel('READ'),
type: 'MCP'
}),
userId: 'user-id',
userSettings: { baseCurrency: 'CHF' },
userSubscription: subscriptionType
? ({
type: subscriptionType
} as ImpersonationContext['userSubscription'])
: undefined
},
{} as Parameters<GhostfolioMcpController['getPerformance']>[1]
);
return (
mcpService.getPerformance.mock.calls[0][0] as {
withAssetPerformanceInBaseCurrency: boolean;
}
).withAssetPerformanceInBaseCurrency;
}
describe('GhostfolioMcpController', () => { describe('GhostfolioMcpController', () => {
// A tool without the decorator of the scope would be open to every access, // A tool without the decorator of the scope would be open to every access,
// hence a new tool has to declare its scope // hence a new tool has to declare its scope
@ -163,6 +249,82 @@ describe('GhostfolioMcpController', () => {
]); ]);
}); });
it('Lists only the tools to read for an access with the permission "View"', () => {
expect(
getNamesOfListedTools({
impersonationOfBearerToken: {
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel('READ'),
type: 'MCP'
})
}
})
).toEqual([
'get-accounts',
'get-activities',
'get-performance',
'get-portfolio',
'get-watchlist'
]);
});
it('Lists every tool for an access with the permission "View and manage"', () => {
expect(
getNamesOfListedTools({
impersonationOfBearerToken: {
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel('CREATE_READ_UPDATE_DELETE'),
type: 'MCP'
})
}
})
).toEqual([
'get-accounts',
'get-activities',
'get-performance',
'get-portfolio',
'get-watchlist',
'import-activities',
'search-asset-profiles'
]);
});
it('Gives the monetary values for an access with the permission "View"', async () => {
expect(await getWithValuesOfReadTools('READ')).toEqual([
true,
true,
true,
true
]);
});
it('Gives no monetary values for an access with the permission "Restricted view"', async () => {
expect(await getWithValuesOfReadTools('READ_RESTRICTED')).toEqual([
false,
false,
false,
false
]);
});
it('Gives the asset performance in base currency for a Premium subscription', async () => {
expect(
await getWithAssetPerformanceInBaseCurrency(SubscriptionType.Premium)
).toBe(true);
});
it('Gives the asset performance in base currency if the subscription is disabled', async () => {
expect(await getWithAssetPerformanceInBaseCurrency()).toBe(true);
});
it('Gives no asset performance in base currency for a Basic subscription', async () => {
expect(
await getWithAssetPerformanceInBaseCurrency(SubscriptionType.Basic)
).toBe(false);
});
it('Lists no tool for an inactive access', () => { it('Lists no tool for an inactive access', () => {
expect( expect(
getNamesOfListedTools({ getNamesOfListedTools({

61
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -3,7 +3,8 @@ import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { scopes } from '@ghostfolio/common/scopes'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import type { ImpersonationContext } from '@ghostfolio/common/types'; import type { ImpersonationContext } from '@ghostfolio/common/types';
import { UseFilters } from '@nestjs/common'; import { UseFilters } from '@nestjs/common';
@ -35,15 +36,22 @@ export class GhostfolioMcpController {
}, },
description: `Gives the accounts of the portfolio with these columns: ${PortfolioTableService.getAccountsTableColumnNames().join( description: `Gives the accounts of the portfolio with these columns: ${PortfolioTableService.getAccountsTableColumnNames().join(
', ' ', '
)}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it.`, )}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getAccountsTableValueColumnNames().join(
', '
)}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it. The parameters change the value in base currency as well. With the holding parameter, it is the value of the holding in the account without the cash balance. Without the holding parameter, it includes the full cash balance of the account, also with the assetClasses parameter. The balance is always the full cash balance of the account in the currency of the account.`,
name: 'get-accounts', name: 'get-accounts',
parameters: GET_ACCOUNTS_PARAMETERS parameters: GET_ACCOUNTS_PARAMETERS
}) })
public async getAccounts( public async getAccounts(
@Impersonation() { userId }: ImpersonationContext, @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext,
@Payload() parameters: z.infer<typeof GET_ACCOUNTS_PARAMETERS> @Payload() parameters: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
) { ) {
return this.mcpService.getAccounts({ ...parameters, userId }); return this.mcpService.getAccounts({
...parameters,
userId,
withValues: this.hasScopeToReadValues(impersonationScopes)
});
} }
@RequiresScopeOfAccess(scopes.activityRead) @RequiresScopeOfAccess(scopes.activityRead)
@ -55,18 +63,22 @@ export class GhostfolioMcpController {
}, },
description: `Gives the activities of the portfolio, the most recent first, with these columns: ${PortfolioTableService.getActivitiesTableColumnNames().join( description: `Gives the activities of the portfolio, the most recent first, with these columns: ${PortfolioTableService.getActivitiesTableColumnNames().join(
', ' ', '
)}. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`, )}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getActivitiesTableValueColumnNames().join(
', '
)}. The unit price and the fee are in the currency of the activity. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`,
name: 'get-activities', name: 'get-activities',
parameters: GET_ACTIVITIES_PARAMETERS parameters: GET_ACTIVITIES_PARAMETERS
}) })
public async getActivities( public async getActivities(
@Impersonation() { userId, userSettings }: ImpersonationContext, @Impersonation()
{ scopes: impersonationScopes, userId, userSettings }: ImpersonationContext,
@Payload() parameters: z.infer<typeof GET_ACTIVITIES_PARAMETERS> @Payload() parameters: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
) { ) {
return this.mcpService.getActivities({ return this.mcpService.getActivities({
...parameters, ...parameters,
userId, userId,
userCurrency: userSettings.baseCurrency userCurrency: userSettings.baseCurrency,
withValues: this.hasScopeToReadValues(impersonationScopes)
}); });
} }
@ -79,15 +91,28 @@ export class GhostfolioMcpController {
}, },
description: `Gives the performance of the portfolio in the date range with these columns: ${PortfolioTableService.getPerformanceTableColumnNames().join( description: `Gives the performance of the portfolio in the date range with these columns: ${PortfolioTableService.getPerformanceTableColumnNames().join(
', ' ', '
)}. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`, )}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getPerformanceTableValueColumnNames().join(
', '
)}. The value in base currency is the value at the end of the date range. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`,
name: 'get-performance', name: 'get-performance',
parameters: GET_PERFORMANCE_PARAMETERS parameters: GET_PERFORMANCE_PARAMETERS
}) })
public async getPerformance( public async getPerformance(
@Impersonation() { userId }: ImpersonationContext, @Impersonation()
{
scopes: impersonationScopes,
userId,
userSubscription
}: ImpersonationContext,
@Payload() parameters: z.infer<typeof GET_PERFORMANCE_PARAMETERS> @Payload() parameters: z.infer<typeof GET_PERFORMANCE_PARAMETERS>
) { ) {
return this.mcpService.getPerformance({ ...parameters, userId }); return this.mcpService.getPerformance({
...parameters,
userId,
withAssetPerformanceInBaseCurrency:
userSubscription?.type !== SubscriptionType.Basic,
withValues: this.hasScopeToReadValues(impersonationScopes)
});
} }
@RequiresScopeOfAccess(scopes.portfolioRead) @RequiresScopeOfAccess(scopes.portfolioRead)
@ -99,11 +124,19 @@ export class GhostfolioMcpController {
}, },
description: `Gives the holdings of the portfolio with these columns: ${PortfolioTableService.getHoldingsTableColumnNames().join( description: `Gives the holdings of the portfolio with these columns: ${PortfolioTableService.getHoldingsTableColumnNames().join(
', ' ', '
)}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getHoldingsTableValueColumnNames().join(
', '
)}.`, )}.`,
name: 'get-portfolio' name: 'get-portfolio'
}) })
public async getPortfolio(@Impersonation() { userId }: ImpersonationContext) { public async getPortfolio(
return this.mcpService.getPortfolio({ userId }); @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext
) {
return this.mcpService.getPortfolio({
userId,
withValues: this.hasScopeToReadValues(impersonationScopes)
});
} }
@RequiresScopeOfAccess(scopes.watchlistRead) @RequiresScopeOfAccess(scopes.watchlistRead)
@ -159,4 +192,8 @@ export class GhostfolioMcpController {
) { ) {
return this.mcpService.searchAssetProfiles({ ...parameters, userId }); return this.mcpService.searchAssetProfiles({ ...parameters, userId });
} }
private hasScopeToReadValues(impersonationScopes: string[]) {
return hasScope(impersonationScopes, scopes.portfolioReadValues);
}
} }

16
apps/api/src/app/endpoints/mcp/mcp.module.ts

@ -7,6 +7,7 @@ import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module'; import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -37,9 +38,20 @@ import { McpService } from './mcp.service';
useFactory: (configurationService: ConfigurationService) => { useFactory: (configurationService: ConfigurationService) => {
const { hostname } = new URL(configurationService.get('ROOT_URL')); const { hostname } = new URL(configurationService.get('ROOT_URL'));
const instructions = [
'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access and import activities into the portfolio. They give the quantities and the monetary values only if the access has the permission "View" or "View and manage", otherwise no quantity and no monetary value (except the unit price of an activity). Only the tools which the permission of the access covers are listed. To import activities, the access needs the permission "Restricted view and manage" or "View and manage".'
];
if (configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
instructions.push(
`With the Basic subscription, the tool get-performance does not give these columns: ${PortfolioTableService.getAssetPerformanceInBaseCurrencyColumnNames().join(
', '
)}.`
);
}
return new McpStrategy({ return new McpStrategy({
instructions: instructions: instructions.join(' '),
'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access and import activities into the portfolio. They give no quantity and no monetary value (except the unit price of an activity). Only the tools which the permission of the access covers are listed. To import activities, the access needs the permission "Restricted view and manage".',
name: 'ghostfolio', name: 'ghostfolio',
resolveUser: getMcpUserOfBearerToken, resolveUser: getMcpUserOfBearerToken,
title: 'Ghostfolio', title: 'Ghostfolio',

48
apps/api/src/app/endpoints/mcp/mcp.service.spec.ts

@ -169,6 +169,14 @@ describe('McpService', () => {
userId userId
}); });
}); });
it('Gives the values if the access reads them', async () => {
await mcpService.getAccounts({ userId, withValues: true });
expect(portfolioTableService.getAccountsTable).toHaveBeenCalledWith(
expect.objectContaining({ withValues: true })
);
});
}); });
describe('getActivities', () => { describe('getActivities', () => {
@ -272,6 +280,14 @@ describe('McpService', () => {
types: [ActivityType.BUY] types: [ActivityType.BUY]
}); });
}); });
it('Gives the values if the access reads them', async () => {
await getActivities({ withValues: true });
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith(
expect.objectContaining({ withValues: true })
);
});
}); });
describe('getPerformance', () => { describe('getPerformance', () => {
@ -350,6 +366,30 @@ describe('McpService', () => {
dateRange: '2024' dateRange: '2024'
}); });
}); });
it('Gives the values if the access reads them', async () => {
await mcpService.getPerformance({
userId,
range: '2024',
withValues: true
});
expect(portfolioTableService.getPerformanceTable).toHaveBeenCalledWith(
expect.objectContaining({ withValues: true })
);
});
it('Gives the asset performance in base currency if the subscription permits it', async () => {
await mcpService.getPerformance({
userId,
range: '2024',
withAssetPerformanceInBaseCurrency: true
});
expect(portfolioTableService.getPerformanceTable).toHaveBeenCalledWith(
expect.objectContaining({ withAssetPerformanceInBaseCurrency: true })
);
});
}); });
describe('getPortfolio', () => { describe('getPortfolio', () => {
@ -364,6 +404,14 @@ describe('McpService', () => {
withDataSource: true withDataSource: true
}); });
}); });
it('Gives the values if the access reads them', async () => {
await mcpService.getPortfolio({ userId, withValues: true });
expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith(
expect.objectContaining({ withValues: true })
);
});
}); });
describe('getWatchlist', () => { describe('getWatchlist', () => {

37
apps/api/src/app/endpoints/mcp/mcp.service.ts

@ -39,8 +39,12 @@ export class McpService {
accountIds, accountIds,
assetClasses, assetClasses,
holding, holding,
userId userId,
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS> & { userId: string }) { withValues
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS> & {
userId: string;
withValues?: boolean;
}) {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
...this.getHoldingFilterParameters({ holding }), ...this.getHoldingFilterParameters({ holding }),
filterByAccounts: accountIds, filterByAccounts: accountIds,
@ -49,7 +53,8 @@ export class McpService {
const table = await this.portfolioTableService.getAccountsTable({ const table = await this.portfolioTableService.getAccountsTable({
filters, filters,
userId userId,
withValues
}); });
return this.getTextResult(table); return this.getTextResult(table);
@ -63,10 +68,12 @@ export class McpService {
skip, skip,
take, take,
userCurrency, userCurrency,
userId userId,
withValues
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS> & { }: z.infer<typeof GET_ACTIVITIES_PARAMETERS> & {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withValues?: boolean;
}) { }) {
let endDate: Date | undefined; let endDate: Date | undefined;
let startDate: Date | undefined; let startDate: Date | undefined;
@ -90,6 +97,7 @@ export class McpService {
take, take,
userCurrency, userCurrency,
userId, userId,
withValues,
types: activityTypes types: activityTypes
}); });
@ -101,8 +109,14 @@ export class McpService {
assetClasses, assetClasses,
holding, holding,
range, range,
userId userId,
}: z.infer<typeof GET_PERFORMANCE_PARAMETERS> & { userId: string }) { withAssetPerformanceInBaseCurrency,
withValues
}: z.infer<typeof GET_PERFORMANCE_PARAMETERS> & {
userId: string;
withAssetPerformanceInBaseCurrency?: boolean;
withValues?: boolean;
}) {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
...this.getHoldingFilterParameters({ holding }), ...this.getHoldingFilterParameters({ holding }),
filterByAccounts: accountIds, filterByAccounts: accountIds,
@ -112,15 +126,24 @@ export class McpService {
const table = await this.portfolioTableService.getPerformanceTable({ const table = await this.portfolioTableService.getPerformanceTable({
filters, filters,
userId, userId,
withAssetPerformanceInBaseCurrency,
withValues,
dateRange: range dateRange: range
}); });
return this.getTextResult(table); return this.getTextResult(table);
} }
public async getPortfolio({ userId }: { userId: string }) { public async getPortfolio({
userId,
withValues
}: {
userId: string;
withValues?: boolean;
}) {
const table = await this.portfolioTableService.getHoldingsTable({ const table = await this.portfolioTableService.getHoldingsTable({
userId, userId,
withValues,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true withDataSource: true
}); });

164
apps/api/src/app/import/import.service.spec.ts

@ -3,10 +3,15 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos'; import {
CreateAssetProfileWithMarketDataDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
@ -15,6 +20,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { isUUID } from 'class-validator';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
@ -22,9 +28,11 @@ import { ImportService } from './import.service';
const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
describe('ImportService', () => { describe('ImportService', () => {
let addSymbolProfile: jest.Mock;
let createActivity: jest.Mock; let createActivity: jest.Mock;
let gatherSymbols: jest.Mock; let gatherSymbols: jest.Mock;
let importService: ImportService; let importService: ImportService;
let updateManyMarketData: jest.Mock;
beforeEach(() => { beforeEach(() => {
const configuration = { const configuration = {
@ -33,8 +41,10 @@ describe('ImportService', () => {
MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER
}; };
addSymbolProfile = jest.fn();
createActivity = jest.fn(); createActivity = jest.fn();
gatherSymbols = jest.fn(); gatherSymbols = jest.fn();
updateManyMarketData = jest.fn();
const validateActivities = jest.fn( const validateActivities = jest.fn(
({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => { ({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => {
@ -77,10 +87,14 @@ describe('ImportService', () => {
{ {
toCurrencyAtDate: jest.fn().mockResolvedValue(0) toCurrencyAtDate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService, } as unknown as ExchangeRateDataService,
{ updateMany: updateManyMarketData } as unknown as MarketDataService,
null, null,
null, null,
null, {
null, add: addSymbolProfile,
getCustomSymbolProfilesByNames: jest.fn().mockResolvedValue([]),
getSymbolProfiles: jest.fn().mockResolvedValue([])
} as unknown as SymbolProfileService,
{ {
getTagsForUser: jest.fn().mockResolvedValue([]) getTagsForUser: jest.fn().mockResolvedValue([])
} as unknown as TagService } as unknown as TagService
@ -275,6 +289,146 @@ describe('ImportService', () => {
]); ]);
}); });
it('creates a custom asset profile with a UUID instead of a symbol with the prefix', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
}
]
});
const { symbol } = addSymbolProfile.mock.calls[0][0];
expect(isUUID(symbol)).toBe(true);
expect(
createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ symbol, dataSource: DataSource.MANUAL });
});
it('keeps the symbol with the prefix as the name of a custom asset profile without a name', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [],
symbol: 'GF_COPX'
}
]
});
expect(addSymbolProfile.mock.calls[0][0].name).toBe('GF_COPX');
});
it('merges the market data of asset profiles with the same symbol with the prefix', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [{ date: '2024-01-01', marketPrice: 100 }],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
},
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [{ date: '2024-01-02', marketPrice: 101 }],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
}
]
});
const { symbol } = addSymbolProfile.mock.calls[0][0];
expect(addSymbolProfile).toHaveBeenCalledTimes(1);
expect(updateManyMarketData.mock.calls[0][0].data).toMatchObject([
{ symbol, date: '2024-01-01', marketPrice: 100 },
{ symbol, date: '2024-01-02', marketPrice: 101 }
]);
expect(
createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ symbol, dataSource: DataSource.MANUAL });
});
it('refers the next activities with a symbol with the prefix to the custom asset profile which createActivity() has created', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
}),
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-02T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
]
});
expect(
createActivity.mock.calls.map(([activity]) => {
return activity.SymbolProfile.connectOrCreate.create;
})
).toMatchObject([
{ dataSource: DataSource.MANUAL, symbol: 'GF_COPX' },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
});
it('keeps the asset profiles of the activities in a dry run', async () => { it('keeps the asset profiles of the activities in a dry run', async () => {
const activities = await importActivities({ const activities = await importActivities({
activitiesDto: [ activitiesDto: [
@ -306,16 +460,18 @@ describe('ImportService', () => {
function importActivities({ function importActivities({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto = [],
isDryRun isDryRun
}: { }: {
activitiesDto: CreateOrderDto[]; activitiesDto: CreateOrderDto[];
assetProfilesWithMarketDataDto?: CreateAssetProfileWithMarketDataDto[];
isDryRun?: boolean; isDryRun?: boolean;
}) { }) {
return importService.import({ return importService.import({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto,
isDryRun, isDryRun,
accountsWithBalancesDto: [], accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [], platformsDto: [],
tagsDto: [], tagsDto: [],
user: { user: {

21
apps/api/src/app/import/import.service.ts

@ -27,6 +27,7 @@ import {
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
hasGhostfolioPrefix,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -649,9 +650,23 @@ export class ImportService {
'marketData' 'marketData'
); );
// Asset profile belongs to a different user, generate a new symbol if (
if (existingAssetProfile && !isDryRun) { (existingAssetProfile || hasGhostfolioPrefix(symbol)) &&
symbol = randomUUID(); !isDryRun
) {
const isSymbolOfAssetProfileToCreate = assetProfilesToCreate.some(
({ assetProfile: { symbol: symbolToCreate } }) => {
return symbolToCreate === assetProfileSymbolMapping[symbol];
}
);
if (hasGhostfolioPrefix(symbol)) {
assetProfile.name ??= symbol;
}
symbol = isSymbolOfAssetProfileToCreate
? assetProfileSymbolMapping[symbol]
: randomUUID();
} }
assetProfile.symbol = symbol; assetProfile.symbol = symbol;

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

@ -4,7 +4,8 @@ import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorato
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject,
nullifyValuesInObjects
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { convertValuesToPercentages } from '@ghostfolio/api/helper/portfolio.helper'; import { convertValuesToPercentages } from '@ghostfolio/api/helper/portfolio.helper';
import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
@ -583,14 +584,22 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription?.type === SubscriptionType.Basic this.request.user.subscription?.type === SubscriptionType.Basic
) { ) {
performanceInformation.chart = performanceInformation.chart.map( performanceInformation.chart = nullifyValuesInObjects(
(item) => { performanceInformation.chart,
return nullifyValuesInObject(item, [ [
'dividendInBaseCurrency', 'dividendInBaseCurrency',
'totalInvestment', 'totalInvestment',
'value' 'value',
]); ...(groupBy === 'year'
} ? [
'investmentValueWithCurrencyEffect',
'netPerformance',
'netPerformanceInPercentage',
'netPerformanceInPercentageWithCurrencyEffect',
'netPerformanceWithCurrencyEffect'
]
: [])
]
); );
performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance, performanceInformation.performance,

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

@ -1052,7 +1052,6 @@ export class PortfolioService {
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
[{ dataSource, symbol }], [{ dataSource, symbol }],
'day',
parseISO(dateOfFirstActivity, { in: utc }), parseISO(dateOfFirstActivity, { in: utc }),
new Date() new Date()
); );

26
apps/api/src/services/data-provider/data-provider.service.ts

@ -371,7 +371,6 @@ export class DataProviderService implements OnModuleInit {
public async getHistorical( public async getHistorical(
aItems: AssetProfileIdentifier[], aItems: AssetProfileIdentifier[],
aGranularity: Granularity = 'month',
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
@ -389,11 +388,6 @@ export class DataProviderService implements OnModuleInit {
return response; return response;
} }
const granularityQuery =
aGranularity === 'month'
? Prisma.sql`AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
: Prisma.empty;
const rangeQuery = const rangeQuery =
from && to from && to
? Prisma.sql`AND date >= ${format(from, DATE_FORMAT, { ? Prisma.sql`AND date >= ${format(from, DATE_FORMAT, {
@ -403,22 +397,18 @@ export class DataProviderService implements OnModuleInit {
})}::timestamp` })}::timestamp`
: Prisma.empty; : Prisma.empty;
const dataSources = aItems.map(({ dataSource }) => { const assetProfileIdentifiers = aItems.map(({ dataSource, symbol }) => {
return dataSource; return Prisma.sql`(${dataSource}::"DataSource", ${symbol})`;
});
const symbols = aItems.map(({ symbol }) => {
return symbol;
}); });
try { try {
const marketDataByGranularity: MarketData[] = await this.prismaService const marketDataByGranularity: Pick<
.$queryRaw` MarketData,
SELECT * 'dataSource' | 'date' | 'marketPrice' | 'symbol'
>[] = await this.prismaService.$queryRaw`
SELECT "dataSource", "date", "marketPrice", "symbol"
FROM "MarketData" FROM "MarketData"
WHERE "dataSource"::text IN (${Prisma.join(dataSources)}) WHERE ("dataSource", "symbol") IN (${Prisma.join(assetProfileIdentifiers)})
AND "symbol" IN (${Prisma.join(symbols)})
${granularityQuery}
${rangeQuery} ${rangeQuery}
ORDER BY date;`; ORDER BY date;`;

1
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -175,7 +175,6 @@ export class ExchangeRateDataService {
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
this.currencyPairs, this.currencyPairs,
'day',
startOfUtcDateOfYesterday, startOfUtcDateOfYesterday,
startOfUtcDateOfYesterday startOfUtcDateOfYesterday
); );

209
apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts

@ -84,7 +84,8 @@ function createAccount({
currency: 'CHF', currency: 'CHF',
platform: { name: 'Platform A' }, platform: { name: 'Platform A' },
tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [], tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [],
value: 2000 value: 2000,
valueInBaseCurrency: 1800
} as unknown as AccountWithValue; } as unknown as AccountWithValue;
} }
@ -105,8 +106,11 @@ function createActivity({
}, },
currency: 'CHF', currency: 'CHF',
date: new Date('2024-01-01'), date: new Date('2024-01-01'),
fee: 1.5,
quantity: 5,
type: 'BUY', type: 'BUY',
unitPrice: 100 unitPrice: 100,
valueInBaseCurrency: 512.3456
} as unknown as Activity; } as unknown as Activity;
} }
@ -143,20 +147,24 @@ function createHolding({
} }
function createPerformance({ function createPerformance({
netPerformance = 200,
netPerformancePercentage = 0.1, netPerformancePercentage = 0.1,
netPerformancePercentageWithCurrencyEffect = 0.15 netPerformancePercentageWithCurrencyEffect = 0.15,
netPerformanceWithCurrencyEffect = 300
}: { }: {
netPerformance?: number;
netPerformancePercentage?: number; netPerformancePercentage?: number;
netPerformancePercentageWithCurrencyEffect?: number; netPerformancePercentageWithCurrencyEffect?: number;
netPerformanceWithCurrencyEffect?: number;
} = {}): PortfolioPerformanceResponse['performance'] { } = {}): PortfolioPerformanceResponse['performance'] {
return { return {
netPerformance,
netPerformancePercentage, netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect, netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
currentNetWorth: 3000, currentNetWorth: 3000,
currentValueInBaseCurrency: 2000, currentValueInBaseCurrency: 2000,
dividendInBaseCurrency: 50, dividendInBaseCurrency: 50,
netPerformance: 200,
netPerformanceWithCurrencyEffect: 300,
totalInvestment: 1700, totalInvestment: 1700,
totalInvestmentValueWithCurrencyEffect: 1700 totalInvestmentValueWithCurrencyEffect: 1700
}; };
@ -246,9 +254,8 @@ function createPortfolioTableService({
} }
describe('PortfolioTableService', () => { describe('PortfolioTableService', () => {
// The tools of the model context protocol are the only callers, and an // A column with a monetary value is given only with the values, hence the
// access of that type never grants the scope to read the monetary values, // names of these columns are given separately
// hence no table has a column with such a value
describe('getAccountsTableColumnNames', () => { describe('getAccountsTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getAccountsTableColumnNames()).toEqual([ expect(PortfolioTableService.getAccountsTableColumnNames()).toEqual([
@ -263,6 +270,15 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getAccountsTableValueColumnNames', () => {
it('gives the columns with a monetary value', () => {
expect(PortfolioTableService.getAccountsTableValueColumnNames()).toEqual([
'Balance',
'Value in Base Currency'
]);
});
});
describe('getActivitiesTableColumnNames', () => { describe('getActivitiesTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getActivitiesTableColumnNames()).toEqual([ expect(PortfolioTableService.getActivitiesTableColumnNames()).toEqual([
@ -278,6 +294,25 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getActivitiesTableValueColumnNames', () => {
it('gives the columns with a quantity or with a monetary value', () => {
expect(
PortfolioTableService.getActivitiesTableValueColumnNames()
).toEqual(['Quantity', 'Fee', 'Value in Base Currency']);
});
});
describe('getAssetPerformanceInBaseCurrencyColumnNames', () => {
it('gives the value columns of the performance which give the asset performance', () => {
expect(
PortfolioTableService.getAssetPerformanceInBaseCurrencyColumnNames()
).toEqual([
'Asset Performance in Base Currency',
'Currency Performance in Base Currency'
]);
});
});
describe('getHoldingsTableColumnNames', () => { describe('getHoldingsTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([
@ -294,6 +329,15 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getHoldingsTableValueColumnNames', () => {
it('gives the columns with a quantity or with a monetary value', () => {
expect(PortfolioTableService.getHoldingsTableValueColumnNames()).toEqual([
'Quantity',
'Value in Base Currency'
]);
});
});
describe('getPerformanceTableColumnNames', () => { describe('getPerformanceTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getPerformanceTableColumnNames()).toEqual([ expect(PortfolioTableService.getPerformanceTableColumnNames()).toEqual([
@ -304,6 +348,19 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getPerformanceTableValueColumnNames', () => {
it('gives the columns with a monetary value', () => {
expect(
PortfolioTableService.getPerformanceTableValueColumnNames()
).toEqual([
'Value in Base Currency',
'Asset Performance in Base Currency',
'Currency Performance in Base Currency',
'Net Performance in Base Currency'
]);
});
});
describe('getWatchlistTableColumnNames', () => { describe('getWatchlistTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([
@ -331,9 +388,27 @@ describe('PortfolioTableService', () => {
expect(result).not.toContain('Cash Balance'); expect(result).not.toContain('Cash Balance');
expect(result).not.toContain('1000'); expect(result).not.toContain('1000');
expect(result).not.toContain('1800');
expect(result).not.toContain('2000'); expect(result).not.toContain('2000');
}); });
it('gives the balance and the value with the values', async () => {
const portfolioTableService = createPortfolioTableService({
accounts: [createAccount()]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id',
withValues: true
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| account-a-id');
});
expect(row).toMatch(/\| false \| 1000 \| 1800\.00 \|$/);
});
// The accountIds parameter of the tool takes the identifiers, hence the // The accountIds parameter of the tool takes the identifiers, hence the
// table has to give them // table has to give them
it('gives the identifier of an account', async () => { it('gives the identifier of an account', async () => {
@ -405,6 +480,40 @@ describe('PortfolioTableService', () => {
expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`);
expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`);
}); });
it('gives no quantity, no fee and no value by default', async () => {
const result = await createPortfolioTableService({
activities: [createActivity()]
}).getActivitiesTable({
take: 50,
userCurrency: 'CHF',
userId: 'user-id'
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| 2024-01-01');
});
expect(result).not.toContain('Quantity');
expect(row).toMatch(/\| 100 \| Account A \|$/);
});
it('gives the quantity, the fee and the value with the values', async () => {
const result = await createPortfolioTableService({
activities: [createActivity()]
}).getActivitiesTable({
take: 50,
userCurrency: 'CHF',
userId: 'user-id',
withValues: true
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| 2024-01-01');
});
expect(row).toMatch(/\| Account A \| 5 \| 1\.5 \| 512\.35 \|$/);
});
}); });
describe('getHoldingsTable', () => { describe('getHoldingsTable', () => {
@ -412,10 +521,12 @@ describe('PortfolioTableService', () => {
holdings: PortfolioPosition[], holdings: PortfolioPosition[],
{ {
configuration, configuration,
withDataSource withDataSource,
withValues
}: { }: {
configuration?: Record<string, unknown>; configuration?: Record<string, unknown>;
withDataSource?: boolean; withDataSource?: boolean;
withValues?: boolean;
} = {} } = {}
) { ) {
return createPortfolioTableService({ return createPortfolioTableService({
@ -423,6 +534,7 @@ describe('PortfolioTableService', () => {
holdings holdings
}).getHoldingsTable({ }).getHoldingsTable({
withDataSource, withDataSource,
withValues,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id' userId: 'user-id'
}); });
@ -516,13 +628,45 @@ describe('PortfolioTableService', () => {
expect(firstRow).toContain('AAPL'); expect(firstRow).toContain('AAPL');
expect(secondRow).toContain('MSFT'); expect(secondRow).toContain('MSFT');
}); });
it('gives no quantity and no value by default', async () => {
const result = await getHoldingsTable([createHolding()]);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(result).not.toContain('Quantity');
expect(row).toMatch(/\| 75\.000% \|$/);
});
it('gives the quantity and the value with the values', async () => {
const result = await getHoldingsTable([createHolding()], {
withValues: true
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(row).toMatch(/\| 75\.000% \| 5 \| 2000\.00 \|$/);
});
}); });
describe('getPerformanceTable', () => { describe('getPerformanceTable', () => {
function getPerformanceTable( function getPerformanceTable(
parameters: Parameters<typeof createPortfolioTableService>[0] = {} parameters: Parameters<typeof createPortfolioTableService>[0] = {},
{
withAssetPerformanceInBaseCurrency,
withValues
}: {
withAssetPerformanceInBaseCurrency?: boolean;
withValues?: boolean;
} = {}
) { ) {
return createPortfolioTableService(parameters).getPerformanceTable({ return createPortfolioTableService(parameters).getPerformanceTable({
withAssetPerformanceInBaseCurrency,
withValues,
dateRange: 'ytd', dateRange: 'ytd',
userId: 'user-id' userId: 'user-id'
}); });
@ -565,6 +709,51 @@ describe('PortfolioTableService', () => {
} }
}); });
it('gives the value and the performance in base currency with the values', async () => {
const result = await getPerformanceTable(
{},
{ withAssetPerformanceInBaseCurrency: true, withValues: true }
);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| 10.000%');
});
expect(row).toBe(
'| 10.000% | 5.000% | 15.000% | 2000.00 | 200.00 | 100.00 | 300.00 |'
);
});
it('gives no asset performance and no currency performance in base currency without the asset performance in base currency', async () => {
const result = await getPerformanceTable({}, { withValues: true });
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| 10.000%');
});
expect(result).not.toContain('Asset Performance in Base Currency');
expect(result).not.toContain('Currency Performance in Base Currency');
expect(row).toBe('| 10.000% | 5.000% | 15.000% | 2000.00 | 300.00 |');
});
it('gives a currency performance in base currency of zero without a sign', async () => {
const result = await getPerformanceTable(
{
performance: createPerformance({
netPerformance: 200.000001,
netPerformanceWithCurrencyEffect: 200
})
},
{ withAssetPerformanceInBaseCurrency: true, withValues: true }
);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| 10.000%');
});
expect(row).toMatch(/\| 200\.00 \| 0\.00 \| 200\.00 \|$/);
});
it('tells that no performance is found if the chart is empty', async () => { it('tells that no performance is found if the chart is empty', async () => {
const result = await getPerformanceTable({ chart: [] }); const result = await getPerformanceTable({ chart: [] });

235
apps/api/src/services/portfolio-table/portfolio-table.service.ts

@ -31,6 +31,17 @@ import { format } from 'date-fns';
import { DataSourceTableContext } from './interfaces/data-source-table-context.interface'; import { DataSourceTableContext } from './interfaces/data-source-table-context.interface';
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
const ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME =
'Asset Performance in Base Currency';
const CURRENCY_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME =
'Currency Performance in Base Currency';
const ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAMES = [
ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME,
CURRENCY_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME
];
const DATA_SOURCE_COLUMN_NAME = 'Data Source'; const DATA_SOURCE_COLUMN_NAME = 'Data Source';
function getDataSourceColumnDefinition<T>( function getDataSourceColumnDefinition<T>(
@ -48,15 +59,21 @@ function getDataSourceColumnDefinition<T>(
}; };
} }
function getAmount(value: number) {
const amount = value.toFixed(2);
return Number.parseFloat(amount) === 0 ? (0).toFixed(2) : amount;
}
function getPercentage(value: number) { function getPercentage(value: number) {
return `${(value * 100).toFixed(3)}%`; return `${(value * 100).toFixed(3)}%`;
} }
/** /**
* Renders the accounts, the activities, the holdings and the performance of a * Renders the accounts, the activities, the holdings and the performance of a
* portfolio and the watchlist of its user as a markdown table. No table has a * portfolio and the watchlist of its user as a markdown table. A column with a
* column with a quantity or with a monetary value, except the unit price of an * quantity or with a monetary value, except the unit price of an activity, is
* activity. * given only with the values.
*/ */
@Injectable() @Injectable()
export class PortfolioTableService { export class PortfolioTableService {
@ -108,6 +125,24 @@ export class PortfolioTableService {
} }
]; ];
private static readonly ACCOUNTS_TABLE_VALUE_COLUMN_DEFINITIONS: TableColumnDefinition<AccountWithValue>[] =
[
{
align: 'right',
getValue: ({ balance }) => {
return balance.toString();
},
name: 'Balance'
},
{
align: 'right',
getValue: ({ valueInBaseCurrency }) => {
return getAmount(valueInBaseCurrency);
},
name: 'Value in Base Currency'
}
];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
Activity, Activity,
DataSourceTableContext DataSourceTableContext
@ -160,6 +195,33 @@ export class PortfolioTableService {
} }
]; ];
private static readonly ACTIVITIES_TABLE_VALUE_COLUMN_DEFINITIONS: TableColumnDefinition<
Activity,
DataSourceTableContext
>[] = [
{
align: 'right',
getValue: ({ quantity }) => {
return quantity.toString();
},
name: 'Quantity'
},
{
align: 'right',
getValue: ({ fee }) => {
return fee.toString();
},
name: 'Fee'
},
{
align: 'right',
getValue: ({ valueInBaseCurrency }) => {
return getAmount(valueInBaseCurrency);
},
name: 'Value in Base Currency'
}
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[ [
{ {
@ -221,6 +283,26 @@ export class PortfolioTableService {
} }
]; ];
private static readonly HOLDINGS_TABLE_VALUE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[
{
align: 'right',
getValue: ({ quantity }) => {
return quantity.toString();
},
name: 'Quantity'
},
{
align: 'right',
getValue: ({ valueInBaseCurrency }) => {
return valueInBaseCurrency === undefined
? ''
: getAmount(valueInBaseCurrency);
},
name: 'Value in Base Currency'
}
];
private static readonly PERFORMANCE_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<PortfolioPerformance>[] = private static readonly PERFORMANCE_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<PortfolioPerformance>[] =
[ [
{ {
@ -256,6 +338,38 @@ export class PortfolioTableService {
} }
]; ];
private static readonly PERFORMANCE_TABLE_VALUE_COLUMN_DEFINITIONS: TableColumnDefinition<PortfolioPerformance>[] =
[
{
align: 'right',
getValue: ({ currentValueInBaseCurrency }) => {
return getAmount(currentValueInBaseCurrency);
},
name: 'Value in Base Currency'
},
{
align: 'right',
getValue: ({ netPerformance }) => {
return getAmount(netPerformance);
},
name: ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME
},
{
align: 'right',
getValue: ({ netPerformance, netPerformanceWithCurrencyEffect }) => {
return getAmount(netPerformanceWithCurrencyEffect - netPerformance);
},
name: CURRENCY_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAME
},
{
align: 'right',
getValue: ({ netPerformanceWithCurrencyEffect }) => {
return getAmount(netPerformanceWithCurrencyEffect);
},
name: 'Net Performance in Base Currency'
}
];
private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
WatchlistResponse['watchlist'][number], WatchlistResponse['watchlist'][number],
DataSourceTableContext DataSourceTableContext
@ -326,6 +440,14 @@ export class PortfolioTableService {
); );
} }
public static getAccountsTableValueColumnNames() {
return PortfolioTableService.ACCOUNTS_TABLE_VALUE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getActivitiesTableColumnNames() { public static getActivitiesTableColumnNames() {
return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map( return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => { ({ name }) => {
@ -334,6 +456,18 @@ export class PortfolioTableService {
); );
} }
public static getActivitiesTableValueColumnNames() {
return PortfolioTableService.ACTIVITIES_TABLE_VALUE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getAssetPerformanceInBaseCurrencyColumnNames() {
return [...ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAMES];
}
public static getHoldingsTableColumnNames() { public static getHoldingsTableColumnNames() {
return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map( return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => { ({ name }) => {
@ -342,6 +476,14 @@ export class PortfolioTableService {
); );
} }
public static getHoldingsTableValueColumnNames() {
return PortfolioTableService.HOLDINGS_TABLE_VALUE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getPerformanceTableColumnNames() { public static getPerformanceTableColumnNames() {
return PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS.map( return PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => { ({ name }) => {
@ -350,6 +492,14 @@ export class PortfolioTableService {
); );
} }
public static getPerformanceTableValueColumnNames() {
return PortfolioTableService.PERFORMANCE_TABLE_VALUE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getWatchlistTableColumnNames() { public static getWatchlistTableColumnNames() {
return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map( return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => { ({ name }) => {
@ -360,10 +510,12 @@ export class PortfolioTableService {
public async getAccountsTable({ public async getAccountsTable({
filters, filters,
userId userId,
withValues = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
withValues?: boolean;
}) { }) {
const { accounts } = const { accounts } =
await this.portfolioService.getAccountsWithAggregations({ await this.portfolioService.getAccountsWithAggregations({
@ -377,8 +529,13 @@ export class PortfolioTableService {
if (accounts.length > 0) { if (accounts.length > 0) {
accountsSection.push( accountsSection.push(
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions: this.getColumnDefinitions({
PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, withValues,
columnDefinitions:
PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS,
valueColumnDefinitions:
PortfolioTableService.ACCOUNTS_TABLE_VALUE_COLUMN_DEFINITIONS
}),
rows: accounts rows: accounts
}) })
); );
@ -397,7 +554,8 @@ export class PortfolioTableService {
take, take,
types, types,
userCurrency, userCurrency,
userId userId,
withValues = false
}: { }: {
endDate?: Date; endDate?: Date;
filters?: Filter[]; filters?: Filter[];
@ -407,6 +565,7 @@ export class PortfolioTableService {
types?: ActivityType[]; types?: ActivityType[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withValues?: boolean;
}) { }) {
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
@ -437,8 +596,13 @@ export class PortfolioTableService {
activitiesSection.push( activitiesSection.push(
'', '',
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions: this.getColumnDefinitions({
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, withValues,
columnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
valueColumnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_VALUE_COLUMN_DEFINITIONS
}),
context: { configurationService: this.configurationService }, context: { configurationService: this.configurationService },
rows: activities rows: activities
}) })
@ -452,12 +616,14 @@ export class PortfolioTableService {
filters, filters,
languageCode, languageCode,
userId, userId,
withDataSource = false withDataSource = false,
withValues = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
languageCode: string; languageCode: string;
userId: string; userId: string;
withDataSource?: boolean; withDataSource?: boolean;
withValues?: boolean;
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters, filters,
@ -484,12 +650,15 @@ export class PortfolioTableService {
'## Holdings', '## Holdings',
'', '',
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions: this.getColumnDefinitions({
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter( withValues,
({ name }) => { columnDefinitions:
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME; PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
} valueColumnDefinitions:
), PortfolioTableService.HOLDINGS_TABLE_VALUE_COLUMN_DEFINITIONS
}).filter(({ name }) => {
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME;
}),
context: { context: {
assetClassTranslations, assetClassTranslations,
assetSubClassTranslations, assetSubClassTranslations,
@ -503,11 +672,15 @@ export class PortfolioTableService {
public async getPerformanceTable({ public async getPerformanceTable({
dateRange, dateRange,
filters, filters,
userId userId,
withAssetPerformanceInBaseCurrency = false,
withValues = false
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
withAssetPerformanceInBaseCurrency?: boolean;
withValues?: boolean;
}) { }) {
const { chart, performance } = await this.portfolioService.getPerformance({ const { chart, performance } = await this.portfolioService.getPerformance({
dateRange, dateRange,
@ -520,8 +693,18 @@ export class PortfolioTableService {
if (chart?.length > 0) { if (chart?.length > 0) {
performanceSection.push( performanceSection.push(
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions: this.getColumnDefinitions({
PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS, withValues,
columnDefinitions:
PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS,
valueColumnDefinitions:
PortfolioTableService.PERFORMANCE_TABLE_VALUE_COLUMN_DEFINITIONS
}).filter(({ name }) => {
return (
withAssetPerformanceInBaseCurrency ||
!ASSET_PERFORMANCE_IN_BASE_CURRENCY_COLUMN_NAMES.includes(name)
);
}),
rows: [performance] rows: [performance]
}) })
); );
@ -587,6 +770,20 @@ export class PortfolioTableService {
return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`; return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`;
} }
private getColumnDefinitions<T>({
columnDefinitions,
valueColumnDefinitions,
withValues
}: {
columnDefinitions: T[];
valueColumnDefinitions: T[];
withValues: boolean;
}) {
return withValues
? [...columnDefinitions, ...valueColumnDefinitions]
: columnDefinitions;
}
private getEnumTranslations<T extends string>({ private getEnumTranslations<T extends string>({
id, id,
languageCode, languageCode,

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

@ -48,6 +48,6 @@
@if (showFooter) { @if (showFooter) {
<footer class="justify-content-center overflow-hidden w-100"> <footer class="justify-content-center overflow-hidden w-100">
<gf-footer class="py-4" [info]="info" [user]="user" /> <gf-footer class="py-4" [info]="info" />
</footer> </footer>
} }

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

@ -35,7 +35,6 @@ import {
inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
@ -82,7 +81,6 @@ import {
MatTabsModule, MatTabsModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-account-detail-dialog', selector: 'gf-account-detail-dialog',
styleUrls: ['./account-detail-dialog.component.scss'], styleUrls: ['./account-detail-dialog.component.scss'],
templateUrl: 'account-detail-dialog.html' templateUrl: 'account-detail-dialog.html'

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

@ -153,7 +153,6 @@
[hasPermissionToCreateActivity]="data.hasPermissionToCreateActivity" [hasPermissionToCreateActivity]="data.hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="false" [hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="!showValuesInPercentage()" [hasPermissionToExportActivities]="!showValuesInPercentage()"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[hasPermissionToUpdateActivity]="data.hasPermissionToUpdateActivity" [hasPermissionToUpdateActivity]="data.hasPermissionToUpdateActivity"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

8
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html

@ -28,12 +28,8 @@
matInput matInput
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
/> />
@if (platformForm.get('url')?.value) { @if (platformForm.get('url')?.value; as url) {
<gf-entity-logo <gf-entity-logo class="mat-icon" matSuffix [url]="url" />
class="mat-icon"
matSuffix
[url]="platformForm.get('url').value"
/>
} }
</mat-form-field> </mat-form-field>
</div> </div>

18
apps/client/src/app/components/footer/footer.component.html

@ -6,7 +6,7 @@
<div class="col-sm"> <div class="col-sm">
<div class="h6 mt-2" i18n>Personal Finance</div> <div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
@if (hasPermissionToAccessFearAndGreedIndex) { @if (hasPermissionToAccessFearAndGreedIndex()) {
<li> <li>
<a i18n [routerLink]="routerLinkMarkets">Markets</a> <a i18n [routerLink]="routerLinkMarkets">Markets</a>
</li> </li>
@ -18,7 +18,7 @@
<div class="h6 mt-2">Ghostfolio</div> <div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li><a i18n [routerLink]="routerLinkAbout">About</a></li> <li><a i18n [routerLink]="routerLinkAbout">About</a></li>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription()) {
<li> <li>
<a i18n [routerLink]="routerLinkBlog">Blog</a> <a i18n [routerLink]="routerLinkBlog">Blog</a>
</li> </li>
@ -27,43 +27,43 @@
<a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a> <a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a>
</li> </li>
<li><a i18n [routerLink]="routerLinkFeatures">Features</a></li> <li><a i18n [routerLink]="routerLinkFeatures">Features</a></li>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription()) {
<li> <li>
<a i18n [routerLink]="routerLinkFaq" <a i18n [routerLink]="routerLinkFaq"
>Frequently Asked Questions (FAQ)</a >Frequently Asked Questions (FAQ)</a
> >
</li> </li>
} }
@if (!hasPermissionForSubscription) { @if (!hasPermissionForSubscription()) {
<li> <li>
<a i18n [routerLink]="routerLinkAboutLicense">License</a> <a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li> </li>
} }
@if (hasPermissionForStatistics) { @if (hasPermissionForStatistics()) {
<li> <li>
<a [routerLink]="routerLinkOpenStartup">Open Startup</a> <a [routerLink]="routerLinkOpenStartup">Open Startup</a>
</li> </li>
} }
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription()) {
<li> <li>
<a i18n [routerLink]="routerLinkPricing">Pricing</a> <a i18n [routerLink]="routerLinkPricing">Pricing</a>
</li> </li>
} }
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription()) {
<li> <li>
<a i18n [routerLink]="routerLinkAboutPrivacyPolicy" <a i18n [routerLink]="routerLinkAboutPrivacyPolicy"
>Privacy Policy</a >Privacy Policy</a
> >
</li> </li>
} }
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription()) {
<li> <li>
<a i18n [routerLink]="routerLinkAboutTermsOfService" <a i18n [routerLink]="routerLinkAboutTermsOfService"
>Terms of Service</a >Terms of Service</a
> >
</li> </li>
} }
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription()) {
<li> <li>
<a <a
class="align-items-baseline d-flex" class="align-items-baseline d-flex"

80
apps/client/src/app/components/footer/footer.component.ts

@ -1,4 +1,4 @@
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfLogoComponent } from '@ghostfolio/ui/logo'; import { GfLogoComponent } from '@ghostfolio/ui/logo';
@ -6,9 +6,9 @@ import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
Input, input
OnChanges
} from '@angular/core'; } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
@ -23,51 +23,53 @@ import { openOutline } from 'ionicons/icons';
styleUrls: ['./footer.component.scss'], styleUrls: ['./footer.component.scss'],
templateUrl: './footer.component.html' templateUrl: './footer.component.html'
}) })
export class GfFooterComponent implements OnChanges { export class GfFooterComponent {
@Input() public info: InfoItem; public readonly info = input.required<InfoItem | undefined>();
@Input() public user: User;
public currentYear = new Date().getFullYear(); protected readonly currentYear = new Date().getFullYear();
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog =
publicRoutes.about.subRoutes.changelog.routerLink;
public routerLinkAboutLicense =
publicRoutes.about.subRoutes.license.routerLink;
public routerLinkAboutPrivacyPolicy =
publicRoutes.about.subRoutes.privacyPolicy.routerLink;
public routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes.termsOfService.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink;
public routerLinkFaq = publicRoutes.faq.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkMarkets = publicRoutes.markets.routerLink;
public routerLinkOpenStartup = publicRoutes.openStartup.routerLink;
public routerLinkPricing = publicRoutes.pricing.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink;
public constructor() {
addIcons({
openOutline
});
}
public ngOnChanges() { protected readonly hasPermissionForStatistics = computed(() => {
this.hasPermissionForStatistics = hasPermission( return hasPermission(
this.info?.globalPermissions, this.info()?.globalPermissions,
permissions.enableStatistics permissions.enableStatistics
); );
});
this.hasPermissionForSubscription = hasPermission( protected readonly hasPermissionForSubscription = computed(() => {
this.info?.globalPermissions, return hasPermission(
this.info()?.globalPermissions,
permissions.enableSubscription permissions.enableSubscription
); );
});
this.hasPermissionToAccessFearAndGreedIndex = hasPermission( protected readonly hasPermissionToAccessFearAndGreedIndex = computed(() => {
this.info?.globalPermissions, return hasPermission(
this.info()?.globalPermissions,
permissions.enableFearAndGreedIndex permissions.enableFearAndGreedIndex
); );
});
protected readonly routerLinkAbout = publicRoutes.about.routerLink;
protected readonly routerLinkAboutChangelog =
publicRoutes.about.subRoutes.changelog.routerLink;
protected readonly routerLinkAboutLicense =
publicRoutes.about.subRoutes.license.routerLink;
protected readonly routerLinkAboutPrivacyPolicy =
publicRoutes.about.subRoutes.privacyPolicy.routerLink;
protected readonly routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes.termsOfService.routerLink;
protected readonly routerLinkBlog = publicRoutes.blog.routerLink;
protected readonly routerLinkFaq = publicRoutes.faq.routerLink;
protected readonly routerLinkFeatures = publicRoutes.features.routerLink;
protected readonly routerLinkMarkets = publicRoutes.markets.routerLink;
protected readonly routerLinkOpenStartup =
publicRoutes.openStartup.routerLink;
protected readonly routerLinkPricing = publicRoutes.pricing.routerLink;
protected readonly routerLinkResources = publicRoutes.resources.routerLink;
public constructor() {
addIcons({
openOutline
});
} }
} }

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

@ -124,7 +124,7 @@
>About</a >About</a
> >
</li> </li>
@if (hasPermissionToAccessAssistant) { @if (hasPermissionToAccessAssistant && user(); as user) {
<li class="d-inline-flex list-inline-item"> <li class="d-inline-flex list-inline-item">
<button <button
#assistantTrigger="matMenuTrigger" #assistantTrigger="matMenuTrigger"
@ -144,7 +144,7 @@
class="no-max-width" class="no-max-width"
xPosition="before" xPosition="before"
[overlapTrigger]="true" [overlapTrigger]="true"
(closed)="assistantElement()?.setIsOpen(false)" (closed)="assistantElement().setIsOpen(false)"
> >
<gf-assistant <gf-assistant
#assistant #assistant
@ -156,7 +156,7 @@
hasPermissionToChangeDateRange() hasPermissionToChangeDateRange()
" "
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters()" [hasPermissionToChangeFilters]="hasPermissionToChangeFilters()"
[user]="user()" [user]="user"
(closed)="closeAssistant()" (closed)="closeAssistant()"
(dateRangeChanged)="onDateRangeChange($event)" (dateRangeChanged)="onDateRangeChange($event)"
(filtersChanged)="onFiltersChanged($event)" (filtersChanged)="onFiltersChanged($event)"
@ -191,7 +191,7 @@
<a class="d-flex" mat-menu-item [routerLink]="routerLinkPricing" <a class="d-flex" mat-menu-item [routerLink]="routerLinkPricing"
><span class="align-items-center d-flex" ><span class="align-items-center d-flex"
><span> ><span>
@if (user().subscription.offer.isRenewal) { @if (user()?.subscription?.offer?.isRenewal) {
<ng-container i18n>Renew Plan</ng-container> <ng-container i18n>Renew Plan</ng-container>
} @else { } @else {
<ng-container i18n>Upgrade Plan</ng-container> <ng-container i18n>Upgrade Plan</ng-container>
@ -203,7 +203,7 @@
></a> ></a>
<hr class="m-0" /> <hr class="m-0" />
} }
@if (user()?.access?.length > 0) { @if ((user()?.access?.length ?? 0) > 0) {
<button mat-menu-item (click)="impersonateAccount(null)"> <button mat-menu-item (click)="impersonateAccount(null)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon

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

@ -41,7 +41,6 @@ import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
@ -116,7 +115,6 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-holding-detail-dialog', selector: 'gf-holding-detail-dialog',
styleUrls: ['./holding-detail-dialog.component.scss'], styleUrls: ['./holding-detail-dialog.component.scss'],
templateUrl: 'holding-detail-dialog.html' templateUrl: 'holding-detail-dialog.html'

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

@ -383,7 +383,6 @@
[hasPermissionToExportActivities]=" [hasPermissionToExportActivities]="
!data.impersonationId && !user?.settings?.isRestrictedView !data.impersonationId && !user?.settings?.isRestrictedView
" "
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[hasPermissionToUpdateActivity]="data.hasPermissionToUpdateActivity" [hasPermissionToUpdateActivity]="data.hasPermissionToUpdateActivity"
[locale]="data.locale" [locale]="data.locale"

56
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -1,5 +1,9 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE,
DEFAULT_LOCALE
} from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
PortfolioPosition, PortfolioPosition,
@ -22,10 +26,12 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef, DestroyRef,
inject, inject,
OnInit OnInit,
signal
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -51,9 +57,22 @@ import { DeviceDetectorService } from 'ngx-device-detector';
export class GfHomeHoldingsComponent implements OnInit { export class GfHomeHoldingsComponent implements OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE'; public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE; protected readonly baseCurrency = computed(() => {
return this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY;
});
protected readonly colorScheme = computed(() => {
return this.user()?.settings?.colorScheme;
});
protected readonly dateRange = computed(() => {
return this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE;
});
protected readonly deviceType = computed(() => {
return this.deviceDetectorService.deviceInfo().deviceType;
});
protected deviceType: string;
protected hasPermissionToAccessHoldingsChart: boolean; protected hasPermissionToAccessHoldingsChart: boolean;
protected hasPermissionToCreateActivity: boolean; protected hasPermissionToCreateActivity: boolean;
protected holdings: PortfolioPosition[] | undefined; protected holdings: PortfolioPosition[] | undefined;
@ -78,9 +97,14 @@ export class GfHomeHoldingsComponent implements OnInit {
{ label: $localize`Closed`, value: 'CLOSED' } { label: $localize`Closed`, value: 'CLOSED' }
]; ];
protected isHoldingsViewModeToggleDisabled = true; protected isHoldingsViewModeToggleDisabled = true;
protected readonly locale = computed(() => {
return this.user()?.settings?.locale ?? DEFAULT_LOCALE;
});
protected readonly routerLinkPortfolioActivities = protected readonly routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink; internalRoutes.portfolio.subRoutes.activities.routerLink;
protected user: User; protected readonly user = signal<User | null>(null);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
@ -94,22 +118,22 @@ export class GfHomeHoldingsComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; const user = state.user;
this.user.set(user);
this.hasPermissionToAccessHoldingsChart = hasPermission( this.hasPermissionToAccessHoldingsChart = hasPermission(
this.user.permissions, user.permissions,
permissions.accessHoldingsChart permissions.accessHoldingsChart
); );
this.hasPermissionToCreateActivity = this.hasPermissionToCreateActivity =
hasPermission(this.user.permissions, permissions.createActivity) && hasPermission(user.permissions, permissions.createActivity) &&
hasScope(this.user.scopes, scopes.activityCreate); hasScope(user.scopes, scopes.activityCreate);
this.initialize(); this.initialize();
} }
@ -129,7 +153,7 @@ export class GfHomeHoldingsComponent implements OnInit {
.get(true) .get(true)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user.set(user);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -144,7 +168,7 @@ export class GfHomeHoldingsComponent implements OnInit {
protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) { protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) { if (dataSource && symbol) {
this.router.navigate([], { void this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }
}); });
} }
@ -157,7 +181,7 @@ export class GfHomeHoldingsComponent implements OnInit {
return this.dataService.fetchPortfolioHoldings({ return this.dataService.fetchPortfolioHoldings({
filters, filters,
range: this.user?.settings?.dateRange range: this.user()?.settings?.dateRange
}); });
} }
@ -171,9 +195,9 @@ export class GfHomeHoldingsComponent implements OnInit {
this.isHoldingsViewModeToggleDisabled = false; this.isHoldingsViewModeToggleDisabled = false;
this.holdingsViewMode = this.holdingsViewMode =
this.deviceType === 'mobile' this.deviceType() === 'mobile'
? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE ? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
: (this.user?.settings?.holdingsViewMode ?? : (this.user()?.settings?.holdingsViewMode ??
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE); GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE);
} else { } else {
this.holdingsViewMode = this.holdingsViewMode =

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

@ -27,18 +27,18 @@
<gf-treemap-chart <gf-treemap-chart
class="mt-3" class="mt-3"
cursor="pointer" cursor="pointer"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="baseCurrency()"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="colorScheme()"
[dateRange]="user?.settings?.dateRange" [dateRange]="dateRange()"
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale" [locale]="locale()"
(treemapChartClicked)="onHoldingClicked($event)" (treemapChartClicked)="onHoldingClicked($event)"
/> />
} }
<div [class.d-none]="holdingsViewMode !== 'TABLE'"> <div [class.d-none]="holdingsViewMode !== 'TABLE'">
<gf-holdings-table <gf-holdings-table
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale ?? DEFAULT_LOCALE" [locale]="locale()"
(holdingClicked)="onHoldingClicked($event)" (holdingClicked)="onHoldingClicked($event)"
/> />
@if (hasPermissionToCreateActivity && (holdings?.length ?? 0) > 0) { @if (hasPermissionToCreateActivity && (holdings?.length ?? 0) > 0) {

8
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -53,7 +53,6 @@ import { DeviceDetectorService } from 'ngx-device-detector';
templateUrl: './home-overview.html' templateUrl: './home-overview.html'
}) })
export class GfHomeOverviewComponent implements OnInit { export class GfHomeOverviewComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected readonly errors = signal<AssetProfileIdentifier[]>([]); protected readonly errors = signal<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false); protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null); protected readonly historicalDataItems = signal<LineChartItem[] | null>(null);
@ -61,6 +60,11 @@ export class GfHomeOverviewComponent implements OnInit {
undefined undefined
); );
protected readonly isLoadingPerformance = signal(true); protected readonly isLoadingPerformance = signal(true);
protected readonly locale = computed(() => {
return this.user()?.settings?.locale ?? DEFAULT_LOCALE;
});
protected readonly performance = signal<PortfolioPerformance | null>(null); protected readonly performance = signal<PortfolioPerformance | null>(null);
protected readonly performanceLabel = $localize`Performance`; protected readonly performanceLabel = $localize`Performance`;
protected readonly precision = signal(2); protected readonly precision = signal(2);
@ -147,7 +151,7 @@ export class GfHomeOverviewComponent implements OnInit {
protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) { protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) { if (dataSource && symbol) {
this.router.navigate([], { void this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }
}); });
} }

33
apps/client/src/app/components/home-overview/home-overview.html

@ -11,7 +11,10 @@
<h4 i18n>Welcome to Ghostfolio</h4> <h4 i18n>Welcome to Ghostfolio</h4>
<p i18n>Ready to take control of your personal finances?</p> <p i18n>Ready to take control of your personal finances?</p>
<ol class="font-weight-bold"> <ol class="font-weight-bold">
<li class="mb-2" [class.text-muted]="user()?.accounts?.length > 1"> <li
class="mb-2"
[class.text-muted]="(user()?.accounts?.length ?? 0) > 1"
>
<a class="d-block" [routerLink]="routerLinkAccounts" <a class="d-block" [routerLink]="routerLinkAccounts"
><span i18n>Setup your accounts</span><br /> ><span i18n>Setup your accounts</span><br />
<span class="font-weight-normal" i18n <span class="font-weight-normal" i18n
@ -48,7 +51,7 @@
> >
<ng-container i18n>Setup accounts</ng-container> <ng-container i18n>Setup accounts</ng-container>
</a> </a>
} @else if (user()?.accounts?.length > 1) { } @else if ((user()?.accounts?.length ?? 0) > 1) {
<a <a
color="primary" color="primary"
mat-flat-button mat-flat-button
@ -73,25 +76,23 @@
[historicalDataItems]="historicalDataItems()" [historicalDataItems]="historicalDataItems()"
[isAnimated]="user()?.settings?.dateRange === '1d' ? false : true" [isAnimated]="user()?.settings?.dateRange === '1d' ? false : true"
[label]="performanceLabel" [label]="performanceLabel"
[locale]="user()?.settings?.locale" [locale]="locale()"
[showGradient]="true" [showGradient]="true"
[showLoader]="false" [showLoader]="false"
[showXAxis]="false" [showXAxis]="false"
[showYAxis]="false" [showYAxis]="false"
/> />
</div> </div>
<div class="overview-container row mt-1"> <div class="mt-1">
<div class="col"> <gf-portfolio-performance
<gf-portfolio-performance [errors]="errors()"
[errors]="errors()" [isLoading]="isLoadingPerformance()"
[isLoading]="isLoadingPerformance()" [locale]="locale()"
[locale]="user()?.settings?.locale" [performance]="performance()"
[performance]="performance()" [precision]="precision()"
[precision]="precision()" [showDetails]="showDetails()"
[showDetails]="showDetails()" [unit]="unit()"
[unit]="unit()" />
/>
</div>
</div> </div>
</div> </div>
@if (showExperimentalHoldings()) { @if (showExperimentalHoldings()) {
@ -107,7 +108,7 @@
<gf-holdings-table <gf-holdings-table
mode="simple" mode="simple"
[holdings]="holdings()" [holdings]="holdings()"
[locale]="user()?.settings?.locale ?? DEFAULT_LOCALE" [locale]="locale()"
(holdingClicked)="onHoldingClicked($event)" (holdingClicked)="onHoldingClicked($event)"
/> />
</mat-card-content> </mat-card-content>

23
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -113,10 +113,15 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
} }
private initialize() { private initialize() {
// Create a clone const isBarChart = !!this.groupBy;
const benchmarkColorRgb = isBarChart ? primaryColorRgb : secondaryColorRgb;
// Create clones of the input data
this.investments = this.benchmarkDataItems.map((item) => this.investments = this.benchmarkDataItems.map((item) =>
Object.assign({}, item) Object.assign({}, item)
); );
this.values = this.historicalDataItems.map((item) => this.values = this.historicalDataItems.map((item) =>
Object.assign({}, item) Object.assign({}, item)
); );
@ -127,9 +132,9 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
}), }),
datasets: [ datasets: [
{ {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, backgroundColor: `rgb(${benchmarkColorRgb.r}, ${benchmarkColorRgb.g}, ${benchmarkColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderColor: `rgb(${benchmarkColorRgb.r}, ${benchmarkColorRgb.g}, ${benchmarkColorRgb.b})`,
borderWidth: this.groupBy ? 0 : 1, borderWidth: isBarChart ? 0 : 1,
data: this.investments.map(({ date, investment }) => { data: this.investments.map(({ date, investment }) => {
return { return {
x: parseDate(date)?.getTime() ?? null, x: parseDate(date)?.getTime() ?? null,
@ -141,7 +146,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
borderColor: (context) => borderColor: (context) =>
this.isInFuture( this.isInFuture(
context, context,
`rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.67)` `rgba(${benchmarkColorRgb.r}, ${benchmarkColorRgb.g}, ${benchmarkColorRgb.b}, 0.67)`
), ),
borderDash: (context) => this.isInFuture(context, [2, 2]) borderDash: (context) => this.isInFuture(context, [2, 2])
}, },
@ -200,10 +205,10 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
annotations: { annotations: {
savingsRate: this.savingsRate savingsRate: this.savingsRate
? { ? {
borderColor: `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.75)`, borderColor: `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.75)`,
borderWidth: 1, borderWidth: 1,
label: { label: {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderRadius: 2, borderRadius: 2,
color: 'white', color: 'white',
content: $localize`Savings Rate`, content: $localize`Savings Rate`,
@ -234,7 +239,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
responsive: true, responsive: true,
scales: { scales: {
x: getTimeAxisOptions({ x: getTimeAxisOptions({
borderWidth: this.groupBy ? 0 : 1, borderWidth: isBarChart ? 0 : 1,
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
locale: this.locale locale: this.locale
}), }),
@ -250,7 +255,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
plugins: [ plugins: [
getVerticalHoverLinePlugin(this.chartCanvas(), this.colorScheme) getVerticalHoverLinePlugin(this.chartCanvas(), this.colorScheme)
], ],
type: this.groupBy ? 'bar' : 'line' type: isBarChart ? 'bar' : 'line'
} }
); );
} }

24
apps/client/src/app/components/markets/markets.component.ts

@ -3,8 +3,8 @@ import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { import {
Benchmark, Benchmark,
HistoricalDataItem,
InfoItem, InfoItem,
LineChartItem,
MarketDataOfMarketsResponse, MarketDataOfMarketsResponse,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -28,6 +28,7 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { isNumber } from 'lodash-es';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@Component({ @Component({
@ -66,7 +67,7 @@ export class GfMarketsComponent implements OnInit {
protected fearAndGreedIndexMode: FearAndGreedIndexMode = 'STOCKS'; protected fearAndGreedIndexMode: FearAndGreedIndexMode = 'STOCKS';
protected hasPermissionToAccessFearAndGreedIndex: boolean; protected hasPermissionToAccessFearAndGreedIndex: boolean;
protected hasPermissionToReadMarketDataOfMarkets: boolean; protected hasPermissionToReadMarketDataOfMarkets: boolean;
protected historicalDataItems: HistoricalDataItem[]; protected historicalDataItems: LineChartItem[];
protected isLoadingFearAndGreedIndex = true; protected isLoadingFearAndGreedIndex = true;
protected user: User; protected user: User;
@ -158,13 +159,20 @@ export class GfMarketsComponent implements OnInit {
this.fearAndGreedIndex = this.fearAndGreedIndex =
this.fearAndGreedIndexData[this.fearAndGreedIndexMode]?.marketPrice; this.fearAndGreedIndexData[this.fearAndGreedIndexMode]?.marketPrice;
this.historicalDataItems = [ const historicalDataItems = (
...(this.fearAndGreedIndexData[this.fearAndGreedIndexMode] this.fearAndGreedIndexData[this.fearAndGreedIndexMode]?.historicalData ??
?.historicalData ?? []), []
{ ).filter((item): item is LineChartItem => {
return isNumber(item.value);
});
if (isNumber(this.fearAndGreedIndex)) {
historicalDataItems.push({
date: resetHours(new Date()).toISOString(), date: resetHours(new Date()).toISOString(),
value: this.fearAndGreedIndex value: this.fearAndGreedIndex
} });
]; }
this.historicalDataItems = historicalDataItems;
} }
} }

2
apps/client/src/app/components/markets/markets.html

@ -29,7 +29,7 @@
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[isAnimated]="true" [isAnimated]="true"
[locale]="user?.settings?.locale || undefined" [locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
[showXAxis]="true" [showXAxis]="true"
[showYAxis]="true" [showYAxis]="true"
[yMax]="100" [yMax]="100"

4
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html

@ -1,6 +1,6 @@
<div class="container p-0"> <div class="container p-0">
<div class="no-gutters row"> <div class="no-gutters row">
<div class="status-container text-muted text-right"> <div class="px-1 status-container text-muted text-right">
@if ((errors()?.length ?? 0) > 0 && !isLoading()) { @if ((errors()?.length ?? 0) > 0 && !isLoading()) {
<ion-icon <ion-icon
i18n-title i18n-title
@ -33,7 +33,7 @@
</div> </div>
</div> </div>
@if (showDetails()) { @if (showDetails()) {
<div class="row"> <div class="mx-0 row">
<div class="d-flex col justify-content-end"> <div class="d-flex col justify-content-end">
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"

1
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.scss

@ -4,7 +4,6 @@
.currency-container, .currency-container,
.status-container { .status-container {
flex: 1; flex: 1;
min-width: 2.5rem;
} }
.status-container { .status-container {

14
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts

@ -35,9 +35,9 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
}) })
export class GfPortfolioPerformanceComponent { export class GfPortfolioPerformanceComponent {
public readonly errors = input<ResponseError['errors']>(); public readonly errors = input<ResponseError['errors']>();
public readonly isLoading = input<boolean>(); public readonly isLoading = input<boolean>(false);
public readonly locale = input<string>(getLocale()); public readonly locale = input<string>(getLocale());
public readonly performance = input.required<PortfolioPerformance>(); public readonly performance = input.required<PortfolioPerformance | null>();
public readonly precision = input.required<number, number>({ public readonly precision = input.required<number, number>({
transform: (value) => { transform: (value) => {
return value >= 0 ? value : 2; return value >= 0 ? value : 2;
@ -60,17 +60,19 @@ export class GfPortfolioPerformanceComponent {
this.value().nativeElement.innerHTML = ''; this.value().nativeElement.innerHTML = '';
} }
} else { } else {
if (isNumber(this.performance().currentValueInBaseCurrency)) { const performance = this.performance();
new CountUp('value', this.performance().currentValueInBaseCurrency, {
if (isNumber(performance?.currentValueInBaseCurrency)) {
new CountUp('value', performance.currentValueInBaseCurrency, {
decimal: getNumberFormatDecimal(this.locale()), decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: this.precision(), decimalPlaces: this.precision(),
duration: 1, duration: 1,
separator: getNumberFormatGroup(this.locale()) separator: getNumberFormatGroup(this.locale())
}).start(); }).start();
} else if (this.showDetails() === false) { } else if (this.showDetails() === false && performance) {
new CountUp( new CountUp(
'value', 'value',
this.performance().netPerformancePercentageWithCurrencyEffect * 100, performance.netPerformancePercentageWithCurrencyEffect * 100,
{ {
decimal: getNumberFormatDecimal(this.locale()), decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: 2, decimalPlaces: 2,

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

@ -5,7 +5,7 @@ import {
export interface RuleSettingsDialogParams { export interface RuleSettingsDialogParams {
categoryName: string; categoryName: string;
locale: string; locale?: string;
rule: PortfolioReportRule; rule: PortfolioReportRule;
settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment']; settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
} }

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

@ -8,11 +8,10 @@ import {
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
DestroyRef, DestroyRef,
EventEmitter,
Input, Input,
OnInit, output
Output
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -41,17 +40,20 @@ import { GfRuleSettingsDialogComponent } from './rule-settings-dialog/rule-setti
styleUrls: ['./rule.component.scss'], styleUrls: ['./rule.component.scss'],
templateUrl: './rule.component.html' templateUrl: './rule.component.html'
}) })
export class GfRuleComponent implements OnInit { export class GfRuleComponent {
@Input() categoryName: string; @Input() categoryName: string;
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale: string; @Input() locale?: string;
@Input() rule: PortfolioReportRule; @Input() rule: PortfolioReportRule;
@Input() settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment']; @Input() settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
@Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>(); public readonly ruleUpdated = output<UpdateUserSettingDto>();
private readonly deviceType = computed(() => {
return this.deviceDetectorService.deviceInfo().deviceType;
});
private deviceType: string;
public constructor( public constructor(
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
private deviceDetectorService: DeviceDetectorService, private deviceDetectorService: DeviceDetectorService,
@ -67,11 +69,7 @@ export class GfRuleComponent implements OnInit {
}); });
} }
public ngOnInit() { protected onCustomizeRule(rule: PortfolioReportRule) {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
}
public onCustomizeRule(rule: PortfolioReportRule) {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfRuleSettingsDialogComponent, GfRuleSettingsDialogComponent,
RuleSettingsDialogParams RuleSettingsDialogParams
@ -82,7 +80,7 @@ export class GfRuleComponent implements OnInit {
locale: this.locale, locale: this.locale,
settings: this.settings settings: this.settings
}, },
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef
@ -99,7 +97,7 @@ export class GfRuleComponent implements OnInit {
}); });
} }
public onUpdateRule(rule: PortfolioReportRule) { protected onUpdateRule(rule: PortfolioReportRule) {
const settings: UpdateUserSettingDto = { const settings: UpdateUserSettingDto = {
xRayRules: { xRayRules: {
[rule.key]: { isActive: !rule.isActive } [rule.key]: { isActive: !rule.isActive }

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

@ -8,9 +8,8 @@ import {
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
EventEmitter,
Input, Input,
Output output
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
@ -26,13 +25,13 @@ export class GfRulesComponent {
@Input() categoryName: string; @Input() categoryName: string;
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale: string; @Input() locale?: string;
@Input() rules: PortfolioReportRule[]; @Input() rules: PortfolioReportRule[];
@Input() settings: XRayRulesSettings; @Input() settings?: XRayRulesSettings;
@Output() rulesUpdated = new EventEmitter<UpdateUserSettingDto>(); public readonly rulesUpdated = output<UpdateUserSettingDto>();
public onRuleUpdated(event: UpdateUserSettingDto) { protected onRuleUpdated(event: UpdateUserSettingDto) {
this.rulesUpdated.emit(event); this.rulesUpdated.emit(event);
} }
} }

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

@ -7,6 +7,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
Scope, Scope,
canGrantRestrictedWriteAccess, canGrantRestrictedWriteAccess,
canGrantUnrestrictedReadAccess,
getAccessLevel, getAccessLevel,
getScopesOfAccess, getScopesOfAccess,
getScopesOfAccessLevel, getScopesOfAccessLevel,
@ -139,6 +140,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
return canGrantRestrictedWriteAccess({ type: this.accessType }); return canGrantRestrictedWriteAccess({ type: this.accessType });
} }
public get canGrantUnrestrictedReadAccess() {
return canGrantUnrestrictedReadAccess({ type: this.accessType });
}
public get canGrantWriteAccess() { public get canGrantWriteAccess() {
return this.hasExperimentalFeatures; return this.hasExperimentalFeatures;
} }
@ -202,13 +207,16 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
granteeUserIdControl?.setValue(null); granteeUserIdControl?.setValue(null);
} }
// Narrow the permission to the scopes which the type permits, because // Narrow the permission to the scopes which the type permits and drop
// an access which is not granted to a user never exposes the monetary // the monetary values, which have to be granted after the type
// values and a public access never changes data
this.accessForm.get('accessLevel')?.setValue( this.accessForm.get('accessLevel')?.setValue(
getAccessLevel( getAccessLevel(
getScopesOfAccess({ getScopesOfAccess({
scopes: getScopesOfAccessLevel(this.accessLevel), scopes: getScopesOfAccessLevel(this.accessLevel).filter(
(scope) => {
return scope !== scopes.portfolioReadValues;
}
),
type: accessType type: accessType
}) })
) )

2
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -92,7 +92,7 @@
/> />
</mat-option> </mat-option>
} }
@if (accessForm.get('type')?.value === 'PRIVATE') { @if (canGrantUnrestrictedReadAccess) {
<mat-option value="READ"> <mat-option value="READ">
<gf-access-level-icon accessLevel="READ" /> <gf-access-level-icon accessLevel="READ" />
</mat-option> </mat-option>

4
apps/client/src/app/components/user-account-membership/user-account-membership.html

@ -3,7 +3,9 @@
<div class="col"> <div class="col">
<div class="align-items-center d-flex flex-column"> <div class="align-items-center d-flex flex-column">
<gf-membership-card <gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat" [expiresAt]="
(user?.subscription?.expiresAt | date: defaultDateFormat) ?? ''
"
[hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey" [hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey"
[hover3d]="true" [hover3d]="true"
[name]="user?.subscription?.type" [name]="user?.subscription?.type"

8
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -43,7 +43,7 @@
}}</span> }}</span>
</mat-form-field> </mat-form-field>
</div> </div>
<div [class.d-none]="platforms?.length < 1"> <div [class.d-none]="platforms.length < 1">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label> <mat-label i18n>Platform</mat-label>
@if (selectedPlatform) { @if (selectedPlatform) {
@ -51,8 +51,8 @@
class="ml-3" class="ml-3"
matPrefix matPrefix
[hasPlaceholder]="true" [hasPlaceholder]="true"
[tooltip]="selectedPlatform?.name" [tooltip]="selectedPlatform.name ?? ''"
[url]="selectedPlatform?.url" [url]="selectedPlatform.url"
/> />
} }
<input <input
@ -73,7 +73,7 @@
<gf-entity-logo <gf-entity-logo
class="mr-1" class="mr-1"
[hasPlaceholder]="true" [hasPlaceholder]="true"
[tooltip]="platformEntry.name" [tooltip]="platformEntry.name ?? ''"
[url]="platformEntry.url" [url]="platformEntry.url"
/> />
<span>{{ platformEntry.name }}</span> <span>{{ platformEntry.name }}</span>

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

@ -128,14 +128,12 @@
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false" [hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="false" [hasPermissionToExportActivities]="false"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="data?.user?.settings?.locale" [locale]="data?.user?.settings?.locale"
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
[pageSize]="pageSize" [pageSize]="pageSize"
[showActions]="false" [showActions]="false"
[showCheckbox]="true" [showCheckbox]="true"
[showSymbolColumn]="false"
[sortColumn]="sortColumn" [sortColumn]="sortColumn"
[sortDirection]="sortDirection" [sortDirection]="sortDirection"
[sortDisabled]="true" [sortDisabled]="true"

44
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -5,6 +5,7 @@ import {
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { canOpenHoldingDetail } from '@ghostfolio/common/helper'; import { canOpenHoldingDetail } from '@ghostfolio/common/helper';
import { import {
InvestmentItem, InvestmentItem,
@ -93,6 +94,7 @@ export class GfAnalysisPageComponent implements OnInit {
protected isLoadingDividendTimelineChart: boolean; protected isLoadingDividendTimelineChart: boolean;
protected isLoadingInvestmentChart: boolean; protected isLoadingInvestmentChart: boolean;
protected isLoadingInvestmentTimelineChart: boolean; protected isLoadingInvestmentTimelineChart: boolean;
protected isLoadingPerformanceTimelineChart: boolean;
protected isLoadingPortfolioPrompt: boolean; protected isLoadingPortfolioPrompt: boolean;
protected readonly mode = signal<GroupBy>('month'); protected readonly mode = signal<GroupBy>('month');
protected readonly modeOptions: ToggleOption<GroupBy>[] = [ protected readonly modeOptions: ToggleOption<GroupBy>[] = [
@ -103,7 +105,9 @@ export class GfAnalysisPageComponent implements OnInit {
protected readonly PerformanceCalculationType = PerformanceCalculationType; protected readonly PerformanceCalculationType = PerformanceCalculationType;
protected performanceDataItems: LineChartItem[]; protected performanceDataItems: LineChartItem[];
protected performanceDataItemsInPercentage: LineChartItem[]; protected performanceDataItemsInPercentage: LineChartItem[];
protected readonly portfolioEvolutionDataLabel = $localize`Investment`; protected performancePercentagesByYear: InvestmentItem[];
protected readonly performanceTimelineDataLabel = $localize`Net Performance`;
protected readonly portfolioEvolutionDataLabel = $localize`Invested Capital`;
protected precision = 2; protected precision = 2;
protected savingsRatePerMonth: number | undefined; protected savingsRatePerMonth: number | undefined;
protected streaks: PortfolioInvestmentsResponse['streaks']; protected streaks: PortfolioInvestmentsResponse['streaks'];
@ -312,6 +316,37 @@ export class GfAnalysisPageComponent implements OnInit {
); );
} }
private fetchPerformanceByYear() {
this.isLoadingPerformanceTimelineChart = true;
this.dataService
.fetchPortfolioPerformance({
filters: this.userService.getFilters(),
groupBy: 'year',
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart }) => {
this.performancePercentagesByYear = [];
for (const {
date,
netPerformanceInPercentageWithCurrencyEffect
} of chart ?? []) {
if (isNumber(netPerformanceInPercentageWithCurrencyEffect)) {
this.performancePercentagesByYear.push({
date,
investment: netPerformanceInPercentageWithCurrencyEffect
});
}
}
this.isLoadingPerformanceTimelineChart = false;
this.changeDetectorRef.markForCheck();
});
}
private update() { private update() {
this.isLoadingInvestmentChart = true; this.isLoadingInvestmentChart = true;
@ -420,6 +455,13 @@ export class GfAnalysisPageComponent implements OnInit {
this.fetchDividendsAndInvestments(); this.fetchDividendsAndInvestments();
if (
this.user?.settings?.isExperimentalFeatures &&
this.user?.subscription?.type !== SubscriptionType.Basic
) {
this.fetchPerformanceByYear();
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }

29
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -430,6 +430,35 @@
</div> </div>
</div> </div>
@if (user?.settings?.isExperimentalFeatures) {
<div class="mb-5 row">
<div class="col-lg">
<div class="align-items-center d-flex mb-4">
<div
class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate"
>
<span i18n>Performance Timeline</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</div>
</div>
<div class="chart-container">
<gf-investment-chart
class="h-100"
groupBy="year"
[benchmarkDataItems]="performancePercentagesByYear"
[benchmarkDataLabel]="performanceTimelineDataLabel"
[colorScheme]="user?.settings?.colorScheme"
[isInPercentage]="true"
[isLoading]="isLoadingPerformanceTimelineChart"
[locale]="user?.settings?.locale"
/>
</div>
</div>
</div>
}
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col-lg"> <div class="col-lg">
<div class="align-items-center d-flex mb-4"> <div class="align-items-center d-flex mb-4">

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

@ -178,7 +178,7 @@
[isPercent]="true" [isPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="2" [precision]="2"
[value]="user?.settings?.annualInterestRate / 100" [value]="(user?.settings?.annualInterestRate ?? 0) / 100"
/></span> /></span>
annual interest rate.</ng-container annual interest rate.</ng-container
> >

32
apps/client/src/app/pages/pricing/pricing-page.html

@ -258,21 +258,23 @@
</ul> </ul>
</div> </div>
<p i18n>Fully managed Ghostfolio cloud offering.</p> <p i18n>Fully managed Ghostfolio cloud offering.</p>
<p class="h5 text-right" [hidden]="!price"> @if (price) {
<span class="font-weight-normal"> <p class="h5 text-right">
@if (coupon) { <span class="font-weight-normal">
<del class="text-muted" @if (coupon) {
>{{ baseCurrency }}&nbsp;{{ price }}</del <del class="text-muted"
>&nbsp;{{ baseCurrency }}&nbsp;<strong>{{ >{{ baseCurrency }}&nbsp;{{ price }}</del
price - coupon >&nbsp;{{ baseCurrency }}&nbsp;<strong>{{
}}</strong> price - coupon
} @else { }}</strong>
{{ baseCurrency }}&nbsp;<strong>{{ price }}</strong> } @else {
} {{ baseCurrency }}&nbsp;<strong>{{ price }}</strong>
<span>&nbsp;</span> }
<span i18n>per year</span></span <span>&nbsp;</span>
> <span i18n>per year</span></span
</p> >
</p>
}
@if ( @if (
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.subscription?.type === 'Basic' user?.subscription?.type === 'Basic'

7
apps/client/src/app/util/date.util.ts

@ -10,16 +10,17 @@ export function formatRelativeTime({
date, date,
language language
}: { }: {
date: string; date: Date | string | null | undefined;
language?: string; language?: string;
}) { }) {
if (date) { if (date) {
const distanceString = formatDistanceToNowStrict(parseISO(date), { const parsedDate = typeof date === 'string' ? parseISO(date) : date;
const distanceString = formatDistanceToNowStrict(parsedDate, {
addSuffix: true, addSuffix: true,
locale: getDateFnsLocale(language) locale: getDateFnsLocale(language)
}); });
return Math.abs(differenceInSeconds(parseISO(date), new Date())) < 60 return Math.abs(differenceInSeconds(parsedDate, new Date())) < 60
? $localize`just now` ? $localize`just now`
: distanceString; : distanceString;
} }

20
apps/client/tsconfig.json

@ -14,20 +14,16 @@
} }
], ],
"angularCompilerOptions": { "angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
// TODO: Remove once the fields are typed as optional
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
},
"strictInjectionParameters": true, "strictInjectionParameters": true,
// TODO: Remove these subsets once strictTemplates is enabled
"strictAttributeTypes": true,
"strictContextGenerics": true,
"strictDomEventTypes": true,
"strictDomLocalRefTypes": true,
"strictInputAccessModifiers": true, "strictInputAccessModifiers": true,
"strictInputTypes": true, "strictTemplates": true
"strictLiteralTypes": true,
"strictNullInputTypes": true,
"strictOutputEventTypes": true,
"strictSafeNavigationTypes": true,
// TODO: Enable stricter rules for this project
"strictTemplates": false
}, },
"compilerOptions": { "compilerOptions": {
"module": "preserve", "module": "preserve",

45
libs/common/src/lib/helper.spec.ts

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canApplyFiltersToAccess, canApplyFiltersToAccess,
canUseCustomAssetProfileSymbol,
extractNumberFromString, extractNumberFromString,
getCountryCodeFromCurrency, getCountryCodeFromCurrency,
getEmojiFlag, getEmojiFlag,
@ -39,6 +40,50 @@ describe('Helper', () => {
}); });
}); });
describe('Can use custom asset profile symbol', () => {
const assetProfile = { id: 'asset-profile-id' };
const symbol = '7e91b7d4-1430-4212-8380-289a06c9bbc1';
it('Free-text symbol', () => {
expect(
canUseCustomAssetProfileSymbol({
assetProfile: null,
symbol: 'Penthouse Apartment'
})
).toEqual(false);
});
it('New asset profile with UUID', () => {
expect(
canUseCustomAssetProfileSymbol({ symbol, assetProfile: null })
).toEqual(true);
});
it('New asset profile with Ghostfolio prefix', () => {
expect(
canUseCustomAssetProfileSymbol({
assetProfile: null,
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(false);
});
it('Existing asset profile with UUID', () => {
expect(canUseCustomAssetProfileSymbol({ assetProfile, symbol })).toEqual(
true
);
});
it('Existing asset profile with Ghostfolio prefix', () => {
expect(
canUseCustomAssetProfileSymbol({
assetProfile,
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(true);
});
});
describe('Extract number from string', () => { describe('Extract number from string', () => {
it('Get decimal number', () => { it('Get decimal number', () => {
expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99); expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99);

23
libs/common/src/lib/helper.ts

@ -237,6 +237,29 @@ export function canOpenHoldingDetail({
return assetProfile?.assetSubClass !== AssetSubClass.CASH; return assetProfile?.assetSubClass !== AssetSubClass.CASH;
} }
/**
* Checks if the symbol of a custom asset profile can be used, either to connect
* to the existing asset profile or to create a new one. The prefix is reserved
* for the asset profiles of the admin, thus a new asset profile cannot use it.
*/
export function canUseCustomAssetProfileSymbol({
assetProfile,
symbol
}: {
assetProfile: Pick<SymbolProfile, 'id'> | null;
symbol: string;
}): boolean {
if (!isValidCustomAssetProfileSymbol(symbol)) {
return false;
}
if (assetProfile) {
return true;
}
return !hasGhostfolioPrefix(symbol);
}
export function capitalize(aString: string) { export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
} }

37
libs/common/src/lib/scopes.spec.ts

@ -3,6 +3,7 @@ import {
SCOPES_OF_READ_ACCESS, SCOPES_OF_READ_ACCESS,
SCOPES_OF_READ_RESTRICTED_ACCESS, SCOPES_OF_READ_RESTRICTED_ACCESS,
SCOPES_OF_WRITE_ACCESS, SCOPES_OF_WRITE_ACCESS,
canGrantUnrestrictedReadAccess,
getAccessLevel, getAccessLevel,
getScopesOfAccess, getScopesOfAccess,
getScopesOfAccessLevel, getScopesOfAccessLevel,
@ -239,12 +240,21 @@ describe('Scopes', () => {
expect(scopesOfAccess).toContain(scopes.watchlistRead); expect(scopesOfAccess).toContain(scopes.watchlistRead);
}); });
it('Cannot expose the monetary values', () => { it('Allows reading the monetary values', () => {
expect( expect(
getScopesOfAccess({ getScopesOfAccess({
scopes: [...SCOPES_OF_READ_ACCESS], scopes: [...SCOPES_OF_READ_ACCESS],
type: 'MCP' type: 'MCP'
}) })
).toContain(scopes.portfolioReadValues);
});
it('Without the scope to read the values', () => {
expect(
getScopesOfAccess({
scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS],
type: 'MCP'
})
).not.toContain(scopes.portfolioReadValues); ).not.toContain(scopes.portfolioReadValues);
}); });
@ -276,6 +286,31 @@ describe('Scopes', () => {
) )
).toEqual('CREATE_READ_RESTRICTED_UPDATE_DELETE'); ).toEqual('CREATE_READ_RESTRICTED_UPDATE_DELETE');
}); });
it('Keeps the access level to change the data with the monetary values', () => {
expect(
getAccessLevel(
getScopesOfAccess({
scopes: getScopesOfAccessLevel('CREATE_READ_UPDATE_DELETE'),
type: 'MCP'
})
)
).toEqual('CREATE_READ_UPDATE_DELETE');
});
});
describe('Can grant unrestricted read access', () => {
it('Model context protocol', () => {
expect(canGrantUnrestrictedReadAccess({ type: 'MCP' })).toEqual(true);
});
it('Private', () => {
expect(canGrantUnrestrictedReadAccess({ type: 'PRIVATE' })).toEqual(true);
});
it('Public', () => {
expect(canGrantUnrestrictedReadAccess({ type: 'PUBLIC' })).toEqual(false);
});
}); });
describe('Get scopes of own access', () => { describe('Get scopes of own access', () => {

13
libs/common/src/lib/scopes.ts

@ -68,19 +68,26 @@ export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] =
* monetary values. * monetary values.
*/ */
const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = { const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = {
MCP: [...SCOPES_OF_READ_RESTRICTED_ACCESS, scopes.activityCreate], MCP: [...SCOPES_OF_READ_ACCESS, scopes.activityCreate],
PRIVATE: Object.values(scopes), PRIVATE: Object.values(scopes),
PUBLIC: SCOPES_OF_PUBLIC_ACCESS PUBLIC: SCOPES_OF_PUBLIC_ACCESS
}; };
/** /**
* Access types which combine a write scope with the restricted read access, * Access types which can combine a write scope with the restricted read access,
* because their tools change data without exposing the monetary values * because their tools which change data do not expose the monetary values
*/ */
export function canGrantRestrictedWriteAccess({ type }: { type: AccessType }) { export function canGrantRestrictedWriteAccess({ type }: { type: AccessType }) {
return type === 'MCP'; return type === 'MCP';
} }
/**
* Access types which can expose the monetary values
*/
export function canGrantUnrestrictedReadAccess({ type }: { type: AccessType }) {
return SCOPES_OF_TYPE[type].includes(scopes.portfolioReadValues);
}
/** /**
* Access level which the scopes of an access grant * Access level which the scopes of an access grant
*/ */

2
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -60,7 +60,7 @@ export class GfLineChartComponent
@Input() benchmarkLabel = ''; @Input() benchmarkLabel = '';
@Input() colorScheme?: ColorScheme; @Input() colorScheme?: ColorScheme;
@Input() currency?: string; @Input() currency?: string;
@Input() historicalDataItems: LineChartItem[]; @Input() historicalDataItems: LineChartItem[] | null;
@Input() isAnimated = false; @Input() isAnimated = false;
@Input() label: string; @Input() label: string;
@Input() locale = getLocale(); @Input() locale = getLocale();

7
libs/ui/src/lib/services/data.service.ts

@ -728,14 +728,21 @@ export class DataService {
public fetchPortfolioPerformance({ public fetchPortfolioPerformance({
filters, filters,
groupBy,
range, range,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
groupBy?: Extract<GroupBy, 'year'>;
range: DateRange; range: DateRange;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Observable<PortfolioPerformanceResponse> { }): Observable<PortfolioPerformanceResponse> {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
if (groupBy) {
params = params.append('groupBy', groupBy);
}
params = params.append('range', range); params = params.append('range', range);
if (withExcludedAccounts) { if (withExcludedAccounts) {

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.82.0", "version": "3.83.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.82.0", "version": "3.83.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.82.0", "version": "3.83.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",

Loading…
Cancel
Save