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. 338
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  14. 311
      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. 102
      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. 30
      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. 53
      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/), 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). 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 ### 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) - 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`) - 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` - Upgraded `prisma` from version `7.9.1` to `7.10.0`
### Fixed ### 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 { 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 { export class GetAllAccountsDto extends FilterDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query?: string; 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 () => { it('does not adjust synthetic cash activities', async () => {
const activity = createActivity({ symbol: 'AAPL' }); const activity = createActivity({ symbol: 'AAPL' });
const cashActivity = createActivity({ const cashActivity = createActivity({

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

@ -759,7 +759,8 @@ export class ActivitiesService {
filters, filters,
userCurrency, userCurrency,
userId, userId,
withCash = false withCash = false,
withExcludedAccountsAndActivities = false
}: { }: {
/** Optional filters to apply to the activities. */ /** Optional filters to apply to the activities. */
filters?: Filter[]; filters?: Filter[];
@ -769,13 +770,15 @@ export class ActivitiesService {
userId: string; userId: string;
/** Whether to include cash activities in the result. */ /** Whether to include cash activities in the result. */
withCash?: boolean; withCash?: boolean;
/** Whether to include activities that are excluded from analysis. */
withExcludedAccountsAndActivities?: boolean;
}) { }) {
const [activities, splits] = await Promise.all([ const [activities, splits] = await Promise.all([
this.getActivities({ this.getActivities({
filters, filters,
userCurrency, userCurrency,
userId, userId,
withExcludedAccountsAndActivities: false // TODO withExcludedAccountsAndActivities
}), }),
this.assetProfileSplitService.getSplitsByUserId({ userId }) 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -12,7 +13,8 @@ import {
Inject, Inject,
Param, Param,
Query, Query,
UseGuards UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -30,6 +32,7 @@ export class AiController {
@Get('prompt/:mode') @Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt) @HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getPrompt( public async getPrompt(
@Param('mode') mode: AiPromptMode, @Param('mode') mode: AiPromptMode,
@Query() @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 { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
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 { ApiModule } from '@ghostfolio/api/services/api/api.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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.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 { PropertyModule } from '@ghostfolio/api/services/property/property.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'; import { Module } from '@nestjs/common';
@ -31,32 +13,12 @@ import { AiService } from './ai.service';
controllers: [AiController], controllers: [AiController],
exports: [AiService], exports: [AiService],
imports: [ imports: [
ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule, PortfolioTableModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, TransformDataSourceInRequestModule
SymbolProfileModule,
TagModule,
UserModule
], ],
providers: [ providers: [AiService]
AccountBalanceService,
AccountService,
AiService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
}) })
export class AiModule {} 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 { 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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
PROPERTY_API_KEY_OPENROUTER, PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL PROPERTY_OPENROUTER_MODEL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types'; import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import {
AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { generateText } from 'ai'; import { generateText } from 'ai';
import { format } from 'date-fns';
import type { ColumnDescriptor } from 'tablemark';
@Injectable() @Injectable()
export class AiService { 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( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService, private readonly portfolioTableService: PortfolioTableService,
private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService 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({ public async generateText({
prompt, prompt,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') 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({ public async getPrompt({
filters, filters,
languageCode, languageCode,
@ -341,98 +59,12 @@ export class AiService {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const holdingsSection = await this.portfolioTableService.getHoldingsTable({
filters, filters,
userId
});
const assetClassTranslations = this.getEnumTranslations({
languageCode, languageCode,
id: 'assetClass', userId
values: Object.values(AssetClass)
});
const assetSubClassTranslations = this.getEnumTranslations({
languageCode,
id: 'assetSubClass',
values: Object.values(AssetSubClass)
}); });
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') { if (mode === 'portfolio') {
return holdingsSection; return holdingsSection;
} }
@ -451,82 +83,4 @@ export class AiService {
`Provide your answer in the following language: ${languageCode}.` `Provide your answer in the following language: ${languageCode}.`
].join('\n'); ].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'; } from '@ghostfolio/common/interfaces';
import { hasPermission } from '@ghostfolio/common/permissions'; import { hasPermission } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -27,7 +27,6 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe,
Patch, Patch,
Post, Post,
Query, Query,
@ -36,11 +35,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client'; import { AssetProfileSplit, DataSource } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service'; import { AssetProfilesService } from './asset-profiles.service';
import { GetAssetProfilesDto } from './get-asset-profiles.dto';
@AllowDuringImpersonation() @AllowDuringImpersonation()
@Controller('asset-profiles') @Controller('asset-profiles')
@ -56,14 +56,17 @@ export class AssetProfilesController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAssetProfiles( public async getAssetProfiles(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query()
@Query('dataSource') filterByDataSource?: string, {
@Query('presetId') presetId?: MarketDataPreset, assetSubClasses: filterByAssetSubClasses,
@Query('query') filterBySearchQuery?: string, dataSource: filterByDataSource,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number, presetId,
@Query('sortColumn') sortColumn?: string, query: filterBySearchQuery,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, skip,
@Query('take', new ParseIntPipe({ optional: true })) take?: number sortColumn,
sortDirection,
take
}: GetAssetProfilesDto
): Promise<AssetProfilesResponse> { ): Promise<AssetProfilesResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses, 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 { GetDividendsDto } from './get-dividends.dto';
import { GetHistoricalDto } from './get-historical.dto'; import { GetHistoricalDto } from './get-historical.dto';
import { GetLookupDto } from './get-lookup.dto';
import { GetQuotesDto } from './get-quotes.dto'; import { GetQuotesDto } from './get-quotes.dto';
import { GhostfolioService } from './ghostfolio.service'; import { GhostfolioService } from './ghostfolio.service';
@ -142,10 +143,8 @@ export class GhostfolioController {
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2') @Version('2')
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false', @Query() { includeIndices, query }: GetLookupDto
@Query('query') query = ''
): Promise<LookupResponse> { ): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
await this.validateDailyRequestLimit(); await this.validateDailyRequestLimit();
try { try {

338
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 { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; import { AccessGuard } from '@ghostfolio/api/guards/access.guard';
import { Activity } from '@ghostfolio/common/interfaces'; import { Scope, scopes } from '@ghostfolio/common/scopes';
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 { import {
GhostfolioMcpController, EXCEPTION_FILTERS_METADATA,
IMPORT_ACTIVITIES_PARAMETERS GUARDS_METADATA
} from './mcp.controller'; } from '@nestjs/common/constants';
import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest';
// The controller reads the columns of the tables from the AiService, which
// imports two packages which ship as an ECMAScript module only, which Jest import { GhostfolioMcpController } from './mcp.controller';
// cannot transform. The mocks only make the imports resolvable, because no
// test calls them. /**
jest.mock('@openrouter/ai-sdk-provider', () => { * Gives the metadata which a decorator sets on the method of a tool. The
return { createOpenRouter: jest.fn() }; * prototype is read by the name of the method, hence the type of the metadata
}); * is given by the caller.
*/
jest.mock('ai', () => { function getMetadataOfMethod<T>(metadataKey: string, methodName: string) {
return { generateText: jest.fn() }; const methodsByName = GhostfolioMcpController.prototype as unknown as Record<
}); string,
object
>;
return Reflect.getMetadata(metadataKey, methodsByName[methodName]) as T;
}
function createActivity(overrides: Record<string, unknown> = {}) { function getToolMethodNames() {
return { return Object.getOwnPropertyNames(GhostfolioMcpController.prototype).filter(
currency: 'USD', (methodName) => {
date: '2024-01-01', return Boolean(
fee: 0, getMetadataOfMethod<ToolMetadata>(MCP_TOOL_METADATA_KEY, methodName)
quantity: 1, );
symbol: 'AAPL', }
type: ActivityType.BUY, );
unitPrice: 100,
...overrides
};
} }
describe('GhostfolioMcpController', () => { describe('GhostfolioMcpController', () => {
const impersonation = { userId: 'user-id' } as ImpersonationContext; // A tool without the decorator of the scope would be open to every access,
// hence a new tool has to declare its scope
let configuration: Record<string, unknown>; it('Requires a scope of access for each tool', () => {
let configurationService: ConfigurationService; const toolMethodNames = getToolMethodNames();
let controller: GhostfolioMcpController;
let importService: ImportService;
let userService: UserService;
function setupUser(userPermissions: string[]) { expect(toolMethodNames.length).toBeGreaterThan(0);
jest.spyOn(userService, 'user').mockResolvedValue({
permissions: userPermissions
} as UserWithSettings);
}
beforeEach(() => { const toolMethodNamesWithoutScope = toolMethodNames.filter((methodName) => {
configuration = { return !getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, methodName)
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], ?.length;
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; expect(toolMethodNamesWithoutScope).toEqual([]);
});
controller = new GhostfolioMcpController( // The decorator RequiresScope sets the same metadata as the decorator
undefined, // RequiresScopeOfAccess, but applies AuthGuard('jwt'), which a request of
undefined, // an access cannot pass, hence the guards tell the two decorators apart
configurationService, it('Applies the guard of the access to each tool', () => {
importService, const toolMethodNames = getToolMethodNames();
userService
expect(toolMethodNames.length).toBeGreaterThan(0);
const toolMethodNamesWithoutGuardOfAccess = toolMethodNames.filter(
(methodName) => {
return !getMetadataOfMethod<unknown[]>(
GUARDS_METADATA,
methodName
)?.includes(AccessGuard);
}
); );
});
afterEach(() => { expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]);
jest.restoreAllMocks();
}); });
describe('Import activities', () => { it('Requires the scope to create an activity for the tool to import activities', () => {
it('Requires the scope to create an activity', () => { expect(
expect( getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities')
Reflect.getMetadata( ).toEqual([scopes.activityCreate]);
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]);
jest
.spyOn(importService, 'import')
.mockRejectedValue(
new ImportValidationError('activities.0.symbol ("X") is not valid')
);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity()]
})
).rejects.toThrow(
new RpcException('activities.0.symbol ("X") is not valid')
);
});
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);
});
it('Does not write the message of a validation to the log', async () => {
setupUser([permissions.createActivity]);
const logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation();
jest
.spyOn(importService, 'import')
.mockRejectedValue(
new ImportValidationError('activities.0.accountId ("X") is not valid')
);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity({ accountId: 'X' })]
})
).rejects.toThrow(RpcException);
expect(logError).not.toHaveBeenCalled();
});
}); });
describe('Parameters of the tool to import activities', () => { it('Requires the scope to create an activity for the tool to search asset profiles', () => {
function parse(activities: unknown[]) { expect(
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'searchAssetProfiles')
} ).toEqual([scopes.activityCreate]);
});
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`, () => { // The tools have no try and catch, hence the filter is the only guarantee
expect( // that an unexpected exception does not expose internals
parse( it('Applies the filter of the exceptions of the tools', () => {
Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => { expect(
return createActivity(); Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, GhostfolioMcpController)
}) ).toEqual([McpToolExceptionFilter]);
)
).toBe(false);
});
}); });
}); });

311
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 { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.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 { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
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 { scopes } from '@ghostfolio/common/scopes'; import { scopes } from '@ghostfolio/common/scopes';
import type { ImpersonationContext } from '@ghostfolio/common/types'; import type { ImpersonationContext } from '@ghostfolio/common/types';
import { HttpException, Logger, UseFilters } from '@nestjs/common'; import { UseFilters } from '@nestjs/common';
import { Payload, RpcException } from '@nestjs/microservices'; import { Payload } from '@nestjs/microservices';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { McpController, Tool } from '@rekog/mcp-nest'; import { McpController, Tool } from '@rekog/mcp-nest';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { z } from 'zod'; import { z } from 'zod';
import 'zod/compile';
const GET_ACCOUNTS_PARAMETERS = z.object({ import {
accountIds: z GET_ACCOUNTS_PARAMETERS,
.array(z.string().min(1)) GET_ACTIVITIES_PARAMETERS,
.min(1) IMPORT_ACTIVITIES_PARAMETERS,
.max(MCP_MAX_ACCOUNTS) SEARCH_ASSET_PROFILES_PARAMETERS
.optional() } from './mcp.schemas';
.describe( import { McpService } from './mcp.service';
`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}`)
});
@McpController() @McpController()
@UseFilters(McpToolExceptionFilter) @UseFilters(McpToolExceptionFilter)
export class GhostfolioMcpController { export class GhostfolioMcpController {
private readonly logger = new Logger(GhostfolioMcpController.name); public constructor(private readonly mcpService: McpService) {}
public constructor(
private readonly aiService: AiService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
private readonly userService: UserService
) {}
@RequiresScopeOfAccess(scopes.accountRead) @RequiresScopeOfAccess(scopes.accountRead)
@Tool({ @Tool({
@ -159,7 +32,7 @@ export class GhostfolioMcpController {
readOnlyHint: true, readOnlyHint: true,
title: 'Get accounts' 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.`, )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it.`,
name: 'get-accounts', name: 'get-accounts',
@ -167,23 +40,9 @@ export class GhostfolioMcpController {
}) })
public async getAccounts( public async getAccounts(
@Impersonation() { userId }: ImpersonationContext, @Impersonation() { userId }: ImpersonationContext,
@Payload() @Payload() parameters: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
{
accountIds,
assetClasses,
holding
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
) { ) {
const filters = this.apiService.buildFiltersFromQueryParams({ return this.mcpService.getAccounts({ ...parameters, userId });
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 }] };
} }
@RequiresScopeOfAccess(scopes.activityRead) @RequiresScopeOfAccess(scopes.activityRead)
@ -193,52 +52,21 @@ export class GhostfolioMcpController {
readOnlyHint: true, readOnlyHint: true,
title: 'Get activities' 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.`, )}. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`,
name: 'get-activities', name: 'get-activities',
parameters: GET_ACTIVITIES_PARAMETERS parameters: GET_ACTIVITIES_PARAMETERS
}) })
public async getActivities( public async getActivities(
@Impersonation() @Impersonation() { userId, userSettings }: ImpersonationContext,
{ userId, userSettings }: ImpersonationContext, @Payload() parameters: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
@Payload()
{
activityTypes,
assetClasses,
holding,
range,
skip,
take
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
) { ) {
let endDate: Date | undefined; return this.mcpService.getActivities({
let startDate: Date | undefined; ...parameters,
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,
userId, userId,
take: take ?? MCP_MAX_ACTIVITIES,
types: activityTypes,
userCurrency: userSettings.baseCurrency userCurrency: userSettings.baseCurrency
}); });
return { content: [{ text: table, type: 'text' as const }] };
} }
@RequiresScopeOfAccess(scopes.portfolioRead) @RequiresScopeOfAccess(scopes.portfolioRead)
@ -248,22 +76,13 @@ export class GhostfolioMcpController {
readOnlyHint: true, readOnlyHint: true,
title: 'Get portfolio' 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' name: 'get-portfolio'
}) })
public async getPortfolio( public async getPortfolio(@Impersonation() { userId }: ImpersonationContext) {
@Impersonation() { userId, userSettings }: ImpersonationContext return this.mcpService.getPortfolio({ userId });
) {
const prompt = await this.aiService.getPrompt({
userId,
languageCode: DEFAULT_LANGUAGE_CODE,
mode: 'portfolio',
userCurrency: userSettings.baseCurrency
});
return { content: [{ text: prompt, type: 'text' as const }] };
} }
/** /**
@ -280,73 +99,33 @@ export class GhostfolioMcpController {
readOnlyHint: false, readOnlyHint: false,
title: 'Import activities' 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', name: 'import-activities',
parameters: IMPORT_ACTIVITIES_PARAMETERS parameters: IMPORT_ACTIVITIES_PARAMETERS
}) })
public async importActivities( public async importActivities(
@Impersonation() { userId }: ImpersonationContext, @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 }); return this.mcpService.importActivities({ ...parameters, userId });
}
if (!hasPermission(user?.permissions, permissions.createActivity)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
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
})
};
});
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 }] }; @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
})
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 { 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 { UserModule } from '@ghostfolio/api/app/user/user.module';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module';
import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -15,11 +16,20 @@ import {
} from '@rekog/mcp-nest'; } from '@rekog/mcp-nest';
import { GhostfolioMcpController } from './mcp.controller'; import { GhostfolioMcpController } from './mcp.controller';
import { McpService } from './mcp.service';
@Module({ @Module({
controllers: [GhostfolioMcpController], controllers: [GhostfolioMcpController],
imports: [AiModule, ApiModule, ConfigurationModule, ImportModule, UserModule], imports: [
ApiModule,
ConfigurationModule,
ImportModule,
PortfolioTableModule,
SymbolModule,
UserModule
],
providers: [ providers: [
McpService,
{ {
inject: [ConfigurationService], inject: [ConfigurationService],
provide: MCP_STRATEGY, 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) { public constructor(message: string) {
super(message); 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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
Activity, Activity,
Filter, Filter,
@ -34,6 +35,8 @@ export class PortfolioCalculatorFactory {
calculationType, calculationType,
currency, currency,
filters = [], filters = [],
usePortfolioSnapshotCache = true,
subscriptionType,
userId userId
}: { }: {
accountBalanceItems?: HistoricalDataItem[]; accountBalanceItems?: HistoricalDataItem[];
@ -41,6 +44,8 @@ export class PortfolioCalculatorFactory {
calculationType: PerformanceCalculationType; calculationType: PerformanceCalculationType;
currency: string; currency: string;
filters?: Filter[]; filters?: Filter[];
usePortfolioSnapshotCache?: boolean;
subscriptionType?: SubscriptionType;
userId: string; userId: string;
}): PortfolioCalculator { }): PortfolioCalculator {
switch (calculationType) { switch (calculationType) {
@ -50,6 +55,8 @@ export class PortfolioCalculatorFactory {
activities, activities,
currency, currency,
filters, filters,
usePortfolioSnapshotCache,
subscriptionType,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -64,6 +71,8 @@ export class PortfolioCalculatorFactory {
activities, activities,
currency, currency,
filters, filters,
usePortfolioSnapshotCache,
subscriptionType,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -78,6 +87,8 @@ export class PortfolioCalculatorFactory {
activities, activities,
currency, currency,
filters, filters,
usePortfolioSnapshotCache,
subscriptionType,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -92,6 +103,8 @@ export class PortfolioCalculatorFactory {
activities, activities,
currency, currency,
filters, filters,
usePortfolioSnapshotCache,
subscriptionType,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
currentRateService: this.currentRateService, 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_HIGH,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
@ -89,7 +90,9 @@ export abstract class PortfolioCalculator {
private snapshot: PortfolioSnapshot; private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>; private snapshotPromise: Promise<void>;
private startDate: Date; private startDate: Date;
private subscriptionType?: SubscriptionType;
private transactionPoints: TransactionPoint[]; private transactionPoints: TransactionPoint[];
private usePortfolioSnapshotCache: boolean;
private userId: string; private userId: string;
public constructor({ public constructor({
@ -102,6 +105,8 @@ export abstract class PortfolioCalculator {
filters, filters,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService, redisCacheService,
usePortfolioSnapshotCache = true,
subscriptionType,
userId userId
}: { }: {
accountBalanceItems: HistoricalDataItem[]; accountBalanceItems: HistoricalDataItem[];
@ -113,6 +118,8 @@ export abstract class PortfolioCalculator {
filters: Filter[]; filters: Filter[];
portfolioSnapshotService: PortfolioSnapshotService; portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService; redisCacheService: RedisCacheService;
usePortfolioSnapshotCache?: boolean;
subscriptionType?: SubscriptionType;
userId: string; userId: string;
}) { }) {
this.accountBalanceItems = accountBalanceItems; this.accountBalanceItems = accountBalanceItems;
@ -175,6 +182,8 @@ export abstract class PortfolioCalculator {
this.portfolioSnapshotService = portfolioSnapshotService; this.portfolioSnapshotService = portfolioSnapshotService;
this.redisCacheService = redisCacheService; this.redisCacheService = redisCacheService;
this.usePortfolioSnapshotCache = usePortfolioSnapshotCache;
this.subscriptionType = subscriptionType;
this.userId = userId; this.userId = userId;
const { endDate, startDate } = getIntervalFromDateRange({ const { endDate, startDate } = getIntervalFromDateRange({
@ -187,7 +196,11 @@ export abstract class PortfolioCalculator {
this.computeTransactionPoints(); 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 // Mark the rejection as handled to prevent an unhandled promise rejection
// in case the snapshot promise is never awaited. Consumers awaiting it // in case the snapshot promise is never awaited. Consumers awaiting it
@ -278,7 +291,8 @@ export abstract class PortfolioCalculator {
dateQuery: { dateQuery: {
gte: this.startDate, gte: this.startDate,
lt: this.endDate lt: this.endDate
} },
subscriptionType: this.subscriptionType
}); });
this.dataProviderInfos = dataProviderInfos; 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(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, 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(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, 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(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -59,7 +59,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, 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(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, 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(); RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
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(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, 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(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

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

@ -54,7 +54,7 @@ describe('PortfolioCalculator', () => {
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, 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(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, 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(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, 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(); RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
null, null,

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

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

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

@ -11,10 +11,8 @@ import {
DataProviderInfo, DataProviderInfo,
ResponseError ResponseError
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday } from 'date-fns';
import { isEmpty, uniqBy } from 'lodash'; import { isEmpty, uniqBy } from 'lodash';
@ -29,15 +27,14 @@ export class CurrentRateService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService
@Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@LogPerformance @LogPerformance
// TODO: Pass user instead of using this.request.user
public async getValues({ public async getValues({
dataGatheringItems, dataGatheringItems,
dateQuery dateQuery,
subscriptionType
}: GetValuesParams): Promise<GetValuesObject> { }: GetValuesParams): Promise<GetValuesObject> {
const dataProviderInfos: DataProviderInfo[] = []; const dataProviderInfos: DataProviderInfo[] = [];
@ -52,8 +49,8 @@ export class CurrentRateService {
if (includesToday) { if (includesToday) {
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: dataGatheringItems, subscriptionType,
user: this.request?.user items: dataGatheringItems
}); });
for (const { dataSource, symbol } of 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 { 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 { 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 { export class GetHoldingsDto extends DateRangeFilterDto {
@IsIn(['ACTIVE', 'CLOSED'] as HoldingType[]) @IsIn(['ACTIVE', 'CLOSED'] as HoldingType[])
@ -10,5 +11,6 @@ export class GetHoldingsDto extends DateRangeFilterDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query?: string; 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 { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { DateQuery } from './date-query.interface'; import { DateQuery } from './date-query.interface';
export interface GetValuesParams { export interface GetValuesParams {
dataGatheringItems: DataGatheringItem[]; dataGatheringItems: DataGatheringItem[];
dateQuery: DateQuery; 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({ const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
symbol, symbol,
userId userId,
withExcludedActivities: true
}); });
if (!holding) { if (!holding) {
@ -631,7 +632,8 @@ export class PortfolioController {
const holding = await this.portfolioService.getHolding({ const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
symbol, symbol,
userId userId,
withExcludedActivities: true
}); });
if (!holding) { 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.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 { parseDate } from '@ghostfolio/common/helper';
import { import {
Activity,
AssetProfileIdentifier, AssetProfileIdentifier,
Filter, Filter,
PortfolioSummary PortfolioSummary
@ -350,8 +355,13 @@ describe('PortfolioService', () => {
describe('getDetails', () => { describe('getDetails', () => {
const setUpCashOnlyPortfolio = ({ const setUpCashOnlyPortfolio = ({
baseCurrency = 'CHF', baseCurrency = 'CHF',
emergencyFund emergencyFund,
}: { baseCurrency?: string; emergencyFund?: number } = {}) => { quantity = 2000
}: {
baseCurrency?: string;
emergencyFund?: number;
quantity?: number;
} = {}) => {
const cashAccount: AccountWithBalance = { const cashAccount: AccountWithBalance = {
balance: 2000, balance: 2000,
comment: null, comment: null,
@ -416,7 +426,7 @@ describe('PortfolioService', () => {
netPerformancePercentage: new Big(0), netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {}, netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {}, netPerformanceWithCurrencyEffectMap: {},
quantity: new Big(2000), quantity: new Big(quantity),
symbol: 'USD', symbol: 'USD',
tags: [], tags: [],
timeWeightedInvestment: new Big(0), timeWeightedInvestment: new Big(0),
@ -488,6 +498,270 @@ describe('PortfolioService', () => {
expect(holdings[0].assetProfile.symbol).toBe('USD'); expect(holdings[0].assetProfile.symbol).toBe('USD');
expect(holdings[0].valueInBaseCurrency).toBe(1000); 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', () => { describe('getSummary', () => {

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

@ -477,12 +477,12 @@ export class PortfolioService {
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
}) { }) {
const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( const {
filters, HOLDING_TYPE: [filterByHoldingType] = [],
({ type }) => { SEARCH_QUERY: [filterBySearchQuery] = []
return type; } = groupBy(filters, ({ type }) => {
} return type;
); });
const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => { const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY'; return type !== 'SEARCH_QUERY';
@ -491,7 +491,8 @@ export class PortfolioService {
let { holdings } = await this.getDetails({ let { holdings } = await this.getDetails({
dateRange, dateRange,
userId, userId,
filters: filtersWithoutSearchQueryFilter filters: filtersWithoutSearchQueryFilter,
includeAllHoldings: !filterByHoldingType
}); });
if (filterBySearchQuery) { if (filterBySearchQuery) {
@ -545,7 +546,8 @@ export class PortfolioService {
filters, filters,
userId, userId,
calculationType: this.getUserPerformanceCalculationType(user), calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency currency: userCurrency,
subscriptionType: user.subscription?.type
}); });
const { historicalData } = await portfolioCalculator.getSnapshot(); const { historicalData } = await portfolioCalculator.getSnapshot();
@ -592,6 +594,7 @@ export class PortfolioService {
public async getDetails({ public async getDetails({
dateRange = DEFAULT_DATE_RANGE, dateRange = DEFAULT_DATE_RANGE,
filters, filters,
includeAllHoldings = false,
user: userFromCaller, user: userFromCaller,
userId, userId,
withExcludedAccounts = false, withExcludedAccounts = false,
@ -600,6 +603,7 @@ export class PortfolioService {
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
includeAllHoldings?: boolean;
user?: UserWithSettings; user?: UserWithSettings;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
@ -614,19 +618,24 @@ export class PortfolioService {
(user.settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const portfolioSnapshotFilters = filters?.filter(({ type }) => {
return type !== 'HOLDING_TYPE';
});
const { activities } = const { activities } =
await this.activitiesService.getActivitiesForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency, userCurrency,
userId userId,
filters: portfolioSnapshotFilters
}); });
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
filters,
userId, userId,
calculationType: this.getUserPerformanceCalculationType(user), calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency currency: userCurrency,
filters: portfolioSnapshotFilters,
subscriptionType: user.subscription?.type
}); });
const { createdAt, currentValueInBaseCurrency, hasErrors, positions } = const { createdAt, currentValueInBaseCurrency, hasErrors, positions } =
@ -706,13 +715,13 @@ export class PortfolioService {
tags, tags,
valueInBaseCurrency valueInBaseCurrency
} of positions) { } of positions) {
if (isFilteredByClosedHoldings === true) { if (!includeAllHoldings) {
if (!quantity.eq(0)) { if (isFilteredByClosedHoldings && !quantity.eq(0)) {
// Ignore positions with a quantity // Ignore positions with a quantity
continue; continue;
} }
} else {
if (quantity.eq(0)) { if (!isFilteredByClosedHoldings && quantity.eq(0)) {
// Ignore positions without any quantity // Ignore positions without any quantity
continue; continue;
} }
@ -888,20 +897,47 @@ export class PortfolioService {
public async getHolding({ public async getHolding({
dataSource, dataSource,
symbol, symbol,
userId userId,
withExcludedActivities = false
}: { }: {
userId: string; userId: string;
withExcludedActivities?: boolean;
} & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> { } & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); 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({ await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency, userCurrency,
userId 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; return undefined;
} }
@ -927,7 +963,10 @@ export class PortfolioService {
activities, activities,
userId, userId,
calculationType: this.getUserPerformanceCalculationType(user), calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency currency: userCurrency,
filters: isExcludedHolding ? holdingFilters : undefined,
usePortfolioSnapshotCache: !isExcludedHolding,
subscriptionType: user.subscription?.type
}); });
const transactionPoints = portfolioCalculator.getTransactionPoints(); const transactionPoints = portfolioCalculator.getTransactionPoints();
@ -1094,7 +1133,9 @@ export class PortfolioService {
userId: assetProfile.userId userId: assetProfile.userId
}, },
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dataProviderInfo: this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
dividendYieldPercent: dividendYieldPercent.toNumber(), dividendYieldPercent: dividendYieldPercent.toNumber(),
dividendYieldPercentWithCurrencyEffect: dividendYieldPercentWithCurrencyEffect:
@ -1180,7 +1221,8 @@ export class PortfolioService {
filters, filters,
userId, userId,
calculationType: this.getUserPerformanceCalculationType(user), calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency currency: userCurrency,
subscriptionType: user.subscription?.type
}); });
const { errors, hasErrors, historicalData } = const { errors, hasErrors, historicalData } =
@ -2031,12 +2073,7 @@ export class PortfolioService {
const nonExcludedActivities: Activity[] = []; const nonExcludedActivities: Activity[] = [];
for (const activity of activities) { for (const activity of activities) {
if ( if (this.isExcludedFromAnalysis(activity)) {
(activity.account && isAccountExcluded(activity.account)) ||
activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
})
) {
excludedActivities.push(activity); excludedActivities.push(activity);
} else { } else {
nonExcludedActivities.push(activity); nonExcludedActivities.push(activity);
@ -2416,4 +2453,13 @@ export class PortfolioService {
return { accounts, platforms }; 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 { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -46,7 +47,7 @@ export class SubscriptionController {
@Post('redeem-coupon') @Post('redeem-coupon')
@HttpCode(StatusCodes.OK) @HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), CustomThrottlerGuard, HasPermissionGuard)
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) { public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
if (!this.request.user) { if (!this.request.user) {
throw new HttpException( throw new HttpException(
@ -119,7 +120,7 @@ export class SubscriptionController {
} }
@Post('stripe/checkout-session') @Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), CustomThrottlerGuard, HasPermissionGuard)
public async createStripeCheckoutSession( public async createStripeCheckoutSession(
@Body() { couponId, priceId }: { couponId?: string; priceId: string } @Body() { couponId, priceId }: { couponId?: string; priceId: string }
): Promise<CreateStripeCheckoutSessionResponse> { ): Promise<CreateStripeCheckoutSessionResponse> {

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

@ -17,7 +17,7 @@ import {
UserWithSettings UserWithSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Prisma, Subscription } from '@prisma/client'; import { Prisma, Subscription } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns'; import { addMilliseconds, isBefore } from 'date-fns';
import ms, { StringValue } from 'ms'; 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 = const stripeCheckoutSessionCreateParams: Stripe.Checkout.SessionCreateParams =
{ {
cancel_url: `${this.configurationService.get('ROOT_URL')}/${ cancel_url: `${this.configurationService.get('ROOT_URL')}/${
@ -77,9 +85,7 @@ export class SubscriptionService {
} }
], ],
locale: this.getStripeLocale(user.settings?.settings?.language), locale: this.getStripeLocale(user.settings?.settings?.language),
metadata: subscriptionOffer metadata: { subscriptionOffer: JSON.stringify(subscriptionOffer) },
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
: {},
mode: 'payment', mode: 'payment',
payment_method_types: ['card'], payment_method_types: ['card'],
success_url: `${this.configurationService.get( 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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { GetLookupDto } from './get-lookup.dto';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Controller('symbol') @Controller('symbol')
@ -43,11 +44,8 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false', @Query() { includeIndices, query }: GetLookupDto
@Query('query') query = ''
): Promise<LookupResponse> { ): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
try { try {
return this.symbolService.lookup({ return this.symbolService.lookup({
includeIndices, includeIndices,

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

@ -275,7 +275,7 @@ export class UserService {
} }
try { try {
const { isBlocked } = await this.throttlerStorage.increment( const { totalHits } = await this.throttlerStorage.increment(
`${THROTTLE_DAILY_KEY}-${user.id}`, `${THROTTLE_DAILY_KEY}-${user.id}`,
THROTTLE_DAILY_TTL, THROTTLE_DAILY_TTL,
maxDailyRequests, maxDailyRequests,
@ -283,7 +283,7 @@ export class UserService {
THROTTLE_DAILY_KEY THROTTLE_DAILY_KEY
); );
return isBlocked; return totalHits > maxDailyRequests;
} catch (error) { } catch (error) {
this.logger.error(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 { export class FilterDto {
@IsOptional() @IsOptional()
@IsString() @IsUUID(undefined, { each: true })
accounts?: string; @Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
accounts?: string[];
@IsEnum(AssetClass, { each: true })
@IsOptional() @IsOptional()
@IsString() @Transform(({ value }: TransformFnParams) => {
assetClasses?: string; return isString(value) ? value.split(',') : value;
})
assetClasses?: AssetClass[];
@IsEnum(DataSource)
@IsOptional() @IsOptional()
@IsString() dataSource?: DataSource;
dataSource?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH)
symbol?: string; symbol?: string;
@IsOptional() @IsOptional()
@IsString() @IsUUID(undefined, { each: true })
tags?: string; @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);
});
});

30
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 { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
import { import {
Catch, Catch,
@ -6,7 +7,6 @@ import {
Logger, Logger,
RpcExceptionFilter RpcExceptionFilter
} from '@nestjs/common'; } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
@ -20,24 +20,32 @@ export class McpToolExceptionFilter implements RpcExceptionFilter {
private readonly logger = new Logger(McpToolExceptionFilter.name); private readonly logger = new Logger(McpToolExceptionFilter.name);
public catch(exception: unknown): Observable<never> { public catch(exception: unknown): Observable<never> {
this.logger.error(exception); // 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 RpcException) { if (exception instanceof CallerFacingError) {
return throwError(() => { return throwError(() => {
return exception.getError(); return { message: exception.message, status: 'error' };
}); });
} }
return throwError(() => { const statusCode = this.getStatus(exception);
return { message: this.getMessage(exception), status: 'error' };
}); // 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 // The message of an exception can carry internals, for example the
// property names of a data transfer object of a failed validation, hence // property names of a data transfer object of a failed validation, hence
// the reason phrase of the status is passed on instead // 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) { 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) { if (Object.keys(valueMap).length === 0) {
return data; return data;
} }

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

@ -14,23 +14,23 @@ export class ApiService {
filterBySymbol, filterBySymbol,
filterByTags filterByTags
}: { }: {
filterByAccounts?: string; filterByAccounts?: string[];
filterByAssetClasses?: string; filterByAssetClasses?: string[];
filterByAssetSubClasses?: string; filterByAssetSubClasses?: string;
filterByDataSource?: string; filterByDataSource?: string;
filterByHoldingType?: string; filterByHoldingType?: string;
filterBySearchQuery?: string; filterBySearchQuery?: string;
filterBySymbol?: string; filterBySymbol?: string;
filterByTags?: string; filterByTags?: string[];
}): Filter[] { }): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? []; const accountIds = filterByAccounts ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetClasses = filterByAssetClasses ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const dataSource = filterByDataSource; const dataSource = filterByDataSource;
const holdingType = filterByHoldingType; const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase(); const searchQuery = filterBySearchQuery?.toLowerCase();
const symbol = filterBySymbol; const symbol = filterBySymbol;
const tagIds = filterByTags?.split(',') ?? []; const tagIds = filterByTags ?? [];
const filters = [ const filters = [
...accountIds.map((accountId) => { ...accountIds.map((accountId) => {
@ -96,11 +96,11 @@ export class ApiService {
userSettings: UserSettings; userSettings: UserSettings;
}): Filter[] { }): Filter[] {
return this.buildFiltersFromQueryParams({ return this.buildFiltersFromQueryParams({
filterByAccounts: userSettings?.['filters.accounts']?.[0], filterByAccounts: userSettings?.['filters.accounts'],
filterByAssetClasses: userSettings?.['filters.assetClasses']?.[0], filterByAssetClasses: userSettings?.['filters.assetClasses'],
filterByDataSource: userSettings?.['filters.dataSource'], filterByDataSource: userSettings?.['filters.dataSource'],
filterBySymbol: userSettings?.['filters.symbol'], 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({ public async getQuotes({
items, items,
requestTimeout, requestTimeout,
useCache = true, subscriptionType,
user useCache = true
}: { }: {
items: AssetProfileIdentifier[]; items: AssetProfileIdentifier[];
requestTimeout?: number; requestTimeout?: number;
subscriptionType?: SubscriptionType;
useCache?: boolean; useCache?: boolean;
user?: UserWithSettings;
}): Promise<{ }): Promise<{
[assetProfileIdentifier: string]: DataProviderResponse; [assetProfileIdentifier: string]: DataProviderResponse;
}> { }> {
@ -687,7 +687,7 @@ export class DataProviderService implements OnModuleInit {
} else if ( } else if (
dataProvider.getDataProviderInfo().isPremium && dataProvider.getDataProviderInfo().isPremium &&
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user?.subscription?.type === SubscriptionType.Basic subscriptionType === SubscriptionType.Basic
) { ) {
// Skip symbols of Premium data providers for users without subscription // Skip symbols of Premium data providers for users without subscription
return false; return false;
@ -725,6 +725,10 @@ export class DataProviderService implements OnModuleInit {
promises.push( promises.push(
promise.then(async (result) => { promise.then(async (result) => {
const fetchedQuotes: (DataProviderResponse & {
symbol: string;
})[] = [];
for (const [symbol, dataProviderResponse] of Object.entries( for (const [symbol, dataProviderResponse] of Object.entries(
result result
)) { )) {
@ -739,12 +743,16 @@ export class DataProviderService implements OnModuleInit {
continue; continue;
} }
const quote = { ...dataProviderResponse, symbol };
fetchedQuotes.push(quote);
response[ response[
getAssetProfileIdentifier({ getAssetProfileIdentifier({
symbol, symbol,
dataSource: DataSource[dataSource] dataSource: DataSource[dataSource]
}) })
] = { ...dataProviderResponse, symbol }; ] = quote;
this.redisCacheService.set( this.redisCacheService.set(
this.redisCacheService.getQuoteKey({ this.redisCacheService.getQuoteKey({
@ -772,15 +780,19 @@ export class DataProviderService implements OnModuleInit {
marketState: 'open' marketState: 'open'
}; };
const derivedQuote = {
...derivedDataProviderResponse,
symbol: `${DEFAULT_CURRENCY}${currency}`
};
fetchedQuotes.push(derivedQuote);
response[ response[
getAssetProfileIdentifier({ getAssetProfileIdentifier({
dataSource: DataSource[dataSource], dataSource: DataSource[dataSource],
symbol: `${DEFAULT_CURRENCY}${currency}` symbol: `${DEFAULT_CURRENCY}${currency}`
}) })
] = { ] = derivedQuote;
...derivedDataProviderResponse,
symbol: `${DEFAULT_CURRENCY}${currency}`
};
this.redisCacheService.set( this.redisCacheService.set(
this.redisCacheService.getQuoteKey({ this.redisCacheService.getQuoteKey({
@ -805,7 +817,7 @@ export class DataProviderService implements OnModuleInit {
try { try {
await this.marketDataService.updateMany({ await this.marketDataService.updateMany({
data: Object.values(response) data: fetchedQuotes
.filter(({ marketPrice, marketState }) => { .filter(({ marketPrice, marketState }) => {
return ( return (
isNumber(marketPrice) && 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 implements DataProviderInterface, OnModuleInit
{ {
private static countriesMapping = { private static countriesMapping = {
'Congo (Dem. Rep. of the)': 'CD',
'Congo (Rep. of)': 'CG',
'Czech Republic': 'CZ', 'Czech Republic': 'CZ',
'Korea (the Republic of)': 'KR', 'Korea (the Republic of)': 'KR',
Macau: 'MO', 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( const expiration = addMilliseconds(
new Date(), new Date(),
(snapshot?.errors?.length ?? 0) === 0 this.configurationService.get('CACHE_QUOTES_TTL')
? this.configurationService.get('CACHE_QUOTES_TTL')
: 0
); );
await this.redisCacheService.set( await this.redisCacheService.set(
@ -101,7 +99,9 @@ export class PortfolioSnapshotProcessor {
return snapshot; return snapshot;
} catch (error) { } 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); throw new Error(error);
} }

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

@ -45,36 +45,6 @@
</td> </td>
</ng-container> </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> <ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-right" mat-header-cell></th>
@ -113,14 +83,6 @@
</span> </span>
</button> </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 ( @if (
(!isReceivedAccess() && user()?.settings?.isExperimentalFeatures) || (!isReceivedAccess() && user()?.settings?.isExperimentalFeatures) ||
element.type === 'PUBLIC' || 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 { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
@ -32,7 +31,6 @@ import {
copyOutline, copyOutline,
createOutline, createOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline,
removeCircleOutline, removeCircleOutline,
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
@ -81,14 +79,7 @@ export class GfAccessTableComponent {
protected readonly dataSource = new MatTableDataSource<Access>(); protected readonly dataSource = new MatTableDataSource<Access>();
protected readonly displayedColumns = computed(() => { protected readonly displayedColumns = computed(() => {
const columns = [ const columns = ['alias', 'grantee', 'type', 'lastUsedAt', 'expiresAt'];
'alias',
'grantee',
'type',
'lastUsedAt',
'expiresAt',
'details'
];
if (this.showActions()) { if (this.showActions()) {
columns.push('actions'); columns.push('actions');
@ -109,8 +100,6 @@ export class GfAccessTableComponent {
return !this.accesses(); return !this.accesses();
}); });
protected readonly mcpEndpoint = MCP_ENDPOINT;
private readonly clipboard = inject(Clipboard); private readonly clipboard = inject(Clipboard);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
@ -121,7 +110,6 @@ export class GfAccessTableComponent {
copyOutline, copyOutline,
createOutline, createOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline,
removeCircleOutline, removeCircleOutline,
trashOutline trashOutline
}); });
@ -142,18 +130,6 @@ export class GfAccessTableComponent {
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; 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) { protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId)); 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({ .fetchPortfolioHoldings({
filters: [ 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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
DEFAULT_COLOR_SCHEME,
DEFAULT_LOCALE,
DEFAULT_PAGE_SIZE
} from '@ghostfolio/common/config';
import { canDeleteAssetProfile } from '@ghostfolio/common/helper'; import { canDeleteAssetProfile } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -13,6 +9,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -27,7 +24,6 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
DestroyRef, DestroyRef,
inject, inject,
OnInit, OnInit,
@ -36,7 +32,6 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { import {
MatPaginator, MatPaginator,
@ -51,7 +46,7 @@ import {
SortDirection SortDirection
} from '@angular/material/sort'; } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; 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 { IonIcon } from '@ionic/angular/standalone';
import { AssetSubClass, DataSource, SymbolProfile } from '@prisma/client'; import { AssetSubClass, DataSource, SymbolProfile } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
@ -66,16 +61,11 @@ import {
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms'; import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators'; import { distinctUntilChanged } from 'rxjs/operators';
import { AdminMarketDataService } from './admin-market-data.service'; 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({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -154,6 +144,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
protected readonly displayedColumns: string[] = []; protected readonly displayedColumns: string[] = [];
protected readonly filters$ = new Subject<Filter[]>(); protected readonly filters$ = new Subject<Filter[]>();
protected isLoading = true; protected isLoading = true;
protected readonly internalRoutes = internalRoutes;
protected readonly isUUID = isUUID; protected readonly isUUID = isUUID;
protected pageSize = DEFAULT_PAGE_SIZE; protected pageSize = DEFAULT_PAGE_SIZE;
protected placeholder = ''; protected placeholder = '';
@ -164,9 +155,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
private activeFilters: Filter[] = []; private activeFilters: Filter[] = [];
private benchmarks: Partial<SymbolProfile>[]; private benchmarks: Partial<SymbolProfile>[];
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly hasPermissionForSubscription: boolean; private readonly hasPermissionForSubscription: boolean;
private readonly info: InfoItem; private readonly info: InfoItem;
private readonly paginator = viewChild.required(MatPaginator); private readonly paginator = viewChild.required(MatPaginator);
@ -176,9 +164,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); 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 router = inject(Router);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
@ -213,21 +198,10 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
this.displayedColumns.push('comment'); this.displayedColumns.push('comment');
this.displayedColumns.push('actions'); this.displayedColumns.push('actions');
this.route.queryParams this.adminMarketDataService.refresh$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe(() => {
if ( this.reloadData();
params['assetProfileDialog'] &&
params['dataSource'] &&
params['symbol']
) {
this.openAssetProfileDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
} else if (params['createAssetProfileDialog']) {
this.openCreateAssetProfileDialog();
}
}); });
this.userService.stateChanged this.userService.stateChanged
@ -341,13 +315,12 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
this.router.navigate([], { void this.router.navigate(
queryParams: { internalRoutes.adminControl.subRoutes.marketData.subRoutes.update.routerLink(
dataSource, dataSource,
symbol, symbol
assetProfileDialog: true )
} );
});
} }
private loadData( 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({ private reloadData({
pageIndex = this.paginator().pageIndex pageIndex = this.paginator().pageIndex
}: { pageIndex?: number } = {}) { }: { 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"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<a <a
mat-menu-item mat-menu-item
[queryParams]="{ [routerLink]="
assetProfileDialog: true, internalRoutes.adminControl.subRoutes.marketData.subRoutes.update.routerLink(
dataSource: element.dataSource, element.dataSource,
symbol: element.symbol element.symbol
}" )
[routerLink]="[]" "
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" /> <ion-icon class="mr-2" name="create-outline" />
@ -381,5 +381,12 @@
</div> </div>
</div> </div>
<gf-fab [queryParams]="{ createAssetProfileDialog: true }" /> <gf-fab
[routerLink]="
internalRoutes.adminControl.subRoutes.marketData.subRoutes.create
.routerLink
"
/>
</div> </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 }) @Service({ autoProvided: false })
export class AdminMarketDataService { export class AdminMarketDataService {
private readonly refreshSubject = new Subject<void>();
private readonly adminService = inject(AdminService); private readonly adminService = inject(AdminService);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
public get refresh$() {
return this.refreshSubject.asObservable();
}
public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
const assetProfileDeleted = new Subject<void>(); const assetProfileDeleted = new Subject<void>();
@ -72,4 +78,8 @@ export class AdminMarketDataService {
return assetProfilesDeleted.asObservable(); 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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
ASSET_CLASS_MAPPING, ASSET_CLASS_MAPPING,
COMMENT_MAXIMUM_LENGTH,
PROPERTY_IS_DATA_GATHERING_ENABLED PROPERTY_IS_DATA_GATHERING_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos'; import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
@ -229,6 +230,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected canEditAssetProfile = true; protected canEditAssetProfile = true;
protected readonly COMMENT_MAXIMUM_LENGTH = COMMENT_MAXIMUM_LENGTH;
protected countries: { protected countries: {
[code: string]: { name: string; value: number }; [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 cdkTextareaAutosize
formControlName="comment" formControlName="comment"
matInput matInput
[maxlength]="COMMENT_MAXIMUM_LENGTH"
(keyup.enter)="$event.stopPropagation()" (keyup.enter)="$event.stopPropagation()"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>

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

@ -187,17 +187,12 @@
</ng-container> </ng-container>
<ng-container matColumnDef="createdAt"> <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> <ng-container i18n>Creation</ng-container>
</th> </th>
<td <td *matCellDef="let element" class="px-1" mat-cell>
*matCellDef="let element"
class="px-1 text-right"
mat-cell
>
@if (element.createdAt) { @if (element.createdAt) {
<gf-value <gf-value
class="d-inline-block justify-content-end"
[isDate]="true" [isDate]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="element.createdAt" [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"> <div class="col-md-6 col-xs-12 d-flex justify-content-end">
<mat-form-field <mat-form-field
appearance="outline" appearance="outline"
class="w-100 without-hint" class="gf-form-field-dense w-100 without-hint"
color="accent" color="accent"
> >
<mat-label i18n>Compare with...</mat-label> <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 { :host {
display: block; display: block;
@include mat.form-field-overrides(
(
container-height: 2rem,
container-vertical-padding: 0.33rem
)
);
.chart-container { .chart-container {
aspect-ratio: 16 / 9; 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 quantityPrecision = 2;
protected reportDataGlitchMailHref: string; protected reportDataGlitchMailHref: string;
protected readonly round = round; protected readonly round = round;
protected readonly routerLinkAdminControlMarketData = protected readonly routerLinkAdminControlMarketDataUpdate =
internalRoutes.adminControl.subRoutes.marketData.routerLink; internalRoutes.adminControl.subRoutes.marketData.subRoutes.update
.routerLink;
protected sectors: { protected sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };

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

@ -358,7 +358,7 @@
</form> </form>
</div> </div>
@if (dataProviderInfo) { @if (dataProviderInfo?.name) {
<div class="col-md-12 mb-3 text-center"> <div class="col-md-12 mb-3 text-center">
<hr /> <hr />
<gf-data-provider-credits <gf-data-provider-credits
@ -484,12 +484,12 @@
@if (data.hasPermissionToAccessAdminControl) { @if (data.hasPermissionToAccessAdminControl) {
<a <a
mat-stroked-button mat-stroked-button
[queryParams]="{ [routerLink]="
assetProfileDialog: true, routerLinkAdminControlMarketDataUpdate(
dataSource: assetProfile?.dataSource, assetProfile?.dataSource,
symbol: assetProfile?.symbol assetProfile?.symbol
}" )
[routerLink]="routerLinkAdminControlMarketData" "
(click)="onClose()" (click)="onClose()"
><ion-icon class="mr-1" name="create-outline" /><span i18n ><ion-icon class="mr-1" name="create-outline" /><span i18n
>Manage Asset Profile</span >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() { private fetchHoldings() {
const filters = this.userService.getFilters(); const filters = this.userService.getFilters();
if (this.holdingType === 'CLOSED') { filters.push({ id: this.holdingType, type: 'HOLDING_TYPE' });
filters.push({ id: 'CLOSED', type: 'HOLDING_TYPE' });
}
return this.dataService.fetchPortfolioHoldings({ return this.dataService.fetchPortfolioHoldings({
filters, 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 { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
DEFAULT_LOCALE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
LineChartItem, LineChartItem,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -30,15 +33,18 @@ import {
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { MatCardModule } from '@angular/material/card';
import { Router, RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfHoldingsTableComponent,
GfLineChartComponent, GfLineChartComponent,
GfPortfolioPerformanceComponent, GfPortfolioPerformanceComponent,
MatButtonModule, MatButtonModule,
MatCardModule,
RouterModule RouterModule
], ],
selector: 'gf-home-overview', selector: 'gf-home-overview',
@ -46,9 +52,13 @@ import { DeviceDetectorService } from 'ngx-device-detector';
templateUrl: './home-overview.html' templateUrl: './home-overview.html'
}) })
export class GfHomeOverviewComponent implements OnInit { export class GfHomeOverviewComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected readonly errors = signal<AssetProfileIdentifier[]>([]); protected readonly errors = signal<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false); protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null); protected readonly historicalDataItems = signal<LineChartItem[] | null>(null);
protected readonly holdings = signal<PortfolioPosition[] | undefined>(
undefined
);
protected readonly isLoadingPerformance = signal(true); protected readonly isLoadingPerformance = signal(true);
protected readonly performance = signal<PortfolioPerformance | null>(null); protected readonly performance = signal<PortfolioPerformance | null>(null);
protected readonly performanceLabel = $localize`Performance`; protected readonly performanceLabel = $localize`Performance`;
@ -77,13 +87,24 @@ export class GfHomeOverviewComponent implements OnInit {
const user = this.user(); const user = this.user();
return user return user
? !user.settings.isRestrictedView && user.settings.viewMode !== 'ZEN' ? !user?.settings?.isRestrictedView && user?.settings?.viewMode !== 'ZEN'
: false; : false;
}); });
protected readonly showExperimentalHoldings = computed(() => {
const user = this.user();
return (
user?.settings?.isExperimentalFeatures === true &&
user?.settings?.viewMode !== 'ZEN'
);
});
protected readonly unit = computed(() => { protected readonly unit = computed(() => {
const user = this.user();
return this.showDetails() return this.showDetails()
? (this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY) ? (user?.settings?.baseCurrency ?? DEFAULT_CURRENCY)
: '%'; : '%';
}); });
@ -94,6 +115,7 @@ export class GfHomeOverviewComponent implements OnInit {
ImpersonationStorageService ImpersonationStorageService
); );
private readonly layoutService = inject(LayoutService); private readonly layoutService = inject(LayoutService);
private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { 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() { private update() {
this.historicalDataItems.set(null); this.historicalDataItems.set(null);
this.isLoadingPerformance.set(true); 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 this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
range: this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE range: this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE

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

@ -1,5 +1,5 @@
<div <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 ( @if (
!hasImpersonationId() && !hasImpersonationId() &&
@ -61,8 +61,8 @@
</div> </div>
</div> </div>
} @else { } @else {
<div class="row w-100"> <div class="align-items-stretch flex-nowrap row w-100">
<div class="col p-0"> <div class="col-12 col-xl p-0">
<div class="chart-container mx-auto position-relative"> <div class="chart-container mx-auto position-relative">
<gf-line-chart <gf-line-chart
class="position-absolute" class="position-absolute"
@ -80,21 +80,40 @@
[showYAxis]="false" [showYAxis]="false"
/> />
</div> </div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
[errors]="errors()"
[isLoading]="isLoadingPerformance()"
[locale]="user()?.settings?.locale"
[performance]="performance()"
[precision]="precision()"
[showDetails]="showDetails()"
[unit]="unit()"
/>
</div>
</div>
</div> </div>
</div> @if (showExperimentalHoldings()) {
<div class="overview-container row mt-1"> <div class="col-xl-4 d-none d-xl-block ml-5 p-0 position-relative">
<div class="col"> <mat-card
<gf-portfolio-performance appearance="outlined"
class="pb-4" class="h-100 holdings position-absolute w-100"
[errors]="errors()" >
[isLoading]="isLoadingPerformance()" <mat-card-header>
[locale]="user()?.settings?.locale" <mat-card-title i18n>Holdings</mat-card-title>
[performance]="performance()" </mat-card-header>
[precision]="precision()" <mat-card-content class="overflow-auto">
[showDetails]="showDetails()" <gf-holdings-table
[unit]="unit()" mode="simple"
/> [holdings]="holdings()"
</div> [locale]="user()?.settings?.locale ?? DEFAULT_LOCALE"
(holdingClicked)="onHoldingClicked($event)"
/>
</mat-card-content>
</mat-card>
</div>
}
</div> </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 { .introduction {
max-width: 50rem; max-width: 50rem;
} }

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

@ -6,6 +6,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
@ -23,12 +24,10 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog'; import { RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; import { HomeWatchlistService } from './home-watchlist.service';
import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -38,6 +37,7 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
RouterModule RouterModule
], ],
providers: [HomeWatchlistService],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-home-watchlist', selector: 'gf-home-watchlist',
styleUrls: ['./home-watchlist.scss'], styleUrls: ['./home-watchlist.scss'],
@ -45,6 +45,7 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
}) })
export class GfHomeWatchlistComponent implements OnInit { export class GfHomeWatchlistComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE; protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected readonly internalRoutes = internalRoutes;
protected hasPermissionToCreateWatchlistItem: boolean; protected hasPermissionToCreateWatchlistItem: boolean;
protected hasPermissionToDeleteWatchlistItem: boolean; protected hasPermissionToDeleteWatchlistItem: boolean;
@ -59,18 +60,14 @@ export class GfHomeWatchlistComponent implements OnInit {
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog); private readonly homeWatchlistService = inject(HomeWatchlistService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
this.route.queryParams this.homeWatchlistService.refresh$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe(() => {
if (params['createWatchlistItemDialog']) { this.loadWatchlistData();
this.openCreateWatchlistItemDialog();
}
}); });
this.userService.stateChanged 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>
</div> </div>
@if (hasPermissionToCreateWatchlistItem) { @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