Browse Source

Merge branch 'main' into fix/oidc-state-silent-failure

pull/7797/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
67e443595e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 79
      CHANGELOG.md
  2. 4
      apps/api/src/app/account/get-all-accounts.dto.ts
  3. 20
      apps/api/src/app/activities/activities.service.spec.ts
  4. 7
      apps/api/src/app/activities/activities.service.ts
  5. 5
      apps/api/src/app/endpoints/ai/ai.controller.ts
  6. 48
      apps/api/src/app/endpoints/ai/ai.module.ts
  7. 164
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  8. 454
      apps/api/src/app/endpoints/ai/ai.service.ts
  9. 25
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  10. 64
      apps/api/src/app/endpoints/asset-profiles/get-asset-profiles.dto.ts
  11. 16
      apps/api/src/app/endpoints/data-providers/ghostfolio/get-lookup.dto.ts
  12. 5
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  13. 320
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  14. 307
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  15. 14
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  16. 91
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  17. 128
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  18. 360
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  19. 232
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  20. 18
      apps/api/src/app/endpoints/mcp/mcp.test-utils.ts
  21. 7
      apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts
  22. 4
      apps/api/src/app/import/errors/import-validation.error.ts
  23. 13
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  24. 18
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  25. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  26. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  27. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  28. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  29. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  30. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  31. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  32. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-before-first-market-price.spec.ts
  33. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  34. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  35. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  36. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  37. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  38. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  39. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  40. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-before-first-market-price.spec.ts
  41. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  42. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-from-two-data-sources.spec.ts
  43. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  44. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts
  45. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  46. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  47. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts
  48. 8
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  49. 3
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  50. 15
      apps/api/src/app/portfolio/current-rate.service.ts
  51. 4
      apps/api/src/app/portfolio/get-holdings.dto.ts
  52. 2
      apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts
  53. 6
      apps/api/src/app/portfolio/portfolio.controller.ts
  54. 282
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  55. 100
      apps/api/src/app/portfolio/portfolio.service.ts
  56. 5
      apps/api/src/app/subscription/subscription.controller.ts
  57. 14
      apps/api/src/app/subscription/subscription.service.ts
  58. 16
      apps/api/src/app/symbol/get-lookup.dto.ts
  59. 6
      apps/api/src/app/symbol/symbol.controller.ts
  60. 4
      apps/api/src/app/user/user.service.ts
  61. 39
      apps/api/src/dtos/filter.dto.ts
  62. 12
      apps/api/src/errors/caller-facing.error.ts
  63. 83
      apps/api/src/filters/mcp-tool-exception.filter.spec.ts
  64. 28
      apps/api/src/filters/mcp-tool-exception.filter.ts
  65. 6
      apps/api/src/helper/interfaces/table-column-definition.interface.ts
  66. 7
      apps/api/src/helper/interfaces/table-parameters.interface.ts
  67. 77
      apps/api/src/helper/markdown-table.helper.spec.ts
  68. 44
      apps/api/src/helper/markdown-table.helper.ts
  69. 2
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  70. 18
      apps/api/src/services/api/api.service.ts
  71. 32
      apps/api/src/services/data-provider/data-provider.service.ts
  72. 2
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  73. 6
      apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts
  74. 14
      apps/api/src/services/portfolio-table/portfolio-table.module.ts
  75. 272
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  76. 399
      apps/api/src/services/portfolio-table/portfolio-table.service.ts
  77. 9
      apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts
  78. 8
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  79. 38
      apps/client/src/app/components/access-table/access-table.component.html
  80. 26
      apps/client/src/app/components/access-table/access-table.component.ts
  81. 8
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  82. 141
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  83. 21
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  84. 10
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  85. 217
      apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/asset-profile-dialog-host.component.ts
  86. 1
      apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/types/asset-profile-dialog-mode.type.ts
  87. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  88. 1
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  89. 9
      apps/client/src/app/components/admin-overview/admin-overview.html
  90. 2
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html
  91. 9
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss
  92. 5
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  93. 14
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  94. 4
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  95. 50
      apps/client/src/app/components/home-overview/home-overview.component.ts
  96. 31
      apps/client/src/app/components/home-overview/home-overview.html
  97. 14
      apps/client/src/app/components/home-overview/home-overview.scss
  98. 66
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  99. 8
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  100. 15
      apps/client/src/app/components/home-watchlist/home-watchlist.service.ts

79
CHANGELOG.md

@ -5,13 +5,88 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## 3.70.0 - 2026-09-14
### Added
- Added the holdings table to the overview tab of the home page on desktop (experimental)
- Added a tool to search for asset profiles to the server of the Model Context Protocol (MCP) (experimental)
### Changed
- Restricted the _Restricted view and manage_ permission of the access to share the portfolio to the Model Context Protocol (MCP) (experimental)
- Improved the style of the activity type filter on the activities page (experimental)
- Improved the style of the no activities info component
- Improved the style of the selector in the benchmark comparator
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for German (`de`)
- Upgraded `@codewithdan/observable-store` from version `2.2.15` to `3.0.0`
- Upgraded `bull-board` from version `9.5.0` to `9.8.0`
- Upgraded `papaparse` from version `5.5.3` to `5.7.0`
- Upgraded `zone.js` from version `0.16.2` to `0.16.3`
### Fixed
- Hardened the authentication with _OpenID Connect_ (`OIDC`) (experimental)
## 3.69.0 - 2026-09-07
### Changed
- Improved the loading state of the activity count in the portfolio summary
- Migrated the create dialog of the watchlist to a dedicated route
- Changed the holdings endpoint to return active and closed holdings by default and reuse a single snapshot for both types
- Upgraded `countries-and-timezones` from version `3.9.0` to `3.10.0`
- Upgraded `bull-board` from version `9.0.1` to `9.5.0`
- Upgraded `zod` from version `4.4.3` to `4.5.4`
### Fixed
- Resolved an issue when opening an asset profile from the watchlist
## 3.68.0 - 2026-09-06
### Added
- Added a simplified mode to the holdings table component
### Changed
- Made the details of holdings excluded from analysis accessible via the activities table
- Migrated the asset profile dialogs of the market data management in the admin control panel to dedicated routes
## 3.67.1 - 2026-09-05
### Added
- Introduced a DTO for the query parameters of the asset profiles endpoint
- Introduced a DTO for the query parameters of the symbol lookup endpoints
### Changed
- Improved the server of the Model Context Protocol (MCP) (experimental)
- Introduced a maximum length for the comment in the API endpoints
- Introduced a maximum length for the search query and the symbol in the API endpoints
- Hardened the validation of the query parameters (`accounts`, `assetClasses`, `dataSource` and `tags`) in the API endpoints with filters
- Upgraded `nestjs` from version `11.1.28` to `11.2.3`
- Upgraded `ngx-skeleton-loader` from version `12.0.0` to `13.0.0`
### Fixed
- Fixed the missing icon of the expiration date picker in the create or update access dialog
- Fixed the data provider information in the holding detail dialog
- Fixed the storage of the market data in the data provider service to only include the newly fetched quotes
- Fixed the immediate expiration of a portfolio snapshot with errors
- Fixed the missing country mapping of _Congo (Dem. Rep. of the)_ and _Congo (Rep. of)_ in the _Financial Modeling Prep_ service
## 3.66.0 - 2026-09-03
### Changed
- Moved the details of the granted access from the table to the dialog on the access page (experimental)
- Restricted the _Restricted view and manage_ permission of the access to share the portfolio to the Model Context Protocol (MCP) (experimental)
- Migrated the transfer cash balance dialog to a dedicated route
- Improved the language localization for Italian (`it`)
- Upgraded `@rekog/mcp-nest` from version `2.0.0` to `2.0.2`
- Upgraded `prisma` from version `7.9.1` to `7.10.0`
### Fixed

4
apps/api/src/app/account/get-all-accounts.dto.ts

@ -1,9 +1,11 @@
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { IsOptional, IsString } from 'class-validator';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class GetAllAccountsDto extends FilterDto {
@IsOptional()
@IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query?: string;
}

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

@ -216,6 +216,26 @@ describe('ActivitiesService', () => {
});
});
it('includes excluded accounts and activities when requested', async () => {
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [],
count: 0
});
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'USD',
userId: 'user-id',
withExcludedAccountsAndActivities: true
});
expect(activitiesService.getActivities).toHaveBeenCalledWith({
filters: undefined,
userCurrency: 'USD',
userId: 'user-id',
withExcludedAccountsAndActivities: true
});
});
it('does not adjust synthetic cash activities', async () => {
const activity = createActivity({ symbol: 'AAPL' });
const cashActivity = createActivity({

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

@ -759,7 +759,8 @@ export class ActivitiesService {
filters,
userCurrency,
userId,
withCash = false
withCash = false,
withExcludedAccountsAndActivities = false
}: {
/** Optional filters to apply to the activities. */
filters?: Filter[];
@ -769,13 +770,15 @@ export class ActivitiesService {
userId: string;
/** Whether to include cash activities in the result. */
withCash?: boolean;
/** Whether to include activities that are excluded from analysis. */
withExcludedAccountsAndActivities?: boolean;
}) {
const [activities, splits] = await Promise.all([
this.getActivities({
filters,
userCurrency,
userId,
withExcludedAccountsAndActivities: false // TODO
withExcludedAccountsAndActivities
}),
this.assetProfileSplitService.getSplitsByUserId({ userId })
]);

5
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -1,6 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -12,7 +13,8 @@ import {
Inject,
Param,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -30,6 +32,7 @@ export class AiController {
@Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getPrompt(
@Param('mode') mode: AiPromptMode,
@Query()

48
apps/api/src/app/endpoints/ai/ai.module.ts

@ -1,26 +1,8 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -31,32 +13,12 @@ import { AiService } from './ai.service';
controllers: [AiController],
exports: [AiService],
imports: [
ActivitiesModule,
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PortfolioTableModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
UserModule
TransformDataSourceInRequestModule
],
providers: [
AccountBalanceService,
AccountService,
AiService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
providers: [AiService]
})
export class AiModule {}

164
apps/api/src/app/endpoints/ai/ai.service.spec.ts

@ -1,164 +0,0 @@
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { AccountWithValue } from '@ghostfolio/common/types';
import { AiService } from './ai.service';
// The service imports two packages which ship as an ECMAScript module only,
// which Jest cannot transform. The mocks only make the imports resolvable,
// because no test calls them.
jest.mock('@openrouter/ai-sdk-provider', () => {
return { createOpenRouter: jest.fn() };
});
jest.mock('ai', () => {
return { generateText: jest.fn() };
});
/**
* The markdown table is rendered by a package which ships as an ECMAScript
* module only, hence the service loads it with a dynamic import which Jest
* cannot run. The tests replace the method by a simple renderer, so that they
* can read the columns and the rows which the service gives to it.
*/
interface AiServiceWithMarkdownTable {
getMarkdownTable(parameters: {
columnDefinitions: readonly { name: string }[];
rows: Record<string, string>[];
}): Promise<string>;
}
function createAccount({
id = 'account-a-id',
isExcluded = false,
name = 'Account A'
}: {
id?: string;
isExcluded?: boolean;
name?: string;
} = {}) {
return {
id,
name,
activitiesCount: 3,
allocationInPercentage: 0.25,
balance: 1000,
currency: 'CHF',
platform: { name: 'Platform A' },
tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [],
value: 2000
} as unknown as AccountWithValue;
}
function createAiService(accounts: AccountWithValue[]) {
const portfolioService = {
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts })
} as unknown as PortfolioService;
const aiService = new AiService(null, null, null, portfolioService, null);
jest
.spyOn(
aiService as unknown as AiServiceWithMarkdownTable,
'getMarkdownTable'
)
.mockImplementation(async ({ columnDefinitions, rows }) => {
const columnNames = columnDefinitions.map(({ name }) => {
return name;
});
return [
columnNames.join(' | '),
...rows.map((row) => {
return columnNames
.map((columnName) => {
return row[columnName];
})
.join(' | ');
})
].join('\n');
});
return aiService;
}
describe('AiService', () => {
// The tools of the model context protocol are the only callers, and an
// access of that type never grants the scope to read the monetary values,
// hence no table has a column with such a value
describe('getAccountsTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(AiService.getAccountsTableColumnNames()).toEqual([
'Id',
'Name',
'Currency',
'Platform',
'Activities Count',
'Allocation in Percentage',
'Excluded from Analysis'
]);
});
});
describe('getActivitiesTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(AiService.getActivitiesTableColumnNames()).toEqual([
'Date',
'Type',
'Name',
'Symbol',
'Currency',
'Unit Price',
'Account'
]);
});
});
describe('getAccountsTable', () => {
it('gives no cash balance and no value of an account', async () => {
const aiService = createAiService([createAccount()]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
expect(result).not.toContain('Cash Balance');
expect(result).not.toContain('1000');
expect(result).not.toContain('2000');
});
// The accountIds parameter of the tool takes the identifiers, hence the
// table has to give them
it('gives the identifier of an account', async () => {
const aiService = createAiService([createAccount()]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
expect(result).toContain('account-a-id');
});
it('marks an account which is excluded from the analysis', async () => {
const aiService = createAiService([
createAccount({ isExcluded: true }),
createAccount({ id: 'account-b-id', name: 'Account B' })
]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
const [rowOfAccountA, rowOfAccountB] = result
.split('\n')
.filter((line) => {
return line.startsWith('account-');
});
expect(rowOfAccountA).toContain('true');
expect(rowOfAccountB).toContain('false');
});
it('tells that no accounts are found if the result is empty', async () => {
const aiService = createAiService([]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
expect(result).toContain('No accounts found.');
});
});
});

454
apps/api/src/app/endpoints/ai/ai.service.ts

@ -1,122 +1,25 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import {
AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { generateText } from 'ai';
import { format } from 'date-fns';
import type { ColumnDescriptor } from 'tablemark';
@Injectable()
export class AiService {
private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ACTIVITIES_COUNT'
| 'ALLOCATION_PERCENTAGE'
| 'CURRENCY'
| 'EXCLUDED_FROM_ANALYSIS'
| 'ID'
| 'NAME'
| 'PLATFORM';
} & ColumnDescriptor)[] = [
{ key: 'ID', name: 'Id' },
{ key: 'NAME', name: 'Name' },
{ key: 'CURRENCY', name: 'Currency' },
{ key: 'PLATFORM', name: 'Platform' },
{ align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' },
{
align: 'right',
key: 'ALLOCATION_PERCENTAGE',
name: 'Allocation in Percentage'
},
{ key: 'EXCLUDED_FROM_ANALYSIS', name: 'Excluded from Analysis' }
];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ACCOUNT'
| 'CURRENCY'
| 'DATE'
| 'NAME'
| 'SYMBOL'
| 'TYPE'
| 'UNIT_PRICE';
} & ColumnDescriptor)[] = [
{ key: 'DATE', name: 'Date' },
{ key: 'TYPE', name: 'Type' },
{ key: 'NAME', name: 'Name' },
{ key: 'SYMBOL', name: 'Symbol' },
{ key: 'CURRENCY', name: 'Currency' },
{ align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' },
{ key: 'ACCOUNT', name: 'Account' }
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ACTIVITIES_COUNT'
| 'ALLOCATION_PERCENTAGE'
| 'ASSET_CLASS'
| 'ASSET_SUB_CLASS'
| 'CURRENCY'
| 'DATE_OF_FIRST_ACTIVITY'
| 'NAME'
| 'SYMBOL';
} & ColumnDescriptor)[] = [
{ key: 'NAME', name: 'Name' },
{ key: 'SYMBOL', name: 'Symbol' },
{ key: 'CURRENCY', name: 'Currency' },
{ key: 'ASSET_CLASS', name: 'Asset Class' },
{ key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' },
{ key: 'DATE_OF_FIRST_ACTIVITY', name: 'Date of First Activity' },
{ align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' },
{
align: 'right',
key: 'ALLOCATION_PERCENTAGE',
name: 'Allocation in Percentage'
}
];
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService,
private readonly portfolioTableService: PortfolioTableService,
private readonly propertyService: PropertyService
) {}
public static getAccountsTableColumnNames() {
return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name;
});
}
public static getActivitiesTableColumnNames() {
return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name;
});
}
public static getHoldingsTableColumnNames() {
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name;
});
}
public async generateText({
prompt,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
@ -143,191 +46,6 @@ export class AiService {
});
}
public async getAccountsTable({
filters,
userId
}: {
filters?: Filter[];
userId: string;
}) {
const { accounts } =
await this.portfolioService.getAccountsWithAggregations({
filters,
userId,
withExcludedAccounts: true
});
const accountsTableRows = accounts.map(
({
activitiesCount,
allocationInPercentage,
currency,
id,
name: label,
platform,
tags
}) => {
return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACTIVITIES_COUNT':
row[name] = activitiesCount.toString();
break;
case 'ALLOCATION_PERCENTAGE':
row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`;
break;
case 'CURRENCY':
row[name] = currency ?? '';
break;
case 'EXCLUDED_FROM_ANALYSIS':
row[name] = isAccountExcluded({ tags }).toString();
break;
case 'ID':
row[name] = id;
break;
case 'NAME':
row[name] = label ?? '';
break;
case 'PLATFORM':
row[name] = platform?.name ?? '';
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
const accountsSection = ['## Accounts', ''];
if (accountsTableRows.length > 0) {
accountsSection.push(
await this.getMarkdownTable({
columnDefinitions: AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS,
rows: accountsTableRows
})
);
} else {
accountsSection.push('No accounts found.');
}
return accountsSection.join('\n');
}
public async getActivitiesTable({
endDate,
filters,
skip = 0,
startDate,
take,
types,
userCurrency,
userId
}: {
endDate?: Date;
filters?: Filter[];
skip?: number;
startDate?: Date;
take: number;
types?: ActivityType[];
userCurrency: string;
userId: string;
}) {
const { activities, count } = await this.activitiesService.getActivities({
endDate,
filters,
skip,
startDate,
take,
types,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'desc',
withExcludedAccountsAndActivities: true
});
const activitiesTableRows = activities.map(
({ account, assetProfile, currency, date, type, unitPrice }) => {
return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACCOUNT':
row[name] = account?.name ?? '';
break;
case 'CURRENCY':
row[name] = currency ?? assetProfile.currency;
break;
case 'DATE':
row[name] = format(date, DATE_FORMAT);
break;
case 'NAME':
row[name] = assetProfile.name ?? '';
break;
case 'SYMBOL':
row[name] = assetProfile.symbol;
break;
case 'TYPE':
row[name] = type;
break;
case 'UNIT_PRICE':
row[name] = unitPrice.toString();
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
const activitiesSection = [
'## Activities',
'',
this.getActivitiesSummary({
count,
skip,
numberOfActivities: activities.length
})
];
if (activitiesTableRows.length > 0) {
activitiesSection.push(
'',
await this.getMarkdownTable({
columnDefinitions: AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
rows: activitiesTableRows
})
);
}
return activitiesSection.join('\n');
}
public async getPrompt({
filters,
languageCode,
@ -341,98 +59,12 @@ export class AiService {
userCurrency: string;
userId: string;
}) {
const { holdings } = await this.portfolioService.getDetails({
const holdingsSection = await this.portfolioTableService.getHoldingsTable({
filters,
userId
});
const assetClassTranslations = this.getEnumTranslations({
languageCode,
id: 'assetClass',
values: Object.values(AssetClass)
});
const assetSubClassTranslations = this.getEnumTranslations({
languageCode,
id: 'assetSubClass',
values: Object.values(AssetSubClass)
userId
});
const holdingsTableRows = [...holdings]
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
.map(
({
activitiesCount,
allocationInPercentage,
assetProfile: {
assetClass,
assetSubClass,
currency,
name: label,
symbol
},
dateOfFirstActivity
}) => {
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACTIVITIES_COUNT':
row[name] = activitiesCount.toString();
break;
case 'ALLOCATION_PERCENTAGE':
row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`;
break;
case 'ASSET_CLASS':
row[name] = assetClassTranslations[assetClass] ?? '';
break;
case 'ASSET_SUB_CLASS':
row[name] = assetSubClassTranslations[assetSubClass] ?? '';
break;
case 'CURRENCY':
row[name] = currency;
break;
case 'DATE_OF_FIRST_ACTIVITY':
row[name] = dateOfFirstActivity
? format(dateOfFirstActivity, DATE_FORMAT)
: '';
break;
case 'NAME':
row[name] = label;
break;
case 'SYMBOL':
row[name] = symbol;
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
const holdingsSection = [
'## Holdings',
'',
await this.getMarkdownTable({
columnDefinitions: AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
rows: holdingsTableRows
})
].join('\n');
if (mode === 'portfolio') {
return holdingsSection;
}
@ -451,82 +83,4 @@ export class AiService {
`Provide your answer in the following language: ${languageCode}.`
].join('\n');
}
private getActivitiesSummary({
count,
numberOfActivities,
skip
}: {
count: number;
numberOfActivities: number;
skip: number;
}) {
if (count === 0) {
return 'No activities found.';
}
if (numberOfActivities === 0) {
return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`;
}
if (numberOfActivities === count) {
return `Showing all ${count} activities, the most recent first.`;
}
const lastActivity = skip + numberOfActivities;
const summary = `Showing the activities ${
skip + 1
} to ${lastActivity} of ${count}, the most recent first.`;
if (lastActivity === count) {
return summary;
}
return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`;
}
private getEnumTranslations<T extends string>({
id,
languageCode,
values
}: {
id: string;
languageCode: string;
values: T[];
}) {
return values.reduce(
(translations, value) => {
translations[value] =
this.i18nService.getTranslation({
languageCode,
id: `${id}.${value}`
}) || value;
return translations;
},
{} as Record<T, string>
);
}
private async getMarkdownTable({
columnDefinitions,
rows
}: {
columnDefinitions: readonly ColumnDescriptor[];
rows: Record<string, string>[];
}) {
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
const { tablemark } = await dynamicImport('tablemark');
return tablemark(rows, {
columns: columnDefinitions.map(({ align, name }) => {
return { name, align: align ?? 'left' };
})
});
}
}

25
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -17,7 +17,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions';
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -27,7 +27,6 @@ import {
HttpException,
Inject,
Param,
ParseIntPipe,
Patch,
Post,
Query,
@ -36,11 +35,12 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client';
import { AssetProfileSplit, DataSource } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service';
import { GetAssetProfilesDto } from './get-asset-profiles.dto';
@AllowDuringImpersonation()
@Controller('asset-profiles')
@ -56,14 +56,17 @@ export class AssetProfilesController {
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAssetProfiles(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take', new ParseIntPipe({ optional: true })) take?: number
@Query()
{
assetSubClasses: filterByAssetSubClasses,
dataSource: filterByDataSource,
presetId,
query: filterBySearchQuery,
skip,
sortColumn,
sortDirection,
take
}: GetAssetProfilesDto
): Promise<AssetProfilesResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,

64
apps/api/src/app/endpoints/asset-profiles/get-asset-profiles.dto.ts

@ -0,0 +1,64 @@
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsOptional,
IsString,
MaxLength,
Min
} from 'class-validator';
export class GetAssetProfilesDto {
@IsOptional()
@IsString()
assetSubClasses?: string;
@IsOptional()
@IsString()
dataSource?: string;
@IsIn([
'BENCHMARKS',
'CURRENCIES',
'ETF_WITHOUT_COUNTRIES',
'ETF_WITHOUT_SECTORS',
'NO_ACTIVITIES'
] as MarketDataPreset[])
@IsOptional()
presetId?: MarketDataPreset;
@IsOptional()
@IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query?: string;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
skip?: number;
@IsIn([
'activitiesCount',
'assetClass',
'assetSubClass',
'dataSource',
'symbol'
])
@IsOptional()
sortColumn?: string;
@IsIn(['asc', 'desc'] as Prisma.SortOrder[])
@IsOptional()
sortDirection?: Prisma.SortOrder;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
take?: number;
}

16
apps/api/src/app/endpoints/data-providers/ghostfolio/get-lookup.dto.ts

@ -0,0 +1,16 @@
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean, IsString, MaxLength } from 'class-validator';
export class GetLookupDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
includeIndices? = false;
@IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query? = '';
}

5
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -32,6 +32,7 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { GetDividendsDto } from './get-dividends.dto';
import { GetHistoricalDto } from './get-historical.dto';
import { GetLookupDto } from './get-lookup.dto';
import { GetQuotesDto } from './get-quotes.dto';
import { GhostfolioService } from './ghostfolio.service';
@ -142,10 +143,8 @@ export class GhostfolioController {
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
@Query() { includeIndices, query }: GetLookupDto
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
await this.validateDailyRequestLimit();
try {

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

@ -1,285 +1,93 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { Activity } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes';
import type {
ImpersonationContext,
UserWithSettings
} from '@ghostfolio/common/types';
import { HttpException, Logger } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { DataSource, Type as ActivityType } from '@prisma/client';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { AccessGuard } from '@ghostfolio/api/guards/access.guard';
import { Scope, scopes } from '@ghostfolio/common/scopes';
import {
GhostfolioMcpController,
IMPORT_ACTIVITIES_PARAMETERS
} from './mcp.controller';
// The controller reads the columns of the tables from the AiService, which
// imports two packages which ship as an ECMAScript module only, which Jest
// cannot transform. The mocks only make the imports resolvable, because no
// test calls them.
jest.mock('@openrouter/ai-sdk-provider', () => {
return { createOpenRouter: jest.fn() };
});
jest.mock('ai', () => {
return { generateText: jest.fn() };
});
function createActivity(overrides: Record<string, unknown> = {}) {
return {
currency: 'USD',
date: '2024-01-01',
fee: 0,
quantity: 1,
symbol: 'AAPL',
type: ActivityType.BUY,
unitPrice: 100,
...overrides
};
EXCEPTION_FILTERS_METADATA,
GUARDS_METADATA
} from '@nestjs/common/constants';
import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest';
import { GhostfolioMcpController } from './mcp.controller';
/**
* Gives the metadata which a decorator sets on the method of a tool. The
* prototype is read by the name of the method, hence the type of the metadata
* is given by the caller.
*/
function getMetadataOfMethod<T>(metadataKey: string, methodName: string) {
const methodsByName = GhostfolioMcpController.prototype as unknown as Record<
string,
object
>;
return Reflect.getMetadata(metadataKey, methodsByName[methodName]) as T;
}
describe('GhostfolioMcpController', () => {
const impersonation = { userId: 'user-id' } as ImpersonationContext;
let configuration: Record<string, unknown>;
let configurationService: ConfigurationService;
let controller: GhostfolioMcpController;
let importService: ImportService;
let userService: UserService;
function setupUser(userPermissions: string[]) {
jest.spyOn(userService, 'user').mockResolvedValue({
permissions: userPermissions
} as UserWithSettings);
}
beforeEach(() => {
configuration = {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [],
ENABLE_FEATURE_SUBSCRIPTION: false
};
configurationService = {
get: jest.fn().mockImplementation((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService;
importService = {
import: jest.fn().mockResolvedValue([])
} as unknown as ImportService;
userService = { user: jest.fn() } as unknown as UserService;
controller = new GhostfolioMcpController(
undefined,
undefined,
configurationService,
importService,
userService
function getToolMethodNames() {
return Object.getOwnPropertyNames(GhostfolioMcpController.prototype).filter(
(methodName) => {
return Boolean(
getMetadataOfMethod<ToolMetadata>(MCP_TOOL_METADATA_KEY, methodName)
);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Import activities', () => {
it('Requires the scope to create an activity', () => {
expect(
Reflect.getMetadata(
REQUIRES_SCOPE_KEY,
GhostfolioMcpController.prototype.importActivities
)
).toEqual([scopes.activityCreate]);
});
it('Refuses a user without the permission to create an activity', async () => {
setupUser([]);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity()]
})
).rejects.toThrow(HttpException);
expect(importService.import).not.toHaveBeenCalled();
});
it('Gives the number of the imported and of the skipped activities', async () => {
setupUser([permissions.createActivity]);
jest
.spyOn(importService, 'import')
.mockResolvedValue([{ id: 'activity-id' } as Activity]);
expect(
await controller.importActivities(impersonation, {
activities: [createActivity(), createActivity({ quantity: 2 })]
})
).toEqual({
content: [
{
text: 'Imported activities: 1\nSkipped duplicate activities: 1',
type: 'text'
}
]
});
});
it('Resolves the mask of the data source of the Ghostfolio data provider', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await controller.importActivities(impersonation, {
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.YAHOO })
]
})
);
});
it('Keeps the data source if the subscription is not enabled', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
await controller.importActivities(impersonation, {
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO })
]
})
);
});
}
it('Passes on the message of a validation only', async () => {
setupUser([permissions.createActivity]);
describe('GhostfolioMcpController', () => {
// A tool without the decorator of the scope would be open to every access,
// hence a new tool has to declare its scope
it('Requires a scope of access for each tool', () => {
const toolMethodNames = getToolMethodNames();
jest
.spyOn(importService, 'import')
.mockRejectedValue(
new ImportValidationError('activities.0.symbol ("X") is not valid')
);
expect(toolMethodNames.length).toBeGreaterThan(0);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity()]
})
).rejects.toThrow(
new RpcException('activities.0.symbol ("X") is not valid')
);
const toolMethodNamesWithoutScope = toolMethodNames.filter((methodName) => {
return !getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, methodName)
?.length;
});
it('Hides the message of an unexpected error and writes it to the log', async () => {
setupUser([permissions.createActivity]);
const error = new Error(
'Unique constraint failed on the fields: (dataSource)'
);
const logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation();
jest.spyOn(importService, 'import').mockRejectedValue(error);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity()]
})
).rejects.toThrow(
new RpcException(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR))
);
expect(logError).toHaveBeenCalledWith(error);
expect(toolMethodNamesWithoutScope).toEqual([]);
});
it('Does not write the message of a validation to the log', async () => {
setupUser([permissions.createActivity]);
const logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation();
// The decorator RequiresScope sets the same metadata as the decorator
// RequiresScopeOfAccess, but applies AuthGuard('jwt'), which a request of
// an access cannot pass, hence the guards tell the two decorators apart
it('Applies the guard of the access to each tool', () => {
const toolMethodNames = getToolMethodNames();
jest
.spyOn(importService, 'import')
.mockRejectedValue(
new ImportValidationError('activities.0.accountId ("X") is not valid')
);
expect(toolMethodNames.length).toBeGreaterThan(0);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity({ accountId: 'X' })]
})
).rejects.toThrow(RpcException);
expect(logError).not.toHaveBeenCalled();
});
});
describe('Parameters of the tool to import activities', () => {
function parse(activities: unknown[]) {
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success;
const toolMethodNamesWithoutGuardOfAccess = toolMethodNames.filter(
(methodName) => {
return !getMetadataOfMethod<unknown[]>(
GUARDS_METADATA,
methodName
)?.includes(AccessGuard);
}
);
it('Refuses a currency in lower case', () => {
expect(parse([createActivity({ currency: 'usd' })])).toBe(false);
});
it('Accepts a currency in upper case', () => {
expect(parse([createActivity({ currency: 'USD' })])).toBe(true);
});
it('Refuses a date at or before the epoch', () => {
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false);
});
it('Refuses an empty symbol', () => {
expect(parse([createActivity({ symbol: '' })])).toBe(false);
expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]);
});
it('Refuses an empty identifier of an account', () => {
expect(parse([createActivity({ accountId: '' })])).toBe(false);
it('Requires the scope to create an activity for the tool to import activities', () => {
expect(
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities')
).toEqual([scopes.activityCreate]);
});
it('Removes a tag, because the tool takes no tag', () => {
it('Requires the scope to create an activity for the tool to search asset profiles', () => {
expect(
IMPORT_ACTIVITIES_PARAMETERS.parse({
activities: [createActivity({ tags: ['tag-id'] })]
}).activities[0]
).not.toHaveProperty('tags');
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'searchAssetProfiles')
).toEqual([scopes.activityCreate]);
});
it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => {
// The tools have no try and catch, hence the filter is the only guarantee
// that an unexpected exception does not expose internals
it('Applies the filter of the exceptions of the tools', () => {
expect(
parse(
Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => {
return createActivity();
})
)
).toBe(false);
});
Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, GhostfolioMcpController)
).toEqual([McpToolExceptionFilter]);
});
});

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

@ -1,156 +1,29 @@
import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service';
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator';
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DATE_RANGES,
DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACCOUNTS,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import {
isValidCurrencyCode,
isValidDateAfter1970
} from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { scopes } from '@ghostfolio/common/scopes';
import type { ImpersonationContext } from '@ghostfolio/common/types';
import { HttpException, Logger, UseFilters } from '@nestjs/common';
import { Payload, RpcException } from '@nestjs/microservices';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { UseFilters } from '@nestjs/common';
import { Payload } from '@nestjs/microservices';
import { McpController, Tool } from '@rekog/mcp-nest';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { z } from 'zod';
import 'zod/compile';
const GET_ACCOUNTS_PARAMETERS = z.object({
accountIds: z
.array(z.string().min(1))
.min(1)
.max(MCP_MAX_ACCOUNTS)
.optional()
.describe(
`The identifiers of the accounts to get, at most ${MCP_MAX_ACCOUNTS}`
),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the accounts to get'),
holding: z
.object({
dataSource: z
.enum(DataSource)
.describe('The data source of the asset profile'),
symbol: z.string().describe('The symbol of the asset profile')
})
.optional()
.describe('The asset profile of the accounts to get')
});
const GET_ACTIVITIES_PARAMETERS = z.object({
activityTypes: z
.array(z.enum(ActivityType))
.min(1)
.optional()
.describe('The types of the activities to get'),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the activities to get'),
holding: z
.object({
dataSource: z
.enum(DataSource)
.describe('The data source of the asset profile'),
symbol: z.string().describe('The symbol of the asset profile')
})
.optional()
.describe('The asset profile of the activities to get'),
range: z
.string()
.regex(DATE_RANGE_PATTERN)
.optional()
.describe(
`The date range of the activities to get, either ${DATE_RANGES.join(
', '
)} or a calendar year like 2024`
),
skip: z
.number()
.int()
.min(0)
.optional()
.describe('The number of activities to skip'),
take: z
.number()
.int()
.min(1)
.max(MCP_MAX_ACTIVITIES)
.optional()
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`)
});
export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
activities: z
.array(
z.object({
accountId: z
.string()
.min(1)
.optional()
.describe('The identifier of the account of the activity'),
comment: z.string().optional().describe('The comment of the activity'),
currency: z
.string()
.refine(isValidCurrencyCode)
.describe(
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case'
),
dataSource: z
.enum(DataSource)
.optional()
.describe('The data source of the asset profile'),
date: z
.string()
.refine(isValidDateAfter1970)
.describe(
'The date of the activity, as an ISO 8601 date or date and time'
),
fee: z.number().min(0).describe('The fee of the activity'),
quantity: z.number().min(0).describe('The quantity of the activity'),
symbol: z.string().min(1).describe('The symbol of the asset profile'),
type: z.enum(ActivityType).describe('The type of the activity'),
unitPrice: z.number().min(0).describe('The unit price of the activity')
})
)
.min(1)
.max(MCP_MAX_ACTIVITIES)
.describe(`The activities to import, at most ${MCP_MAX_ACTIVITIES}`)
});
import {
GET_ACCOUNTS_PARAMETERS,
GET_ACTIVITIES_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS
} from './mcp.schemas';
import { McpService } from './mcp.service';
@McpController()
@UseFilters(McpToolExceptionFilter)
export class GhostfolioMcpController {
private readonly logger = new Logger(GhostfolioMcpController.name);
public constructor(
private readonly aiService: AiService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
private readonly userService: UserService
) {}
public constructor(private readonly mcpService: McpService) {}
@RequiresScopeOfAccess(scopes.accountRead)
@Tool({
@ -159,7 +32,7 @@ export class GhostfolioMcpController {
readOnlyHint: true,
title: 'Get accounts'
},
description: `Gives the accounts of the portfolio with these columns: ${AiService.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.`,
name: 'get-accounts',
@ -167,23 +40,9 @@ export class GhostfolioMcpController {
})
public async getAccounts(
@Impersonation() { userId }: ImpersonationContext,
@Payload()
{
accountIds,
assetClasses,
holding
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
@Payload() parameters: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
) {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts: accountIds?.join(','),
filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.aiService.getAccountsTable({ filters, userId });
return { content: [{ text: table, type: 'text' as const }] };
return this.mcpService.getAccounts({ ...parameters, userId });
}
@RequiresScopeOfAccess(scopes.activityRead)
@ -193,52 +52,21 @@ export class GhostfolioMcpController {
readOnlyHint: true,
title: 'Get activities'
},
description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.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.`,
name: 'get-activities',
parameters: GET_ACTIVITIES_PARAMETERS
})
public async getActivities(
@Impersonation()
{ userId, userSettings }: ImpersonationContext,
@Payload()
{
activityTypes,
assetClasses,
holding,
range,
skip,
take
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
@Impersonation() { userId, userSettings }: ImpersonationContext,
@Payload() parameters: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
) {
let endDate: Date | undefined;
let startDate: Date | undefined;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.aiService.getActivitiesTable({
endDate,
filters,
skip,
startDate,
return this.mcpService.getActivities({
...parameters,
userId,
take: take ?? MCP_MAX_ACTIVITIES,
types: activityTypes,
userCurrency: userSettings.baseCurrency
});
return { content: [{ text: table, type: 'text' as const }] };
}
@RequiresScopeOfAccess(scopes.portfolioRead)
@ -248,22 +76,13 @@ export class GhostfolioMcpController {
readOnlyHint: true,
title: 'Get portfolio'
},
description: `Gives the holdings of the portfolio with these columns: ${AiService.getHoldingsTableColumnNames().join(
description: `Gives the holdings of the portfolio with these columns: ${PortfolioTableService.getHoldingsTableColumnNames().join(
', '
)}.`,
name: 'get-portfolio'
})
public async getPortfolio(
@Impersonation() { userId, userSettings }: ImpersonationContext
) {
const prompt = await this.aiService.getPrompt({
userId,
languageCode: DEFAULT_LANGUAGE_CODE,
mode: 'portfolio',
userCurrency: userSettings.baseCurrency
});
return { content: [{ text: prompt, type: 'text' as const }] };
public async getPortfolio(@Impersonation() { userId }: ImpersonationContext) {
return this.mcpService.getPortfolio({ userId });
}
/**
@ -280,73 +99,33 @@ export class GhostfolioMcpController {
readOnlyHint: false,
title: 'Import activities'
},
description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`,
description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. Use search-asset-profiles first unless the exact symbol and data source are already known. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`,
name: 'import-activities',
parameters: IMPORT_ACTIVITIES_PARAMETERS
})
public async importActivities(
@Impersonation() { userId }: ImpersonationContext,
@Payload() { activities }: z.infer<typeof IMPORT_ACTIVITIES_PARAMETERS>
@Payload() parameters: z.infer<typeof IMPORT_ACTIVITIES_PARAMETERS>
) {
const user = await this.userService.user({ id: userId });
if (!hasPermission(user?.permissions, permissions.createActivity)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
return this.mcpService.importActivities({ ...parameters, userId });
}
const ghostfolioDataSources = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
const activitiesDto = activities.map((activity) => {
return {
...activity,
dataSource: getUnmaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: activity.dataSource
@RequiresScopeOfAccess(scopes.activityCreate)
@Tool({
annotations: {
openWorldHint: true,
readOnlyHint: true,
title: 'Search asset profiles'
},
description:
'Searches for financial assets, such as stocks, ETFs, cryptocurrencies, mutual funds and commodities, which are available to the user. Each result is an asset profile that can be used to import an activity. Use this before importing an activity unless the exact symbol and data source are already known. Select the candidate that matches the intended asset and pass its symbol, dataSource and currency unchanged to import-activities.',
name: 'search-asset-profiles',
parameters: SEARCH_ASSET_PROFILES_PARAMETERS
})
};
});
let importedActivities: Activity[];
try {
importedActivities = await this.importService.import({
activitiesDto,
user,
accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [],
tagsDto: []
});
} catch (error) {
// The message of a validation names the activity which is not valid and
// is written for the caller, hence it is passed on
if (error instanceof ImportValidationError) {
throw new RpcException(error.message);
}
// Every other message can carry internals of the application, hence it
// is written to the log and the reason phrase is passed on instead
this.logger.error(error);
throw new RpcException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)
);
}
const text = [
`Imported activities: ${importedActivities.length}`,
`Skipped duplicate activities: ${
activities.length - importedActivities.length
}`
].join('\n');
return { content: [{ text, type: 'text' as const }] };
public async searchAssetProfiles(
@Impersonation() { userId }: ImpersonationContext,
@Payload() parameters: z.infer<typeof SEARCH_ASSET_PROFILES_PARAMETERS>
) {
return this.mcpService.searchAssetProfiles({ ...parameters, userId });
}
}

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

@ -1,10 +1,11 @@
import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module';
import { ImportModule } from '@ghostfolio/api/app/import/import.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { environment } from '@ghostfolio/api/environments/environment';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module';
import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { Module } from '@nestjs/common';
@ -15,11 +16,20 @@ import {
} from '@rekog/mcp-nest';
import { GhostfolioMcpController } from './mcp.controller';
import { McpService } from './mcp.service';
@Module({
controllers: [GhostfolioMcpController],
imports: [AiModule, ApiModule, ConfigurationModule, ImportModule, UserModule],
imports: [
ApiModule,
ConfigurationModule,
ImportModule,
PortfolioTableModule,
SymbolModule,
UserModule
],
providers: [
McpService,
{
inject: [ConfigurationService],
provide: MCP_STRATEGY,

91
apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts

@ -0,0 +1,91 @@
import {
MCP_MAX_ACTIVITIES,
SEARCH_QUERY_MAXIMUM_LENGTH,
SEARCH_QUERY_MINIMUM_LENGTH
} from '@ghostfolio/common/config';
import {
IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS
} from './mcp.schemas';
import { createActivity } from './mcp.test-utils';
describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
function parse(activities: unknown[]) {
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success;
}
it('Refuses a currency in lower case', () => {
expect(parse([createActivity({ currency: 'usd' })])).toBe(false);
});
it('Accepts a currency in upper case', () => {
expect(parse([createActivity({ currency: 'USD' })])).toBe(true);
});
it('Refuses a date at or before the epoch', () => {
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false);
});
it('Refuses an empty symbol', () => {
expect(parse([createActivity({ symbol: '' })])).toBe(false);
});
it('Refuses an empty identifier of an account', () => {
expect(parse([createActivity({ accountId: '' })])).toBe(false);
});
it('Removes a tag, because the tool takes no tag', () => {
expect(
IMPORT_ACTIVITIES_PARAMETERS.parse({
activities: [{ ...createActivity(), tags: ['tag-id'] }]
}).activities[0]
).not.toHaveProperty('tags');
});
it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => {
expect(
parse(
Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => {
return createActivity();
})
)
).toBe(false);
});
});
describe('SEARCH_ASSET_PROFILES_PARAMETERS', () => {
it('Refuses a query that contains only spaces', () => {
expect(
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query: ' ' }).success
).toBe(false);
});
it(`Refuses a query shorter than ${SEARCH_QUERY_MINIMUM_LENGTH} characters`, () => {
expect(
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query: 'A' }).success
).toBe(false);
});
it(`Refuses a query longer than ${SEARCH_QUERY_MAXIMUM_LENGTH} characters`, () => {
expect(
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({
query: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1)
}).success
).toBe(false);
});
it('Accepts a name, symbol or ISIN', () => {
for (const query of ['Apple', 'AAPL', 'US0378331005']) {
expect(
SEARCH_ASSET_PROFILES_PARAMETERS.safeParse({ query }).success
).toBe(true);
}
});
it('Removes spaces at the start and the end of a query', () => {
expect(
SEARCH_ASSET_PROFILES_PARAMETERS.parse({ query: ' Apple ' }).query
).toBe('Apple');
});
});

128
apps/api/src/app/endpoints/mcp/mcp.schemas.ts

@ -0,0 +1,128 @@
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import {
DATE_RANGES,
MCP_MAX_ACCOUNTS,
MCP_MAX_ACTIVITIES,
SEARCH_QUERY_MAXIMUM_LENGTH,
SEARCH_QUERY_MINIMUM_LENGTH
} from '@ghostfolio/common/config';
import {
isValidCurrencyCode,
isValidDateAfter1970
} from '@ghostfolio/common/helper';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { z } from 'zod';
const HOLDING_PARAMETER = z.object({
dataSource: z
.enum(DataSource)
.describe('The data source of the asset profile'),
symbol: z.string().describe('The symbol of the asset profile')
});
export const GET_ACCOUNTS_PARAMETERS = z.object({
accountIds: z
.array(z.string().min(1))
.min(1)
.max(MCP_MAX_ACCOUNTS)
.optional()
.describe(
`The identifiers of the accounts to get, at most ${MCP_MAX_ACCOUNTS}`
),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the accounts to get'),
holding: HOLDING_PARAMETER.optional().describe(
'The asset profile of the accounts to get'
)
});
export const GET_ACTIVITIES_PARAMETERS = z.object({
activityTypes: z
.array(z.enum(ActivityType))
.min(1)
.optional()
.describe('The types of the activities to get'),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the activities to get'),
holding: HOLDING_PARAMETER.optional().describe(
'The asset profile of the activities to get'
),
range: z
.string()
.regex(DATE_RANGE_PATTERN)
.optional()
.describe(
`The date range of the activities to get, either ${DATE_RANGES.join(
', '
)} or a calendar year like 2024`
),
skip: z
.number()
.int()
.min(0)
.optional()
.describe('The number of activities to skip'),
take: z
.number()
.int()
.min(1)
.max(MCP_MAX_ACTIVITIES)
.default(MCP_MAX_ACTIVITIES)
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`)
});
export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
activities: z
.array(
z.object({
accountId: z
.string()
.min(1)
.optional()
.describe('The identifier of the account of the activity'),
comment: z.string().optional().describe('The comment of the activity'),
currency: z
.string()
.refine(isValidCurrencyCode)
.describe(
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case'
),
dataSource: z
.enum(DataSource)
.optional()
.describe('The data source of the asset profile'),
date: z
.string()
.refine(isValidDateAfter1970)
.describe(
'The date of the activity, as an ISO 8601 date or date and time'
),
fee: z.number().min(0).describe('The fee of the activity'),
quantity: z.number().min(0).describe('The quantity of the activity'),
symbol: z.string().min(1).describe('The symbol of the asset profile'),
type: z.enum(ActivityType).describe('The type of the activity'),
unitPrice: z.number().min(0).describe('The unit price of the activity')
})
)
.min(1)
.max(MCP_MAX_ACTIVITIES)
.describe(`The activities to import, at most ${MCP_MAX_ACTIVITIES}`)
});
export const SEARCH_ASSET_PROFILES_PARAMETERS = z.object({
query: z
.string()
.trim()
.min(SEARCH_QUERY_MINIMUM_LENGTH)
.max(SEARCH_QUERY_MAXIMUM_LENGTH)
.describe(
'The name, ticker symbol or ISIN of the financial asset, for example Apple, AAPL, Bitcoin or US0378331005'
)
});

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

@ -0,0 +1,360 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import {
DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { UserWithSettings } from '@ghostfolio/common/types';
import { HttpException } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
Type as ActivityType
} from '@prisma/client';
import { McpService } from './mcp.service';
import { createActivity } from './mcp.test-utils';
describe('McpService', () => {
const filters: Filter[] = [{ id: 'account-id', type: 'ACCOUNT' }];
const userCurrency = 'USD';
const userId = 'user-id';
let apiService: ApiService;
let configuration: Record<string, unknown>;
let configurationService: ConfigurationService;
let importService: ImportService;
let mcpService: McpService;
let portfolioTableService: PortfolioTableService;
let symbolService: SymbolService;
let userService: UserService;
function setupUser(userPermissions: string[]) {
const user = {
id: userId,
permissions: userPermissions
} as UserWithSettings;
jest.spyOn(userService, 'user').mockResolvedValue(user);
return user;
}
beforeEach(() => {
configuration = {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [],
ENABLE_FEATURE_SUBSCRIPTION: false
};
configurationService = {
get: jest.fn().mockImplementation((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService;
apiService = {
buildFiltersFromQueryParams: jest.fn().mockReturnValue(filters)
} as unknown as ApiService;
importService = {
import: jest.fn().mockResolvedValue([])
} as unknown as ImportService;
portfolioTableService = {
getAccountsTable: jest.fn().mockResolvedValue('## Accounts'),
getActivitiesTable: jest.fn().mockResolvedValue('## Activities'),
getHoldingsTable: jest.fn().mockResolvedValue('## Holdings')
} as unknown as PortfolioTableService;
symbolService = {
lookup: jest.fn().mockResolvedValue({ items: [] })
} as unknown as SymbolService;
userService = { user: jest.fn() } as unknown as UserService;
mcpService = new McpService(
apiService,
configurationService,
importService,
portfolioTableService,
symbolService,
userService
);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getAccounts', () => {
it('Maps the parameters of the tool to the filters', async () => {
await mcpService.getAccounts({
userId,
accountIds: ['account-id'],
assetClasses: [AssetClass.EQUITY],
holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({
filterByAccounts: ['account-id'],
filterByAssetClasses: [AssetClass.EQUITY],
filterByDataSource: DataSource.YAHOO,
filterBySymbol: 'AAPL'
});
});
it('Gives the table of the accounts of the filters', async () => {
expect(await mcpService.getAccounts({ userId })).toEqual({
content: [{ text: '## Accounts', type: 'text' }]
});
expect(portfolioTableService.getAccountsTable).toHaveBeenCalledWith({
filters,
userId
});
});
});
describe('getActivities', () => {
function getActivities(
parameters: Partial<Parameters<McpService['getActivities']>[0]> = {}
) {
return mcpService.getActivities({
userCurrency,
userId,
take: MCP_MAX_ACTIVITIES,
...parameters
});
}
it('Maps the parameters of the tool to the filters', async () => {
await getActivities({
assetClasses: [AssetClass.EQUITY],
holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({
filterByAssetClasses: [AssetClass.EQUITY],
filterByDataSource: DataSource.YAHOO,
filterBySymbol: 'AAPL'
});
});
it('Changes the range into the start date and the end date', async () => {
await getActivities({ range: '2024' });
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith(
expect.objectContaining({
endDate: new Date('2024-12-31T23:59:59.999Z'),
startDate: new Date('2023-12-31T23:59:59.999Z')
})
);
});
it('Gives no date if the range is absent', async () => {
await getActivities();
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith(
expect.objectContaining({ endDate: undefined, startDate: undefined })
);
});
it('Gives the table of the activities of the filters', async () => {
expect(
await getActivities({ activityTypes: [ActivityType.BUY], skip: 10 })
).toEqual({ content: [{ text: '## Activities', type: 'text' }] });
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith({
filters,
userCurrency,
userId,
endDate: undefined,
skip: 10,
startDate: undefined,
take: MCP_MAX_ACTIVITIES,
types: [ActivityType.BUY]
});
});
});
describe('getPortfolio', () => {
it('Gives the table of the holdings in the default language', async () => {
expect(await mcpService.getPortfolio({ userId })).toEqual({
content: [{ text: '## Holdings', type: 'text' }]
});
expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({
userId,
languageCode: DEFAULT_LANGUAGE_CODE
});
});
});
describe('searchAssetProfiles', () => {
it('Refuses a user without the permission to create an activity', async () => {
setupUser([]);
await expect(
mcpService.searchAssetProfiles({ query: 'Apple', userId })
).rejects.toThrow(HttpException);
expect(symbolService.lookup).not.toHaveBeenCalled();
});
it('Gives the import-ready asset profiles available to the user', async () => {
const user = setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
jest.spyOn(symbolService, 'lookup').mockResolvedValue({
items: [
{
assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK,
currency: 'USD',
dataProviderInfo: { isPremium: false },
dataSource: DataSource.YAHOO,
name: 'Apple Inc.',
symbol: 'AAPL'
},
{
assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK,
currency: 'USD',
dataProviderInfo: { isPremium: true },
dataSource: DataSource.GHOSTFOLIO,
name: 'Premium asset',
symbol: 'PREMIUM'
}
]
});
const result = await mcpService.searchAssetProfiles({
query: 'Apple',
userId
});
expect(symbolService.lookup).toHaveBeenCalledWith({
query: 'Apple',
user
});
expect(JSON.parse(result.content[0].text)).toEqual({
assetProfiles: [
{
assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK,
currency: 'USD',
dataSource: DataSource.GHOSTFOLIO,
name: 'Apple Inc.',
symbol: 'AAPL'
}
]
});
});
});
describe('importActivities', () => {
it('Refuses a user without the permission to create an activity', async () => {
setupUser([]);
await expect(
mcpService.importActivities({
userId,
activities: [createActivity()]
})
).rejects.toThrow(HttpException);
expect(importService.import).not.toHaveBeenCalled();
});
it('Gives the number of the imported and of the skipped activities', async () => {
setupUser([permissions.createActivity]);
jest
.spyOn(importService, 'import')
.mockResolvedValue([{ id: 'activity-id' } as Activity]);
expect(
await mcpService.importActivities({
userId,
activities: [createActivity(), createActivity({ quantity: 2 })]
})
).toEqual({
content: [
{
text: 'Imported activities: 1\nSkipped duplicate activities: 1',
type: 'text'
}
]
});
});
it('Resolves the mask of the data source of the Ghostfolio data provider', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await mcpService.importActivities({
userId,
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.YAHOO })
]
})
);
});
it('Keeps the data source if the subscription is not enabled', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
await mcpService.importActivities({
userId,
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO })
]
})
);
});
// The McpToolExceptionFilter maps the error, hence the tool passes it on
it('Passes on an error of the import', async () => {
setupUser([permissions.createActivity]);
const error = new ImportValidationError(
'activities.0.symbol ("X") is not valid'
);
jest.spyOn(importService, 'import').mockRejectedValue(error);
await expect(
mcpService.importActivities({
userId,
activities: [createActivity()]
})
).rejects.toBe(error);
});
});
});

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

@ -0,0 +1,232 @@
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
getMaskedGhostfolioDataSource,
getUnmaskedGhostfolioDataSource
} from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { z } from 'zod';
import {
GET_ACCOUNTS_PARAMETERS,
GET_ACTIVITIES_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS,
SEARCH_ASSET_PROFILES_PARAMETERS
} from './mcp.schemas';
@Injectable()
export class McpService {
public constructor(
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
private readonly portfolioTableService: PortfolioTableService,
private readonly symbolService: SymbolService,
private readonly userService: UserService
) {}
public async getAccounts({
accountIds,
assetClasses,
holding,
userId
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS> & { userId: string }) {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts: accountIds,
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.portfolioTableService.getAccountsTable({
filters,
userId
});
return this.getTextResult(table);
}
public async getActivities({
activityTypes,
assetClasses,
holding,
range,
skip,
take,
userCurrency,
userId
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS> & {
userCurrency: string;
userId: string;
}) {
let endDate: Date | undefined;
let startDate: Date | undefined;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.portfolioTableService.getActivitiesTable({
endDate,
filters,
skip,
startDate,
take,
userCurrency,
userId,
types: activityTypes
});
return this.getTextResult(table);
}
public async getPortfolio({ userId }: { userId: string }) {
const table = await this.portfolioTableService.getHoldingsTable({
userId,
languageCode: DEFAULT_LANGUAGE_CODE
});
return this.getTextResult(table);
}
public async importActivities({
activities,
userId
}: z.infer<typeof IMPORT_ACTIVITIES_PARAMETERS> & { userId: string }) {
const user = await this.getUserWithPermission({
userId,
permission: permissions.createActivity
});
const ghostfolioDataSources = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
const activitiesDto = activities.map((activity) => {
return {
...activity,
dataSource: getUnmaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: activity.dataSource
})
};
});
// The filter passes on the message of a CallerFacingError, which is
// written for the caller, and hides the message of every other error
const importedActivities = await this.importService.import({
activitiesDto,
user,
accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [],
tagsDto: []
});
const text = [
`Imported activities: ${importedActivities.length}`,
`Skipped duplicate activities: ${
activities.length - importedActivities.length
}`
].join('\n');
return this.getTextResult(text);
}
public async searchAssetProfiles({
query,
userId
}: z.infer<typeof SEARCH_ASSET_PROFILES_PARAMETERS> & { userId: string }) {
const user = await this.getUserWithPermission({
userId,
permission: permissions.createActivity
});
const { items } = await this.symbolService.lookup({ query, user });
const ghostfolioDataSources = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
const assetProfiles = items.flatMap(
({
assetClass,
assetSubClass,
currency,
dataProviderInfo,
dataSource,
name,
symbol
}) => {
if (!dataSource || dataProviderInfo.isPremium) {
return [];
}
return [
{
assetClass,
assetSubClass,
currency,
name,
symbol,
dataSource: getMaskedGhostfolioDataSource({
dataSource,
ghostfolioDataSources
})
}
];
}
);
return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2));
}
private getTextResult(text: string) {
return { content: [{ text, type: 'text' as const }] };
}
/**
* Gives the user of the access, if the role of the user has the permission.
* The scope of the access is evaluated separately by the ScopeGuard, hence a
* tool which changes data has to call this.
*/
private async getUserWithPermission({
permission,
userId
}: {
permission: string;
userId: string;
}) {
const user = await this.userService.user({ id: userId });
if (!hasPermission(user?.permissions, permission)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return user;
}
}

18
apps/api/src/app/endpoints/mcp/mcp.test-utils.ts

@ -0,0 +1,18 @@
import { Type as ActivityType } from '@prisma/client';
import { ActivityToImport } from './types/activity-to-import.type';
export function createActivity(
overrides: Partial<ActivityToImport> = {}
): ActivityToImport {
return {
currency: 'USD',
date: '2024-01-01',
fee: 0,
quantity: 1,
symbol: 'AAPL',
type: ActivityType.BUY,
unitPrice: 100,
...overrides
};
}

7
apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts

@ -0,0 +1,7 @@
import { z } from 'zod';
import { IMPORT_ACTIVITIES_PARAMETERS } from '../mcp.schemas';
export type ActivityToImport = z.infer<
typeof IMPORT_ACTIVITIES_PARAMETERS
>['activities'][number];

4
apps/api/src/app/import/errors/import-validation.error.ts

@ -1,4 +1,6 @@
export class ImportValidationError extends Error {
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
export class ImportValidationError extends CallerFacingError {
public constructor(message: string) {
super(message);

13
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -3,6 +3,7 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
Activity,
Filter,
@ -34,6 +35,8 @@ export class PortfolioCalculatorFactory {
calculationType,
currency,
filters = [],
usePortfolioSnapshotCache = true,
subscriptionType,
userId
}: {
accountBalanceItems?: HistoricalDataItem[];
@ -41,6 +44,8 @@ export class PortfolioCalculatorFactory {
calculationType: PerformanceCalculationType;
currency: string;
filters?: Filter[];
usePortfolioSnapshotCache?: boolean;
subscriptionType?: SubscriptionType;
userId: string;
}): PortfolioCalculator {
switch (calculationType) {
@ -50,6 +55,8 @@ export class PortfolioCalculatorFactory {
activities,
currency,
filters,
usePortfolioSnapshotCache,
subscriptionType,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
@ -64,6 +71,8 @@ export class PortfolioCalculatorFactory {
activities,
currency,
filters,
usePortfolioSnapshotCache,
subscriptionType,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
@ -78,6 +87,8 @@ export class PortfolioCalculatorFactory {
activities,
currency,
filters,
usePortfolioSnapshotCache,
subscriptionType,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
@ -92,6 +103,8 @@ export class PortfolioCalculatorFactory {
activities,
currency,
filters,
usePortfolioSnapshotCache,
subscriptionType,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,

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

@ -20,6 +20,7 @@ import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
@ -89,7 +90,9 @@ export abstract class PortfolioCalculator {
private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>;
private startDate: Date;
private subscriptionType?: SubscriptionType;
private transactionPoints: TransactionPoint[];
private usePortfolioSnapshotCache: boolean;
private userId: string;
public constructor({
@ -102,6 +105,8 @@ export abstract class PortfolioCalculator {
filters,
portfolioSnapshotService,
redisCacheService,
usePortfolioSnapshotCache = true,
subscriptionType,
userId
}: {
accountBalanceItems: HistoricalDataItem[];
@ -113,6 +118,8 @@ export abstract class PortfolioCalculator {
filters: Filter[];
portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService;
usePortfolioSnapshotCache?: boolean;
subscriptionType?: SubscriptionType;
userId: string;
}) {
this.accountBalanceItems = accountBalanceItems;
@ -175,6 +182,8 @@ export abstract class PortfolioCalculator {
this.portfolioSnapshotService = portfolioSnapshotService;
this.redisCacheService = redisCacheService;
this.usePortfolioSnapshotCache = usePortfolioSnapshotCache;
this.subscriptionType = subscriptionType;
this.userId = userId;
const { endDate, startDate } = getIntervalFromDateRange({
@ -187,7 +196,11 @@ export abstract class PortfolioCalculator {
this.computeTransactionPoints();
this.snapshotPromise = this.initialize();
this.snapshotPromise = this.usePortfolioSnapshotCache
? this.initialize()
: this.computeSnapshot().then((snapshot) => {
this.snapshot = snapshot;
});
// Mark the rejection as handled to prevent an unhandled promise rejection
// in case the snapshot promise is never awaited. Consumers awaiting it
@ -278,7 +291,8 @@ export abstract class PortfolioCalculator {
dateQuery: {
gte: this.startDate,
lt: this.endDate
}
},
subscriptionType: this.subscriptionType
});
this.dataProviderInfos = dataProviderInfos;

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

@ -59,7 +59,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -59,7 +59,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

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

@ -59,7 +59,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

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

@ -59,7 +59,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts

@ -81,7 +81,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -69,7 +69,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -71,7 +71,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-before-first-market-price.spec.ts

@ -57,7 +57,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

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

@ -69,7 +69,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

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

@ -69,7 +69,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -118,7 +118,6 @@ describe('PortfolioCalculator', () => {
currentRateService = new CurrentRateService(
null,
dataProviderService,
null,
null
);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -59,7 +59,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -71,7 +71,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts

@ -72,7 +72,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -59,7 +59,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-before-first-market-price.spec.ts

@ -57,7 +57,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -56,7 +56,8 @@ describe('PortfolioCalculator', () => {
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-from-two-data-sources.spec.ts

@ -71,7 +71,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -59,7 +59,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts

@ -54,7 +54,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -72,7 +72,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -72,7 +72,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts

@ -60,7 +60,8 @@ describe('RoaiPortfolioCalculator stock splits', () => {
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,

8
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -59,7 +59,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
@ -108,10 +108,11 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
usePortfolioSnapshotCache: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('500000'),
@ -169,6 +170,9 @@ describe('PortfolioCalculator', () => {
totalInvestmentValueWithCurrencyEffect: 500000
})
);
expect(PortfolioSnapshotServiceMock.jobsStore.size).toBe(0);
expect(RedisCacheServiceMock.cache.size).toBe(0);
});
});
});

3
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -119,8 +119,7 @@ describe('CurrentRateService', () => {
currentRateService = new CurrentRateService(
null,
dataProviderService,
marketDataService,
null
marketDataService
);
});

15
apps/api/src/app/portfolio/current-rate.service.ts

@ -11,10 +11,8 @@ import {
DataProviderInfo,
ResponseError
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
import { isEmpty, uniqBy } from 'lodash';
@ -29,15 +27,14 @@ export class CurrentRateService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
private readonly marketDataService: MarketDataService
) {}
@LogPerformance
// TODO: Pass user instead of using this.request.user
public async getValues({
dataGatheringItems,
dateQuery
dateQuery,
subscriptionType
}: GetValuesParams): Promise<GetValuesObject> {
const dataProviderInfos: DataProviderInfo[] = [];
@ -52,8 +49,8 @@ export class CurrentRateService {
if (includesToday) {
const quotes = await this.dataProviderService.getQuotes({
items: dataGatheringItems,
user: this.request?.user
subscriptionType,
items: dataGatheringItems
});
for (const { dataSource, symbol } of dataGatheringItems) {

4
apps/api/src/app/portfolio/get-holdings.dto.ts

@ -1,7 +1,8 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { HoldingType } from '@ghostfolio/common/types';
import { IsIn, IsOptional, IsString } from 'class-validator';
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
export class GetHoldingsDto extends DateRangeFilterDto {
@IsIn(['ACTIVE', 'CLOSED'] as HoldingType[])
@ -10,5 +11,6 @@ export class GetHoldingsDto extends DateRangeFilterDto {
@IsOptional()
@IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query?: string;
}

2
apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts

@ -1,8 +1,10 @@
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { DateQuery } from './date-query.interface';
export interface GetValuesParams {
dataGatheringItems: DataGatheringItem[];
dateQuery: DateQuery;
subscriptionType?: SubscriptionType;
}

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

@ -357,7 +357,8 @@ export class PortfolioController {
const holding = await this.portfolioService.getHolding({
dataSource,
symbol,
userId
userId,
withExcludedActivities: true
});
if (!holding) {
@ -631,7 +632,8 @@ export class PortfolioController {
const holding = await this.portfolioService.getHolding({
dataSource,
symbol,
userId
userId,
withExcludedActivities: true
});
if (!holding) {

282
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -9,9 +9,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TAG_ID_EMERGENCY_FUND, UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
TAG_ID_EMERGENCY_FUND,
TAG_ID_EXCLUDE_FROM_ANALYSIS,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier,
Filter,
PortfolioSummary
@ -350,8 +355,13 @@ describe('PortfolioService', () => {
describe('getDetails', () => {
const setUpCashOnlyPortfolio = ({
baseCurrency = 'CHF',
emergencyFund
}: { baseCurrency?: string; emergencyFund?: number } = {}) => {
emergencyFund,
quantity = 2000
}: {
baseCurrency?: string;
emergencyFund?: number;
quantity?: number;
} = {}) => {
const cashAccount: AccountWithBalance = {
balance: 2000,
comment: null,
@ -416,7 +426,7 @@ describe('PortfolioService', () => {
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
quantity: new Big(2000),
quantity: new Big(quantity),
symbol: 'USD',
tags: [],
timeWeightedInvestment: new Big(0),
@ -488,6 +498,270 @@ describe('PortfolioService', () => {
expect(holdings[0].assetProfile.symbol).toBe('USD');
expect(holdings[0].valueInBaseCurrency).toBe(1000);
});
it('should include closed holdings when all holdings are requested', async () => {
setUpCashOnlyPortfolio({ quantity: 0 });
const { holdings } = await portfolioService.getDetails({
filters: [],
includeAllHoldings: true,
userId: userDummyData.id
});
expect(holdings).toHaveLength(1);
expect(holdings[0].quantity).toBe(0);
});
it.each([
{ holdingType: 'ACTIVE', quantity: 2000 },
{ holdingType: 'CLOSED', quantity: 0 }
])(
'should return $holdingType holdings when the holding type is specified',
async ({ holdingType, quantity }) => {
setUpCashOnlyPortfolio({ quantity });
const { holdings } = await portfolioService.getDetails({
filters: [{ id: holdingType, type: 'HOLDING_TYPE' }],
userId: userDummyData.id
});
expect(holdings).toHaveLength(1);
expect(holdings[0].quantity).toBe(quantity);
}
);
it('should remove the holding type only from the snapshot filters', async () => {
setUpCashOnlyPortfolio({ quantity: 0 });
await portfolioService.getDetails({
filters: [
{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' },
{ id: 'CLOSED', type: 'HOLDING_TYPE' }
],
userId: userDummyData.id
});
expect(portfolioCalculatorFactory.createCalculator).toHaveBeenCalledWith(
expect.objectContaining({
filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }]
})
);
expect(
activitiesService.getActivitiesForPortfolioCalculator
).toHaveBeenCalledWith({
filters: [{ id: AssetClass.EQUITY, type: 'ASSET_CLASS' }],
userCurrency: 'CHF',
userId: userDummyData.id
});
});
});
describe('getHoldings', () => {
const activeHolding = {
assetProfile: {
isin: 'US0378331005',
name: 'Apple',
symbol: 'AAPL'
},
quantity: 1
};
const closedHolding = {
assetProfile: {
isin: 'US5949181045',
name: 'Microsoft',
symbol: 'MSFT'
},
quantity: 0
};
beforeEach(() => {
jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({
holdings: [activeHolding, closedHolding]
} as unknown as Awaited<ReturnType<typeof portfolioService.getDetails>>);
});
it('should request all holdings when the holding type is not specified', async () => {
const holdings = await portfolioService.getHoldings({
dateRange: 'max',
userId: userDummyData.id
});
expect(holdings).toEqual([activeHolding, closedHolding]);
expect(portfolioService.getDetails).toHaveBeenCalledWith({
dateRange: 'max',
filters: undefined,
includeAllHoldings: true,
userId: userDummyData.id
});
});
it('should find a closed holding when the holding type is not specified', async () => {
const holdings = await portfolioService.getHoldings({
dateRange: 'max',
filters: [{ id: 'Microsoft', type: 'SEARCH_QUERY' }],
userId: userDummyData.id
});
expect(holdings).toEqual([closedHolding]);
});
it.each(['ACTIVE', 'CLOSED'])(
'should not request all holdings when the holding type is %s',
async (holdingType) => {
await portfolioService.getHoldings({
dateRange: 'max',
filters: [{ id: holdingType, type: 'HOLDING_TYPE' }],
userId: userDummyData.id
});
expect(portfolioService.getDetails).toHaveBeenCalledWith({
dateRange: 'max',
filters: [{ id: holdingType, type: 'HOLDING_TYPE' }],
includeAllHoldings: false,
userId: userDummyData.id
});
}
);
});
describe('getHolding', () => {
const dataSource = DataSource.YAHOO;
const symbol = 'AAPL';
const includedActivity = {
assetProfile: { dataSource, symbol },
tags: []
} as Activity;
beforeEach(() => {
jest.spyOn(userService, 'user').mockResolvedValue({
id: userDummyData.id,
settings: { settings: { baseCurrency: 'USD' } }
} as unknown as Awaited<ReturnType<typeof userService.user>>);
jest
.spyOn(symbolProfileService, 'getSymbolProfiles')
.mockResolvedValue([]);
jest
.spyOn(portfolioCalculatorFactory, 'createCalculator')
.mockReturnValue({
getSnapshot: jest.fn().mockResolvedValue({ positions: [] }),
getTransactionPoints: jest.fn().mockReturnValue([])
} as unknown as PortfolioCalculator);
});
it('keeps the cached path when the holding has included and excluded activities', async () => {
const excludedActivity = {
...includedActivity,
tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }]
} as Activity;
const getActivities = jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValueOnce({ activities: [includedActivity], count: 1 })
.mockResolvedValue({
activities: [includedActivity, excludedActivity],
count: 2
});
await portfolioService.getHolding({
dataSource,
symbol,
userId: userDummyData.id,
withExcludedActivities: true
});
expect(getActivities).toHaveBeenCalledTimes(1);
expect(getActivities).toHaveBeenCalledWith({
userCurrency: 'USD',
userId: userDummyData.id
});
expect(portfolioCalculatorFactory.createCalculator).toHaveBeenCalledWith(
expect.objectContaining({
activities: [includedActivity],
filters: undefined,
usePortfolioSnapshotCache: true
})
);
});
it.each([
{
account: { tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] },
name: 'account',
tags: []
},
{
account: { tags: [] },
name: 'activity tag',
tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }]
}
])(
'uses the direct path for a holding excluded by its $name',
async ({ account, tags }) => {
const excludedActivity = {
...includedActivity,
account,
tags
} as Activity;
const getActivities = jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValueOnce({ activities: [], count: 0 })
.mockResolvedValueOnce({
activities: [excludedActivity],
count: 1
});
await portfolioService.getHolding({
dataSource,
symbol,
userId: userDummyData.id,
withExcludedActivities: true
});
expect(getActivities).toHaveBeenCalledTimes(2);
expect(getActivities).toHaveBeenLastCalledWith({
filters: [
{ id: dataSource, type: 'DATA_SOURCE' },
{ id: symbol, type: 'SYMBOL' }
],
userCurrency: 'USD',
userId: userDummyData.id,
withExcludedAccountsAndActivities: true
});
expect(
portfolioCalculatorFactory.createCalculator
).toHaveBeenCalledWith(
expect.objectContaining({
activities: [excludedActivity],
filters: [
{ id: dataSource, type: 'DATA_SOURCE' },
{ id: symbol, type: 'SYMBOL' }
],
usePortfolioSnapshotCache: false
})
);
}
);
it('does not load excluded activities by default', async () => {
const getActivities = jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({ activities: [], count: 0 });
const holding = await portfolioService.getHolding({
dataSource,
symbol,
userId: userDummyData.id
});
expect(holding).toBeUndefined();
expect(getActivities).toHaveBeenCalledTimes(1);
expect(
portfolioCalculatorFactory.createCalculator
).not.toHaveBeenCalled();
});
});
describe('getSummary', () => {

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

@ -477,12 +477,12 @@ export class PortfolioService {
filters?: Filter[];
userId: string;
}) {
const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy(
filters,
({ type }) => {
const {
HOLDING_TYPE: [filterByHoldingType] = [],
SEARCH_QUERY: [filterBySearchQuery] = []
} = groupBy(filters, ({ type }) => {
return type;
}
);
});
const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY';
@ -491,7 +491,8 @@ export class PortfolioService {
let { holdings } = await this.getDetails({
dateRange,
userId,
filters: filtersWithoutSearchQueryFilter
filters: filtersWithoutSearchQueryFilter,
includeAllHoldings: !filterByHoldingType
});
if (filterBySearchQuery) {
@ -545,7 +546,8 @@ export class PortfolioService {
filters,
userId,
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
currency: userCurrency,
subscriptionType: user.subscription?.type
});
const { historicalData } = await portfolioCalculator.getSnapshot();
@ -592,6 +594,7 @@ export class PortfolioService {
public async getDetails({
dateRange = DEFAULT_DATE_RANGE,
filters,
includeAllHoldings = false,
user: userFromCaller,
userId,
withExcludedAccounts = false,
@ -600,6 +603,7 @@ export class PortfolioService {
}: {
dateRange?: DateRange;
filters?: Filter[];
includeAllHoldings?: boolean;
user?: UserWithSettings;
userId: string;
withExcludedAccounts?: boolean;
@ -614,19 +618,24 @@ export class PortfolioService {
(user.settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const portfolioSnapshotFilters = filters?.filter(({ type }) => {
return type !== 'HOLDING_TYPE';
});
const { activities } =
await this.activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency,
userId
userId,
filters: portfolioSnapshotFilters
});
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
filters,
userId,
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
currency: userCurrency,
filters: portfolioSnapshotFilters,
subscriptionType: user.subscription?.type
});
const { createdAt, currentValueInBaseCurrency, hasErrors, positions } =
@ -706,13 +715,13 @@ export class PortfolioService {
tags,
valueInBaseCurrency
} of positions) {
if (isFilteredByClosedHoldings === true) {
if (!quantity.eq(0)) {
if (!includeAllHoldings) {
if (isFilteredByClosedHoldings && !quantity.eq(0)) {
// Ignore positions with a quantity
continue;
}
} else {
if (quantity.eq(0)) {
if (!isFilteredByClosedHoldings && quantity.eq(0)) {
// Ignore positions without any quantity
continue;
}
@ -888,20 +897,47 @@ export class PortfolioService {
public async getHolding({
dataSource,
symbol,
userId
userId,
withExcludedActivities = false
}: {
userId: string;
withExcludedActivities?: boolean;
} & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> {
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { activities } =
const holdingFilters: Filter[] = [
{ id: dataSource, type: 'DATA_SOURCE' },
{ id: symbol, type: 'SYMBOL' }
];
let { activities } =
await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency,
userId
});
if (activities.length === 0) {
let hasActivitiesOfHolding = activities.some(({ assetProfile }) => {
return (
assetProfile.dataSource === dataSource && assetProfile.symbol === symbol
);
});
const isExcludedHolding = withExcludedActivities && !hasActivitiesOfHolding;
if (isExcludedHolding) {
({ activities } =
await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency,
userId,
filters: holdingFilters,
withExcludedAccountsAndActivities: true
}));
hasActivitiesOfHolding = activities.length > 0;
}
if (!hasActivitiesOfHolding) {
return undefined;
}
@ -927,7 +963,10 @@ export class PortfolioService {
activities,
userId,
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
currency: userCurrency,
filters: isExcludedHolding ? holdingFilters : undefined,
usePortfolioSnapshotCache: !isExcludedHolding,
subscriptionType: user.subscription?.type
});
const transactionPoints = portfolioCalculator.getTransactionPoints();
@ -1094,7 +1133,9 @@ export class PortfolioService {
userId: assetProfile.userId
},
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dataProviderInfo: this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
dividendYieldPercent: dividendYieldPercent.toNumber(),
dividendYieldPercentWithCurrencyEffect:
@ -1180,7 +1221,8 @@ export class PortfolioService {
filters,
userId,
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
currency: userCurrency,
subscriptionType: user.subscription?.type
});
const { errors, hasErrors, historicalData } =
@ -2031,12 +2073,7 @@ export class PortfolioService {
const nonExcludedActivities: Activity[] = [];
for (const activity of activities) {
if (
(activity.account && isAccountExcluded(activity.account)) ||
activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
})
) {
if (this.isExcludedFromAnalysis(activity)) {
excludedActivities.push(activity);
} else {
nonExcludedActivities.push(activity);
@ -2416,4 +2453,13 @@ export class PortfolioService {
return { accounts, platforms };
}
private isExcludedFromAnalysis(activity: Activity) {
return (
isAccountExcluded(activity.account) ||
activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) === true
);
}
}

5
apps/api/src/app/subscription/subscription.controller.ts

@ -1,4 +1,5 @@
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -46,7 +47,7 @@ export class SubscriptionController {
@Post('redeem-coupon')
@HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseGuards(AuthGuard('jwt'), CustomThrottlerGuard, HasPermissionGuard)
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
if (!this.request.user) {
throw new HttpException(
@ -119,7 +120,7 @@ export class SubscriptionController {
}
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseGuards(AuthGuard('jwt'), CustomThrottlerGuard, HasPermissionGuard)
public async createStripeCheckoutSession(
@Body() { couponId, priceId }: { couponId?: string; priceId: string }
): Promise<CreateStripeCheckoutSessionResponse> {

14
apps/api/src/app/subscription/subscription.service.ts

@ -17,7 +17,7 @@ import {
UserWithSettings
} from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Prisma, Subscription } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns';
import ms, { StringValue } from 'ms';
@ -64,6 +64,14 @@ export class SubscriptionService {
}
);
if (!subscriptionOffer) {
throw new BadRequestException('Invalid priceId');
}
if (couponId && couponId !== subscriptionOffer.couponId) {
throw new BadRequestException('Invalid couponId');
}
const stripeCheckoutSessionCreateParams: Stripe.Checkout.SessionCreateParams =
{
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
@ -77,9 +85,7 @@ export class SubscriptionService {
}
],
locale: this.getStripeLocale(user.settings?.settings?.language),
metadata: subscriptionOffer
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
: {},
metadata: { subscriptionOffer: JSON.stringify(subscriptionOffer) },
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(

16
apps/api/src/app/symbol/get-lookup.dto.ts

@ -0,0 +1,16 @@
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean, IsString, MaxLength } from 'class-validator';
export class GetLookupDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
includeIndices? = false;
@IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query? = '';
}

6
apps/api/src/app/symbol/symbol.controller.ts

@ -27,6 +27,7 @@ import { isValid, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash';
import { GetLookupDto } from './get-lookup.dto';
import { SymbolService } from './symbol.service';
@Controller('symbol')
@ -43,11 +44,8 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
@Query() { includeIndices, query }: GetLookupDto
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
try {
return this.symbolService.lookup({
includeIndices,

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

@ -275,7 +275,7 @@ export class UserService {
}
try {
const { isBlocked } = await this.throttlerStorage.increment(
const { totalHits } = await this.throttlerStorage.increment(
`${THROTTLE_DAILY_KEY}-${user.id}`,
THROTTLE_DAILY_TTL,
maxDailyRequests,
@ -283,7 +283,7 @@ export class UserService {
THROTTLE_DAILY_KEY
);
return isBlocked;
return totalHits > maxDailyRequests;
} catch (error) {
this.logger.error(error);

39
apps/api/src/dtos/filter.dto.ts

@ -1,23 +1,44 @@
import { IsOptional, IsString } from 'class-validator';
import { SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { AssetClass, DataSource } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength
} from 'class-validator';
import { isString } from 'lodash';
export class FilterDto {
@IsOptional()
@IsString()
accounts?: string;
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
accounts?: string[];
@IsEnum(AssetClass, { each: true })
@IsOptional()
@IsString()
assetClasses?: string;
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
assetClasses?: AssetClass[];
@IsEnum(DataSource)
@IsOptional()
@IsString()
dataSource?: string;
dataSource?: DataSource;
@IsOptional()
@IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH)
symbol?: string;
@IsOptional()
@IsString()
tags?: string;
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
tags?: string[];
}

12
apps/api/src/errors/caller-facing.error.ts

@ -0,0 +1,12 @@
/**
* An error whose message is written for the caller. A filter passes such a
* message on, while it hides the message of every other error, because that
* message can carry internals of the application.
*/
export class CallerFacingError extends Error {
public constructor(message: string) {
super(message);
this.name = 'CallerFacingError';
}
}

83
apps/api/src/filters/mcp-tool-exception.filter.spec.ts

@ -0,0 +1,83 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { ForbiddenException, Logger } from '@nestjs/common';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { firstValueFrom } from 'rxjs';
import { McpToolExceptionFilter } from './mcp-tool-exception.filter';
describe('McpToolExceptionFilter', () => {
let filter: McpToolExceptionFilter;
let logError: jest.SpyInstance;
async function getErrorOfException(exception: unknown) {
try {
await firstValueFrom(filter.catch(exception));
} catch (error) {
return error;
}
throw new Error('The filter gave no error');
}
beforeEach(() => {
filter = new McpToolExceptionFilter();
logError = jest.spyOn(Logger.prototype, 'error').mockImplementation();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('Passes on the message of an error which is written for the caller', async () => {
const exception = new ImportValidationError(
'activities.0.symbol ("X") is not valid'
);
expect(await getErrorOfException(exception)).toEqual({
message: 'activities.0.symbol ("X") is not valid',
status: 'error'
});
expect(logError).not.toHaveBeenCalled();
});
it('Hides the message of an unexpected error and writes it to the log', async () => {
const exception = new Error(
'Unique constraint failed on the fields: (dataSource)'
);
expect(await getErrorOfException(exception)).toEqual({
message: getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
status: 'error'
});
expect(logError).toHaveBeenCalledWith(exception);
});
// An access without the scope of a tool causes a refused call at each
// attempt, which would fill the log
it('Gives the reason phrase of the status of an HttpException and writes no log', async () => {
expect(await getErrorOfException(new ForbiddenException())).toEqual({
message: getReasonPhrase(StatusCodes.FORBIDDEN),
status: 'error'
});
expect(logError).not.toHaveBeenCalled();
});
it('Gives the reason phrase of a service which is not available if a snapshot cannot be computed', async () => {
const exception = new PortfolioSnapshotComputationError(
'The snapshot cannot be computed'
);
expect(await getErrorOfException(exception)).toEqual({
message: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
status: 'error'
});
expect(logError).toHaveBeenCalledWith(exception);
});
});

28
apps/api/src/filters/mcp-tool-exception.filter.ts

@ -1,4 +1,5 @@
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
import {
Catch,
@ -6,7 +7,6 @@ import {
Logger,
RpcExceptionFilter
} from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { Observable, throwError } from 'rxjs';
@ -20,24 +20,32 @@ export class McpToolExceptionFilter implements RpcExceptionFilter {
private readonly logger = new Logger(McpToolExceptionFilter.name);
public catch(exception: unknown): Observable<never> {
this.logger.error(exception);
if (exception instanceof RpcException) {
// The message of this exception is written for the caller, hence it is
// passed on and is not written to the log
if (exception instanceof CallerFacingError) {
return throwError(() => {
return exception.getError();
return { message: exception.message, status: 'error' };
});
}
return throwError(() => {
return { message: this.getMessage(exception), status: 'error' };
});
const statusCode = this.getStatus(exception);
// An exception which the caller causes, for example a refused call, is
// expected, hence only an exception of the application is written to the
// log
if (statusCode >= StatusCodes.INTERNAL_SERVER_ERROR) {
this.logger.error(exception);
}
private getMessage(exception: unknown) {
// The message of an exception can carry internals, for example the
// property names of a data transfer object of a failed validation, hence
// the reason phrase of the status is passed on instead
return this.getReasonPhraseOfStatus(this.getStatus(exception));
return throwError(() => {
return {
message: this.getReasonPhraseOfStatus(statusCode),
status: 'error'
};
});
}
private getReasonPhraseOfStatus(statusCode: number) {

6
apps/api/src/helper/interfaces/table-column-definition.interface.ts

@ -0,0 +1,6 @@
import type { ColumnDescriptor } from 'tablemark';
export interface TableColumnDefinition<T, C = void> extends ColumnDescriptor {
getValue: (item: T, context: C) => string;
name: string;
}

7
apps/api/src/helper/interfaces/table-parameters.interface.ts

@ -0,0 +1,7 @@
import { TableColumnDefinition } from './table-column-definition.interface';
export interface TableParameters<T, C> {
columnDefinitions: readonly TableColumnDefinition<T, C>[];
context?: C;
rows: readonly T[];
}

77
apps/api/src/helper/markdown-table.helper.spec.ts

@ -0,0 +1,77 @@
import { TableColumnDefinition } from './interfaces/table-column-definition.interface';
import { getTableInput } from './markdown-table.helper';
interface Holding {
name: string;
quantity: number;
}
interface HoldingContext {
currency: string;
}
describe('getTableInput', () => {
const holdings: Holding[] = [
{ name: 'Apple', quantity: 2 },
{ name: 'Microsoft', quantity: 30 }
];
const columnDefinitions: TableColumnDefinition<Holding, HoldingContext>[] = [
{
getValue: ({ name }) => {
return name;
},
name: 'Name'
},
{
align: 'right',
getValue: ({ quantity }) => {
return quantity.toString();
},
name: 'Quantity'
},
{
getValue: (_, { currency }) => {
return currency;
},
name: 'Currency'
}
];
function getInput(rows: Holding[] = holdings) {
return getTableInput({
columnDefinitions,
rows,
context: { currency: 'USD' }
});
}
it('Gives a column for each definition, in the sequence of the definitions', () => {
expect(getInput().columns).toEqual([
{ align: 'left', name: 'Name' },
{ align: 'right', name: 'Quantity' },
{ align: 'left', name: 'Currency' }
]);
});
it('Gives a row for each row, with the name of the column as the key', () => {
expect(getInput().rows).toEqual([
{ Currency: 'USD', Name: 'Apple', Quantity: '2' },
{ Currency: 'USD', Name: 'Microsoft', Quantity: '30' }
]);
});
it('Gives the keys of a row in the sequence of the columns', () => {
const [firstRow] = getInput().rows;
expect(Object.keys(firstRow)).toEqual(
getInput().columns.map(({ name }) => {
return name;
})
);
});
it('Gives no row if there is no row', () => {
expect(getInput([]).rows).toEqual([]);
});
});

44
apps/api/src/helper/markdown-table.helper.ts

@ -0,0 +1,44 @@
import { TableParameters } from './interfaces/table-parameters.interface';
/**
* Gives the columns and the rows in the form which the renderer takes, with
* one column per definition, which gives the title of the column and reads
* the value of a row
*/
export function getTableInput<T, C = void>({
columnDefinitions,
context,
rows
}: TableParameters<T, C>) {
return {
columns: columnDefinitions.map(({ align, name }) => {
return { name, align: align ?? 'left' };
}),
rows: rows.map((row) => {
return columnDefinitions.reduce(
(tableRow, { getValue, name }) => {
tableRow[name] = getValue(row, context);
return tableRow;
},
{} as Record<string, string>
);
})
};
}
export async function getMarkdownTable<T, C = void>(
parameters: TableParameters<T, C>
) {
const { columns, rows } = getTableInput(parameters);
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
const { tablemark } = await dynamicImport('tablemark');
return tablemark(rows, { columns });
}

2
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -70,6 +70,8 @@ export class TransformDataSourceInResponseInterceptor<
}
}
data.dataProviderInfo = undefined;
if (Object.keys(valueMap).length === 0) {
return data;
}

18
apps/api/src/services/api/api.service.ts

@ -14,23 +14,23 @@ export class ApiService {
filterBySymbol,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByAccounts?: string[];
filterByAssetClasses?: string[];
filterByAssetSubClasses?: string;
filterByDataSource?: string;
filterByHoldingType?: string;
filterBySearchQuery?: string;
filterBySymbol?: string;
filterByTags?: string;
filterByTags?: string[];
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const accountIds = filterByAccounts ?? [];
const assetClasses = filterByAssetClasses ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const dataSource = filterByDataSource;
const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase();
const symbol = filterBySymbol;
const tagIds = filterByTags?.split(',') ?? [];
const tagIds = filterByTags ?? [];
const filters = [
...accountIds.map((accountId) => {
@ -96,11 +96,11 @@ export class ApiService {
userSettings: UserSettings;
}): Filter[] {
return this.buildFiltersFromQueryParams({
filterByAccounts: userSettings?.['filters.accounts']?.[0],
filterByAssetClasses: userSettings?.['filters.assetClasses']?.[0],
filterByAccounts: userSettings?.['filters.accounts'],
filterByAssetClasses: userSettings?.['filters.assetClasses'],
filterByDataSource: userSettings?.['filters.dataSource'],
filterBySymbol: userSettings?.['filters.symbol'],
filterByTags: userSettings?.['filters.tags']?.[0]
filterByTags: userSettings?.['filters.tags']
});
}
}

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

@ -594,13 +594,13 @@ export class DataProviderService implements OnModuleInit {
public async getQuotes({
items,
requestTimeout,
useCache = true,
user
subscriptionType,
useCache = true
}: {
items: AssetProfileIdentifier[];
requestTimeout?: number;
subscriptionType?: SubscriptionType;
useCache?: boolean;
user?: UserWithSettings;
}): Promise<{
[assetProfileIdentifier: string]: DataProviderResponse;
}> {
@ -687,7 +687,7 @@ export class DataProviderService implements OnModuleInit {
} else if (
dataProvider.getDataProviderInfo().isPremium &&
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user?.subscription?.type === SubscriptionType.Basic
subscriptionType === SubscriptionType.Basic
) {
// Skip symbols of Premium data providers for users without subscription
return false;
@ -725,6 +725,10 @@ export class DataProviderService implements OnModuleInit {
promises.push(
promise.then(async (result) => {
const fetchedQuotes: (DataProviderResponse & {
symbol: string;
})[] = [];
for (const [symbol, dataProviderResponse] of Object.entries(
result
)) {
@ -739,12 +743,16 @@ export class DataProviderService implements OnModuleInit {
continue;
}
const quote = { ...dataProviderResponse, symbol };
fetchedQuotes.push(quote);
response[
getAssetProfileIdentifier({
symbol,
dataSource: DataSource[dataSource]
})
] = { ...dataProviderResponse, symbol };
] = quote;
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
@ -772,15 +780,19 @@ export class DataProviderService implements OnModuleInit {
marketState: 'open'
};
const derivedQuote = {
...derivedDataProviderResponse,
symbol: `${DEFAULT_CURRENCY}${currency}`
};
fetchedQuotes.push(derivedQuote);
response[
getAssetProfileIdentifier({
dataSource: DataSource[dataSource],
symbol: `${DEFAULT_CURRENCY}${currency}`
})
] = {
...derivedDataProviderResponse,
symbol: `${DEFAULT_CURRENCY}${currency}`
};
] = derivedQuote;
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
@ -805,7 +817,7 @@ export class DataProviderService implements OnModuleInit {
try {
await this.marketDataService.updateMany({
data: Object.values(response)
data: fetchedQuotes
.filter(({ marketPrice, marketState }) => {
return (
isNumber(marketPrice) &&

2
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -54,6 +54,8 @@ export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit
{
private static countriesMapping = {
'Congo (Dem. Rep. of the)': 'CD',
'Congo (Rep. of)': 'CG',
'Czech Republic': 'CZ',
'Korea (the Republic of)': 'KR',
Macau: 'MO',

6
apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts

@ -0,0 +1,6 @@
import { AssetClass, AssetSubClass } from '@prisma/client';
export interface HoldingsTableContext {
assetClassTranslations: Record<AssetClass, string>;
assetSubClassTranslations: Record<AssetSubClass, string>;
}

14
apps/api/src/services/portfolio-table/portfolio-table.module.ts

@ -0,0 +1,14 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common';
import { PortfolioTableService } from './portfolio-table.service';
@Module({
exports: [PortfolioTableService],
imports: [ActivitiesModule, I18nModule, PortfolioModule],
providers: [PortfolioTableService]
})
export class PortfolioTableModule {}

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

@ -0,0 +1,272 @@
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface';
import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import {
DEFAULT_LANGUAGE_CODE,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass } from '@prisma/client';
import { PortfolioTableService } from './portfolio-table.service';
/**
* The markdown table is rendered by a package which ships as an ECMAScript
* module only, which Jest cannot run. The mock keeps the mapping of the
* columns and of the rows and writes them in the same shape as the renderer
*/
jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => {
const { getTableInput } = jest.requireActual<
typeof import('@ghostfolio/api/helper/markdown-table.helper')
>('@ghostfolio/api/helper/markdown-table.helper');
return {
getTableInput,
getMarkdownTable: jest.fn(
(parameters: TableParameters<unknown, unknown>) => {
const { columns, rows } = getTableInput(parameters);
const names = columns.map(({ name }) => {
return name;
});
return Promise.resolve(
[
names,
names.map(() => {
return '---';
}),
...rows.map((row) => {
return names.map((name) => {
return row[name];
});
})
]
.map((cells) => {
return `| ${cells.join(' | ')} |`;
})
.join('\n')
);
}
)
};
});
function createAccount({
id = 'account-a-id',
isExcluded = false,
name = 'Account A'
}: {
id?: string;
isExcluded?: boolean;
name?: string;
} = {}) {
return {
id,
name,
activitiesCount: 3,
allocationInPercentage: 0.25,
balance: 1000,
currency: 'CHF',
platform: { name: 'Platform A' },
tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [],
value: 2000
} as unknown as AccountWithValue;
}
function createHolding({
allocationInPercentage = 0.75,
assetClass = AssetClass.EQUITY,
assetSubClass = AssetSubClass.STOCK,
symbol = 'AAPL'
}: {
allocationInPercentage?: number;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
symbol?: string;
} = {}) {
return {
allocationInPercentage,
activitiesCount: 3,
assetProfile: {
assetClass,
assetSubClass,
symbol,
currency: 'CHF',
name: `Name of ${symbol}`
},
dateOfFirstActivity: new Date('2024-01-01'),
grossPerformance: 100,
netPerformance: 90,
quantity: 5,
valueInBaseCurrency: 2000
} as unknown as PortfolioPosition;
}
function createPortfolioTableService({
accounts = [],
holdings = []
}: {
accounts?: AccountWithValue[];
holdings?: PortfolioPosition[];
} = {}) {
// The mock gives the identifier of the translation, so that a test can tell
// the translation of the asset class from that of the asset sub class
const i18nService = {
getTranslation: jest.fn(({ id }: { id: string }) => {
return `translation of ${id}`;
})
} as unknown as I18nService;
const portfolioService = {
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }),
getDetails: jest.fn().mockResolvedValue({ holdings })
} as unknown as PortfolioService;
return new PortfolioTableService(null, i18nService, portfolioService);
}
describe('PortfolioTableService', () => {
// The tools of the model context protocol are the only callers, and an
// access of that type never grants the scope to read the monetary values,
// hence no table has a column with such a value
describe('getAccountsTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getAccountsTableColumnNames()).toEqual([
'Id',
'Name',
'Currency',
'Platform',
'Activities Count',
'Allocation in Percentage',
'Excluded from Analysis'
]);
});
});
describe('getActivitiesTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getActivitiesTableColumnNames()).toEqual([
'Date',
'Type',
'Name',
'Symbol',
'Currency',
'Unit Price',
'Account'
]);
});
});
describe('getHoldingsTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([
'Name',
'Symbol',
'Currency',
'Asset Class',
'Asset Sub Class',
'Date of First Activity',
'Activities Count',
'Allocation in Percentage'
]);
});
});
describe('getAccountsTable', () => {
it('gives no cash balance and no value of an account', async () => {
const portfolioTableService = createPortfolioTableService({
accounts: [createAccount()]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
});
expect(result).not.toContain('Cash Balance');
expect(result).not.toContain('1000');
expect(result).not.toContain('2000');
});
// The accountIds parameter of the tool takes the identifiers, hence the
// table has to give them
it('gives the identifier of an account', async () => {
const portfolioTableService = createPortfolioTableService({
accounts: [createAccount()]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
});
expect(result).toContain('account-a-id');
});
it('marks an account which is excluded from the analysis', async () => {
const portfolioTableService = createPortfolioTableService({
accounts: [
createAccount({ isExcluded: true }),
createAccount({ id: 'account-b-id', name: 'Account B' })
]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
});
const [rowOfAccountA, rowOfAccountB] = result
.split('\n')
.filter((line) => {
return line.startsWith('| account-');
});
expect(rowOfAccountA).toContain('true');
expect(rowOfAccountB).toContain('false');
});
it('tells that no accounts are found if the result is empty', async () => {
const portfolioTableService = createPortfolioTableService();
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
});
expect(result).toContain('No accounts found.');
});
});
describe('getHoldingsTable', () => {
function getHoldingsTable(holdings: PortfolioPosition[]) {
return createPortfolioTableService({ holdings }).getHoldingsTable({
languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id'
});
}
it('gives the translation of the asset class and of the asset sub class', async () => {
const result = await getHoldingsTable([createHolding()]);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(row).toContain('translation of assetClass.EQUITY');
expect(row).toContain('translation of assetSubClass.STOCK');
});
it('gives the holding with the largest allocation first', async () => {
const result = await getHoldingsTable([
createHolding({ allocationInPercentage: 0.25, symbol: 'MSFT' }),
createHolding({ allocationInPercentage: 0.75, symbol: 'AAPL' })
]);
const [firstRow, secondRow] = result.split('\n').filter((line) => {
return line.startsWith('| Name of');
});
expect(firstRow).toContain('AAPL');
expect(secondRow).toContain('MSFT');
});
});
});

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

@ -0,0 +1,399 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { format } from 'date-fns';
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
function getAllocationInPercentage(allocationInPercentage: number) {
return `${(allocationInPercentage * 100).toFixed(3)}%`;
}
/**
* Renders the accounts, the activities and the holdings of a portfolio as a
* markdown table. No table has a column with a quantity or with a monetary
* value, except the unit price of an activity.
*/
@Injectable()
export class PortfolioTableService {
private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<AccountWithValue>[] =
[
{
getValue: ({ id }) => {
return id;
},
name: 'Id'
},
{
getValue: ({ name }) => {
return name ?? '';
},
name: 'Name'
},
{
getValue: ({ currency }) => {
return currency ?? '';
},
name: 'Currency'
},
{
getValue: ({ platform }) => {
return platform?.name ?? '';
},
name: 'Platform'
},
{
align: 'right',
getValue: ({ activitiesCount }) => {
return activitiesCount.toString();
},
name: 'Activities Count'
},
{
align: 'right',
getValue: ({ allocationInPercentage }) => {
return getAllocationInPercentage(allocationInPercentage);
},
name: 'Allocation in Percentage'
},
{
getValue: ({ tags }) => {
return isAccountExcluded({ tags }).toString();
},
name: 'Excluded from Analysis'
}
];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<Activity>[] =
[
{
getValue: ({ date }) => {
return format(date, DATE_FORMAT);
},
name: 'Date'
},
{
getValue: ({ type }) => {
return type;
},
name: 'Type'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.name ?? '';
},
name: 'Name'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.symbol;
},
name: 'Symbol'
},
{
getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency;
},
name: 'Currency'
},
{
align: 'right',
getValue: ({ unitPrice }) => {
return unitPrice.toString();
},
name: 'Unit Price'
},
{
getValue: ({ account }) => {
return account?.name ?? '';
},
name: 'Account'
}
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[
{
getValue: ({ assetProfile }) => {
return assetProfile.name;
},
name: 'Name'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.symbol;
},
name: 'Symbol'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.currency;
},
name: 'Currency'
},
{
getValue: ({ assetProfile }, { assetClassTranslations }) => {
return assetClassTranslations[assetProfile.assetClass] ?? '';
},
name: 'Asset Class'
},
{
getValue: ({ assetProfile }, { assetSubClassTranslations }) => {
return assetSubClassTranslations[assetProfile.assetSubClass] ?? '';
},
name: 'Asset Sub Class'
},
{
getValue: ({ dateOfFirstActivity }) => {
return dateOfFirstActivity
? format(dateOfFirstActivity, DATE_FORMAT)
: '';
},
name: 'Date of First Activity'
},
{
align: 'right',
getValue: ({ activitiesCount }) => {
return activitiesCount.toString();
},
name: 'Activities Count'
},
{
align: 'right',
getValue: ({ allocationInPercentage }) => {
return getAllocationInPercentage(allocationInPercentage);
},
name: 'Allocation in Percentage'
}
];
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService
) {}
public static getAccountsTableColumnNames() {
return PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getActivitiesTableColumnNames() {
return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getHoldingsTableColumnNames() {
return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public async getAccountsTable({
filters,
userId
}: {
filters?: Filter[];
userId: string;
}) {
const { accounts } =
await this.portfolioService.getAccountsWithAggregations({
filters,
userId,
withExcludedAccounts: true
});
const accountsSection = ['## Accounts', ''];
if (accounts.length > 0) {
accountsSection.push(
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS,
rows: accounts
})
);
} else {
accountsSection.push('No accounts found.');
}
return accountsSection.join('\n');
}
public async getActivitiesTable({
endDate,
filters,
skip = 0,
startDate,
take,
types,
userCurrency,
userId
}: {
endDate?: Date;
filters?: Filter[];
skip?: number;
startDate?: Date;
take: number;
types?: ActivityType[];
userCurrency: string;
userId: string;
}) {
const { activities, count } = await this.activitiesService.getActivities({
endDate,
filters,
skip,
startDate,
take,
types,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'desc',
withExcludedAccountsAndActivities: true
});
const activitiesSection = [
'## Activities',
'',
this.getActivitiesSummary({
count,
skip,
numberOfActivities: activities.length
})
];
if (activities.length > 0) {
activitiesSection.push(
'',
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
rows: activities
})
);
}
return activitiesSection.join('\n');
}
public async getHoldingsTable({
filters,
languageCode,
userId
}: {
filters?: Filter[];
languageCode: string;
userId: string;
}) {
const { holdings } = await this.portfolioService.getDetails({
filters,
userId
});
const assetClassTranslations = this.getEnumTranslations({
languageCode,
id: 'assetClass',
values: Object.values(AssetClass)
});
const assetSubClassTranslations = this.getEnumTranslations({
languageCode,
id: 'assetSubClass',
values: Object.values(AssetSubClass)
});
const sortedHoldings = [...holdings].sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
});
return [
'## Holdings',
'',
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
context: { assetClassTranslations, assetSubClassTranslations },
rows: sortedHoldings
})
].join('\n');
}
private getActivitiesSummary({
count,
numberOfActivities,
skip
}: {
count: number;
numberOfActivities: number;
skip: number;
}) {
if (count === 0) {
return 'No activities found.';
}
if (numberOfActivities === 0) {
return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`;
}
if (numberOfActivities === count) {
return `Showing all ${count} activities, the most recent first.`;
}
const lastActivity = skip + numberOfActivities;
const summary = `Showing the activities ${
skip + 1
} to ${lastActivity} of ${count}, the most recent first.`;
if (lastActivity === count) {
return summary;
}
return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`;
}
private getEnumTranslations<T extends string>({
id,
languageCode,
values
}: {
id: string;
languageCode: string;
values: T[];
}) {
return values.reduce(
(translations, value) => {
translations[value] =
this.i18nService.getTranslation({
languageCode,
id: `${id}.${value}`
}) || value;
return translations;
},
{} as Record<T, string>
);
}
}

9
apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts

@ -0,0 +1,9 @@
import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { HoldingsTableContext } from '../interfaces/holdings-table-context.interface';
export type HoldingsTableColumnDefinition = TableColumnDefinition<
PortfolioPosition,
HoldingsTableContext
>;

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

@ -82,9 +82,7 @@ export class PortfolioSnapshotProcessor {
const expiration = addMilliseconds(
new Date(),
(snapshot?.errors?.length ?? 0) === 0
? this.configurationService.get('CACHE_QUOTES_TTL')
: 0
this.configurationService.get('CACHE_QUOTES_TTL')
);
await this.redisCacheService.set(
@ -101,7 +99,9 @@ export class PortfolioSnapshotProcessor {
return snapshot;
} catch (error) {
this.logger.error(error.message);
this.logger.error(
`Portfolio snapshot calculation of user '${job.data.userId}' has failed: ${error.message}`
);
throw new Error(error);
}

38
apps/client/src/app/components/access-table/access-table.component.html

@ -45,36 +45,6 @@
</td>
</ng-container>
<ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.type === 'PUBLIC') {
<div class="align-items-center d-flex">
<ion-icon class="mr-1" name="link-outline" />
<a target="_blank" [href]="getPublicUrl(element.id)">{{
getPublicUrl(element.id)
}}</a>
</div>
@if (user()?.settings?.isExperimentalFeatures) {
<div>
<code
>GET {{ baseUrl }}/api/v1/public/{{
element.id
}}/portfolio</code
>
</div>
}
} @else if (element.type === 'MCP' && hasPermissionToEnableMcp) {
<div>
<code>{{ baseUrl }}{{ mcpEndpoint }}</code>
</div>
<div>
<code>Authorization: Bearer {{ element.id }}</code>
</div>
}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell></th>
@ -113,14 +83,6 @@
</span>
</button>
}
@if (element.type === 'MCP' && hasPermissionToEnableMcp) {
<button mat-menu-item (click)="onCopyIdToClipboard(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span i18n>Copy identifier to clipboard</span>
</span>
</button>
}
@if (
(!isReceivedAccess() && user()?.settings?.isExperimentalFeatures) ||
element.type === 'PUBLIC' ||

26
apps/client/src/app/components/access-table/access-table.component.ts

@ -1,4 +1,3 @@
import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces';
@ -32,7 +31,6 @@ import {
copyOutline,
createOutline,
ellipsisHorizontal,
linkOutline,
removeCircleOutline,
trashOutline
} from 'ionicons/icons';
@ -81,14 +79,7 @@ export class GfAccessTableComponent {
protected readonly dataSource = new MatTableDataSource<Access>();
protected readonly displayedColumns = computed(() => {
const columns = [
'alias',
'grantee',
'type',
'lastUsedAt',
'expiresAt',
'details'
];
const columns = ['alias', 'grantee', 'type', 'lastUsedAt', 'expiresAt'];
if (this.showActions()) {
columns.push('actions');
@ -109,8 +100,6 @@ export class GfAccessTableComponent {
return !this.accesses();
});
protected readonly mcpEndpoint = MCP_ENDPOINT;
private readonly clipboard = inject(Clipboard);
private readonly dataService = inject(DataService);
private readonly notificationService = inject(NotificationService);
@ -121,7 +110,6 @@ export class GfAccessTableComponent {
copyOutline,
createOutline,
ellipsisHorizontal,
linkOutline,
removeCircleOutline,
trashOutline
});
@ -142,18 +130,6 @@ export class GfAccessTableComponent {
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
}
protected onCopyIdToClipboard(aId: string) {
this.clipboard.copy(aId);
this.snackBar.open(
'✅ ' + $localize`Identifier has been copied to the clipboard`,
undefined,
{
duration: ms('3 seconds')
}
);
}
protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId));

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

@ -393,8 +393,12 @@ export class GfAccountDetailDialogComponent implements OnInit {
.fetchPortfolioHoldings({
filters: [
{
type: 'ACCOUNT',
id: this.data.accountId
id: this.data.accountId,
type: 'ACCOUNT'
},
{
id: 'ACTIVE',
type: 'HOLDING_TYPE'
}
]
})

141
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -1,9 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_COLOR_SCHEME,
DEFAULT_LOCALE,
DEFAULT_PAGE_SIZE
} from '@ghostfolio/common/config';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { canDeleteAssetProfile } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
@ -13,6 +9,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { translate } from '@ghostfolio/ui/i18n';
@ -27,7 +24,6 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
DestroyRef,
inject,
OnInit,
@ -36,7 +32,6 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
import {
MatPaginator,
@ -51,7 +46,7 @@ import {
SortDirection
} from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { AssetSubClass, DataSource, SymbolProfile } from '@prisma/client';
import { isUUID } from 'class-validator';
@ -66,16 +61,11 @@ import {
trashOutline
} from 'ionicons/icons';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { AdminMarketDataService } from './admin-market-data.service';
import { GfAssetProfileDialogComponent } from './asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
import { GfCreateAssetProfileDialogComponent } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -154,6 +144,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
protected readonly displayedColumns: string[] = [];
protected readonly filters$ = new Subject<Filter[]>();
protected isLoading = true;
protected readonly internalRoutes = internalRoutes;
protected readonly isUUID = isUUID;
protected pageSize = DEFAULT_PAGE_SIZE;
protected placeholder = '';
@ -164,9 +155,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
private activeFilters: Filter[] = [];
private benchmarks: Partial<SymbolProfile>[];
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly hasPermissionForSubscription: boolean;
private readonly info: InfoItem;
private readonly paginator = viewChild.required(MatPaginator);
@ -176,9 +164,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService);
@ -213,21 +198,10 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
this.displayedColumns.push('comment');
this.displayedColumns.push('actions');
this.route.queryParams
this.adminMarketDataService.refresh$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (
params['assetProfileDialog'] &&
params['dataSource'] &&
params['symbol']
) {
this.openAssetProfileDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
} else if (params['createAssetProfileDialog']) {
this.openCreateAssetProfileDialog();
}
.subscribe(() => {
this.reloadData();
});
this.userService.stateChanged
@ -341,13 +315,12 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
dataSource,
symbol
}: AssetProfileIdentifier) {
this.router.navigate([], {
queryParams: {
void this.router.navigate(
internalRoutes.adminControl.subRoutes.marketData.subRoutes.update.routerLink(
dataSource,
symbol,
assetProfileDialog: true
}
});
symbol
)
);
}
private loadData(
@ -418,96 +391,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
);
}
private openAssetProfileDialog({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open<
GfAssetProfileDialogComponent,
AssetProfileDialogParams,
AssetProfileIdentifier
>(GfAssetProfileDialogComponent, {
autoFocus: false,
data: {
dataSource,
symbol,
colorScheme:
this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME,
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies AssetProfileDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((newAssetProfileIdentifier) => {
this.reloadData();
if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
});
});
}
private openCreateAssetProfileDialog() {
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open<
GfCreateAssetProfileDialogComponent,
CreateAssetProfileDialogParams
>(GfCreateAssetProfileDialogComponent, {
autoFocus: false,
data: {
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies CreateAssetProfileDialogParams,
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (!result) {
this.router.navigate(['.'], { relativeTo: this.route });
return;
}
const { addAssetProfile, dataSource, symbol } = result;
if (addAssetProfile && dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.loadData();
});
} else {
this.loadData();
}
this.onOpenAssetProfileDialog({ dataSource, symbol });
});
});
}
private reloadData({
pageIndex = this.paginator().pageIndex
}: { pageIndex?: number } = {}) {

21
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -303,12 +303,12 @@
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<a
mat-menu-item
[queryParams]="{
assetProfileDialog: true,
dataSource: element.dataSource,
symbol: element.symbol
}"
[routerLink]="[]"
[routerLink]="
internalRoutes.adminControl.subRoutes.marketData.subRoutes.update.routerLink(
element.dataSource,
element.symbol
)
"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
@ -381,5 +381,12 @@
</div>
</div>
<gf-fab [queryParams]="{ createAssetProfileDialog: true }" />
<gf-fab
[routerLink]="
internalRoutes.adminControl.subRoutes.marketData.subRoutes.create
.routerLink
"
/>
</div>
<router-outlet />

10
apps/client/src/app/components/admin-market-data/admin-market-data.service.ts

@ -8,9 +8,15 @@ import { EMPTY, Subject, catchError, finalize, forkJoin } from 'rxjs';
@Service({ autoProvided: false })
export class AdminMarketDataService {
private readonly refreshSubject = new Subject<void>();
private readonly adminService = inject(AdminService);
private readonly notificationService = inject(NotificationService);
public get refresh$() {
return this.refreshSubject.asObservable();
}
public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
const assetProfileDeleted = new Subject<void>();
@ -72,4 +78,8 @@ export class AdminMarketDataService {
return assetProfilesDeleted.asObservable();
}
public triggerRefresh() {
this.refreshSubject.next();
}
}

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

@ -0,0 +1,217 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_COLOR_SCHEME,
DEFAULT_LOCALE
} from '@ghostfolio/common/config';
import { AssetProfileIdentifier, User } from '@ghostfolio/common/interfaces';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AdminService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
OnDestroy,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { distinctUntilChanged, map, takeUntil, tap } from 'rxjs/operators';
import { AdminMarketDataService } from '../admin-market-data.service';
import { GfAssetProfileDialogComponent } from '../asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from '../asset-profile-dialog/interfaces/interfaces';
import { GfCreateAssetProfileDialogComponent } from '../create-asset-profile-dialog/create-asset-profile-dialog.component';
import { CreateAssetProfileDialogParams } from '../create-asset-profile-dialog/interfaces/interfaces';
import { AssetProfileDialogMode } from './types/asset-profile-dialog-mode.type';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-asset-profile-dialog-host',
template: ''
})
export class GfAssetProfileDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef<
GfAssetProfileDialogComponent | GfCreateAssetProfileDialogComponent
>;
private readonly deviceType = computed(() => {
return this.deviceDetectorService.deviceInfo().deviceType;
});
private readonly dialogClosed = new Subject<void>();
private readonly adminMarketDataService = inject(AdminMarketDataService);
private readonly adminService = inject(AdminService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public ngOnInit() {
const mode = this.route.snapshot.data.mode as AssetProfileDialogMode;
// The router reuses this component when only the asset profile identifier
// changes, so the parameters are observed instead of read from the
// snapshot once
this.route.paramMap
.pipe(
map((paramMap) => {
return {
dataSource: paramMap.get('dataSource'),
symbol: paramMap.get('symbol')
};
}),
distinctUntilChanged((previous, current) => {
return (
previous.dataSource === current.dataSource &&
previous.symbol === current.symbol
);
}),
tap(() => {
this.closeDialog();
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ dataSource, symbol }) => {
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
if (mode === 'create') {
this.openCreateAssetProfileDialog({ user });
} else if (dataSource && symbol) {
this.openAssetProfileDialog({
symbol,
user,
dataSource: dataSource as DataSource
});
} else {
this.navigateBack();
}
});
});
}
public ngOnDestroy() {
// The dialog lives in an overlay outside of this component, so it needs to
// be closed explicitly when leaving the route (for example via the browser
// navigation)
this.dialogRef?.close();
this.dialogClosed.complete();
}
private closeDialog() {
// Tear down the subscription of the dialog which is about to be replaced,
// so that its result is not mistaken for the user closing it
this.dialogClosed.next();
this.dialogRef?.close();
}
private navigateBack() {
void this.router.navigate(
internalRoutes.adminControl.subRoutes.marketData.routerLink
);
}
private navigateToAssetProfileDialog({
dataSource,
symbol
}: AssetProfileIdentifier) {
void this.router.navigate(
internalRoutes.adminControl.subRoutes.marketData.subRoutes.update.routerLink(
dataSource,
symbol
)
);
}
private openAssetProfileDialog({
dataSource,
symbol,
user
}: AssetProfileIdentifier & { user: User }) {
const dialogRef = this.dialog.open<
GfAssetProfileDialogComponent,
AssetProfileDialogParams,
AssetProfileIdentifier
>(GfAssetProfileDialogComponent, {
autoFocus: false,
data: {
dataSource,
symbol,
colorScheme: user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME,
deviceType: this.deviceType(),
locale: user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies AssetProfileDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef))
.subscribe((newAssetProfileIdentifier) => {
this.adminMarketDataService.triggerRefresh();
if (newAssetProfileIdentifier) {
this.navigateToAssetProfileDialog(newAssetProfileIdentifier);
} else {
this.navigateBack();
}
});
}
private openCreateAssetProfileDialog({ user }: { user: User }) {
const dialogRef = this.dialog.open<
GfCreateAssetProfileDialogComponent,
CreateAssetProfileDialogParams,
AssetProfileIdentifier & { addAssetProfile: boolean }
>(GfCreateAssetProfileDialogComponent, {
autoFocus: false,
data: {
deviceType: this.deviceType(),
locale: user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies CreateAssetProfileDialogParams,
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (!result) {
this.navigateBack();
return;
}
const { addAssetProfile, dataSource, symbol } = result;
if (addAssetProfile && dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.navigateToAssetProfileDialog({ dataSource, symbol });
});
} else {
this.navigateToAssetProfileDialog({ dataSource, symbol });
}
});
}
}

1
apps/client/src/app/components/admin-market-data/asset-profile-dialog-host/types/asset-profile-dialog-mode.type.ts

@ -0,0 +1 @@
export type AssetProfileDialogMode = 'create' | 'update';

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

@ -2,6 +2,7 @@ import { AdminMarketDataService } from '@ghostfolio/client/components/admin-mark
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
ASSET_CLASS_MAPPING,
COMMENT_MAXIMUM_LENGTH,
PROPERTY_IS_DATA_GATHERING_ENABLED
} from '@ghostfolio/common/config';
import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
@ -229,6 +230,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected canEditAssetProfile = true;
protected readonly COMMENT_MAXIMUM_LENGTH = COMMENT_MAXIMUM_LENGTH;
protected countries: {
[code: string]: { name: string; value: number };

1
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -467,6 +467,7 @@
cdkTextareaAutosize
formControlName="comment"
matInput
[maxlength]="COMMENT_MAXIMUM_LENGTH"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>

9
apps/client/src/app/components/admin-overview/admin-overview.html

@ -187,17 +187,12 @@
</ng-container>
<ng-container matColumnDef="createdAt">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Creation</ng-container>
</th>
<td
*matCellDef="let element"
class="px-1 text-right"
mat-cell
>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (element.createdAt) {
<gf-value
class="d-inline-block justify-content-end"
[isDate]="true"
[locale]="user?.settings?.locale"
[value]="element.createdAt"

2
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html

@ -12,7 +12,7 @@
<div class="col-md-6 col-xs-12 d-flex justify-content-end">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
class="gf-form-field-dense w-100 without-hint"
color="accent"
>
<mat-label i18n>Compare with...</mat-label>

9
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss

@ -1,15 +1,6 @@
@use '@angular/material' as mat;
:host {
display: block;
@include mat.form-field-overrides(
(
container-height: 2rem,
container-vertical-padding: 0.33rem
)
);
.chart-container {
aspect-ratio: 16 / 9;

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

@ -182,8 +182,9 @@ export class GfHoldingDetailDialogComponent implements OnInit {
protected quantityPrecision = 2;
protected reportDataGlitchMailHref: string;
protected readonly round = round;
protected readonly routerLinkAdminControlMarketData =
internalRoutes.adminControl.subRoutes.marketData.routerLink;
protected readonly routerLinkAdminControlMarketDataUpdate =
internalRoutes.adminControl.subRoutes.marketData.subRoutes.update
.routerLink;
protected sectors: {
[name: string]: { name: string; value: number };
};

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

@ -358,7 +358,7 @@
</form>
</div>
@if (dataProviderInfo) {
@if (dataProviderInfo?.name) {
<div class="col-md-12 mb-3 text-center">
<hr />
<gf-data-provider-credits
@ -484,12 +484,12 @@
@if (data.hasPermissionToAccessAdminControl) {
<a
mat-stroked-button
[queryParams]="{
assetProfileDialog: true,
dataSource: assetProfile?.dataSource,
symbol: assetProfile?.symbol
}"
[routerLink]="routerLinkAdminControlMarketData"
[routerLink]="
routerLinkAdminControlMarketDataUpdate(
assetProfile?.dataSource,
assetProfile?.symbol
)
"
(click)="onClose()"
><ion-icon class="mr-1" name="create-outline" /><span i18n
>Manage Asset Profile</span

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

@ -153,9 +153,7 @@ export class GfHomeHoldingsComponent implements OnInit {
private fetchHoldings() {
const filters = this.userService.getFilters();
if (this.holdingType === 'CLOSED') {
filters.push({ id: 'CLOSED', type: 'HOLDING_TYPE' });
}
filters.push({ id: this.holdingType, type: 'HOLDING_TYPE' });
return this.dataService.fetchPortfolioHoldings({
filters,

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

@ -5,17 +5,20 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE,
DEFAULT_LOCALE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
import {
AssetProfileIdentifier,
LineChartItem,
PortfolioPerformance,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services';
@ -30,15 +33,18 @@ import {
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { Router, RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfHoldingsTableComponent,
GfLineChartComponent,
GfPortfolioPerformanceComponent,
MatButtonModule,
MatCardModule,
RouterModule
],
selector: 'gf-home-overview',
@ -46,9 +52,13 @@ import { DeviceDetectorService } from 'ngx-device-detector';
templateUrl: './home-overview.html'
})
export class GfHomeOverviewComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected readonly errors = signal<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null);
protected readonly holdings = signal<PortfolioPosition[] | undefined>(
undefined
);
protected readonly isLoadingPerformance = signal(true);
protected readonly performance = signal<PortfolioPerformance | null>(null);
protected readonly performanceLabel = $localize`Performance`;
@ -77,13 +87,24 @@ export class GfHomeOverviewComponent implements OnInit {
const user = this.user();
return user
? !user.settings.isRestrictedView && user.settings.viewMode !== 'ZEN'
? !user?.settings?.isRestrictedView && user?.settings?.viewMode !== 'ZEN'
: false;
});
protected readonly showExperimentalHoldings = computed(() => {
const user = this.user();
return (
user?.settings?.isExperimentalFeatures === true &&
user?.settings?.viewMode !== 'ZEN'
);
});
protected readonly unit = computed(() => {
const user = this.user();
return this.showDetails()
? (this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY)
? (user?.settings?.baseCurrency ?? DEFAULT_CURRENCY)
: '%';
});
@ -94,6 +115,7 @@ export class GfHomeOverviewComponent implements OnInit {
ImpersonationStorageService
);
private readonly layoutService = inject(LayoutService);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
@ -122,10 +144,32 @@ export class GfHomeOverviewComponent implements OnInit {
});
}
protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) {
this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true }
});
}
}
private update() {
this.historicalDataItems.set(null);
this.isLoadingPerformance.set(true);
if (this.showExperimentalHoldings()) {
this.holdings.set(undefined);
this.dataService
.fetchPortfolioHoldings({
filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }],
range: this.user()?.settings?.dateRange
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings.set(holdings);
});
}
this.dataService
.fetchPortfolioPerformance({
range: this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE

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

@ -1,5 +1,5 @@
<div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative px-xl-3"
>
@if (
!hasImpersonationId() &&
@ -61,8 +61,8 @@
</div>
</div>
} @else {
<div class="row w-100">
<div class="col p-0">
<div class="align-items-stretch flex-nowrap row w-100">
<div class="col-12 col-xl p-0">
<div class="chart-container mx-auto position-relative">
<gf-line-chart
class="position-absolute"
@ -80,12 +80,9 @@
[showYAxis]="false"
/>
</div>
</div>
</div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
class="pb-4"
[errors]="errors()"
[isLoading]="isLoadingPerformance()"
[locale]="user()?.settings?.locale"
@ -96,5 +93,27 @@
/>
</div>
</div>
</div>
@if (showExperimentalHoldings()) {
<div class="col-xl-4 d-none d-xl-block ml-5 p-0 position-relative">
<mat-card
appearance="outlined"
class="h-100 holdings position-absolute w-100"
>
<mat-card-header>
<mat-card-title i18n>Holdings</mat-card-title>
</mat-card-header>
<mat-card-content class="overflow-auto">
<gf-holdings-table
mode="simple"
[holdings]="holdings()"
[locale]="user()?.settings?.locale ?? DEFAULT_LOCALE"
(holdingClicked)="onHoldingClicked($event)"
/>
</mat-card-content>
</mat-card>
</div>
}
</div>
}
</div>

14
apps/client/src/app/components/home-overview/home-overview.scss

@ -30,6 +30,20 @@
}
}
mat-card {
&.holdings {
--mat-card-title-text-size: var(--mat-sys-title-medium-size);
mat-card-content {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
}
.introduction {
max-width: 50rem;
}

66
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -6,6 +6,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFabComponent } from '@ghostfolio/ui/fab';
@ -23,12 +24,10 @@ import {
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component';
import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces';
import { HomeWatchlistService } from './home-watchlist.service';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -38,6 +37,7 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
GfPremiumIndicatorComponent,
RouterModule
],
providers: [HomeWatchlistService],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-home-watchlist',
styleUrls: ['./home-watchlist.scss'],
@ -45,6 +45,7 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
})
export class GfHomeWatchlistComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected readonly internalRoutes = internalRoutes;
protected hasPermissionToCreateWatchlistItem: boolean;
protected hasPermissionToDeleteWatchlistItem: boolean;
@ -59,18 +60,14 @@ export class GfHomeWatchlistComponent implements OnInit {
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly homeWatchlistService = inject(HomeWatchlistService);
private readonly userService = inject(UserService);
public constructor() {
this.route.queryParams
this.homeWatchlistService.refresh$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (params['createWatchlistItemDialog']) {
this.openCreateWatchlistItemDialog();
}
.subscribe(() => {
this.loadWatchlistData();
});
this.userService.stateChanged
@ -130,49 +127,4 @@ export class GfHomeWatchlistComponent implements OnInit {
}
});
}
private openCreateWatchlistItemDialog() {
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
if (
!hasPermission(user?.permissions, permissions.createWatchlistItem) ||
!hasScope(user?.scopes, scopes.watchlistCreate)
) {
this.router.navigate(['.'], { relativeTo: this.route });
return;
}
const dialogRef = this.dialog.open<
GfCreateWatchlistItemDialogComponent,
CreateWatchlistItemDialogParams
>(GfCreateWatchlistItemDialogComponent, {
data: {
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
},
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) {
this.dataService
.postWatchlistItem({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.loadWatchlistData()
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
}

8
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -22,5 +22,11 @@
</div>
</div>
@if (hasPermissionToCreateWatchlistItem) {
<gf-fab [queryParams]="{ createWatchlistItemDialog: true }" />
<gf-fab
[routerLink]="
internalRoutes.home.subRoutes.watchlist.subRoutes.create.routerLink
"
/>
}
<router-outlet />

15
apps/client/src/app/components/home-watchlist/home-watchlist.service.ts

@ -0,0 +1,15 @@
import { Service } from '@angular/core';
import { Subject } from 'rxjs';
@Service({ autoProvided: false })
export class HomeWatchlistService {
private readonly refreshSubject = new Subject<void>();
public get refresh$() {
return this.refreshSubject.asObservable();
}
public triggerRefresh() {
this.refreshSubject.next();
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save