Browse Source

Feature/data source in MCP tools (#8019)

* Extend tools with data source

* Update changelog
pull/7529/merge
Thomas Kaul 5 days ago
committed by GitHub
parent
commit
2364489ee4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 89
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  3. 24
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  4. 114
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  5. 31
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  6. 123
      apps/api/src/helper/data-source.helper.spec.ts
  7. 65
      apps/api/src/helper/data-source.helper.ts
  8. 5
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  9. 5
      apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts
  10. 4
      apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts
  11. 9
      apps/api/src/services/portfolio-table/portfolio-table.module.ts
  12. 196
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  13. 142
      apps/api/src/services/portfolio-table/portfolio-table.service.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the label of the cash positions in the holdings charts and table
- Extended the tools to get the activities, the portfolio and the watchlist in the server of the Model Context Protocol (MCP) to include the data source (experimental)
- Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0`
- Upgraded `ai` from version `7.0.37` to `7.0.114`
- Upgraded `dotenv` from version `17.4.2` to `18.0.3`

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

@ -1,3 +1,4 @@
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import {
COMMENT_MAXIMUM_LENGTH,
DEFAULT_DATE_RANGE,
@ -18,6 +19,8 @@ import {
} from './mcp.schemas';
import { createActivity } from './mcp.test-utils';
const UNKNOWN_ENCODED_DATA_SOURCE = 'ffffffff';
describe('GET_ACCOUNTS_PARAMETERS', () => {
it('Refuses an empty symbol of the holding', () => {
expect(
@ -34,6 +37,40 @@ describe('GET_ACCOUNTS_PARAMETERS', () => {
}).success
).toBe(false);
});
it('Accepts an encoded data source of the holding', () => {
expect(
GET_ACCOUNTS_PARAMETERS.safeParse({
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
}
}).success
).toBe(true);
});
it('Refuses an unknown encoded data source of the holding', () => {
expect(
GET_ACCOUNTS_PARAMETERS.safeParse({
holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' }
}).success
).toBe(false);
});
it('Gives the valid data sources in the error of an unknown data source of the holding', () => {
for (const dataSource of ['yahoo', 1]) {
const { error } = GET_ACCOUNTS_PARAMETERS.safeParse({
holding: { dataSource, symbol: 'AAPL' }
});
expect(error.issues).toEqual([
expect.objectContaining({
message: expect.stringContaining(`|${DataSource.YAHOO}`),
path: ['holding', 'dataSource']
})
]);
}
});
});
describe('GET_ACTIVITIES_PARAMETERS', () => {
@ -52,6 +89,25 @@ describe('GET_ACTIVITIES_PARAMETERS', () => {
}).success
).toBe(false);
});
it('Accepts an encoded data source of the holding', () => {
expect(
GET_ACTIVITIES_PARAMETERS.safeParse({
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
}
}).success
).toBe(true);
});
it('Refuses an unknown encoded data source of the holding', () => {
expect(
GET_ACTIVITIES_PARAMETERS.safeParse({
holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' }
}).success
).toBe(false);
});
});
describe('GET_PERFORMANCE_PARAMETERS', () => {
@ -86,6 +142,25 @@ describe('GET_PERFORMANCE_PARAMETERS', () => {
}).success
).toBe(false);
});
it('Accepts an encoded data source of the holding', () => {
expect(
GET_PERFORMANCE_PARAMETERS.safeParse({
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
}
}).success
).toBe(true);
});
it('Refuses an unknown encoded data source of the holding', () => {
expect(
GET_PERFORMANCE_PARAMETERS.safeParse({
holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' }
}).success
).toBe(false);
});
});
describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
@ -125,6 +200,20 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
expect(parse([createActivity({ currency: 'USD' })])).toBe(true);
});
it('Accepts an encoded data source', () => {
expect(
parse([
createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) })
])
).toBe(true);
});
it('Refuses an unknown encoded data source', () => {
expect(
parse([createActivity({ dataSource: UNKNOWN_ENCODED_DATA_SOURCE })])
).toBe(false);
});
it('Refuses a date at or before the epoch', () => {
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false);
});

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

@ -1,4 +1,5 @@
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { isValidEncodedDataSource } from '@ghostfolio/api/helper/data-source.helper';
import {
COMMENT_MAXIMUM_LENGTH,
DATE_RANGES,
@ -17,6 +18,20 @@ import {
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { z } from 'zod';
const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`;
const DATA_SOURCE_PARAMETER = z
.union(
[
z.enum(DataSource),
z.string().refine(isValidEncodedDataSource, {
error: DATA_SOURCE_PARAMETER_ERROR
})
],
{ error: DATA_SOURCE_PARAMETER_ERROR }
)
.describe('The data source of the asset profile');
const SYMBOL_PARAMETER = z
.string()
.trim()
@ -25,9 +40,7 @@ const SYMBOL_PARAMETER = z
.describe('The symbol of the asset profile');
const HOLDING_PARAMETER = z.object({
dataSource: z
.enum(DataSource)
.describe('The data source of the asset profile'),
dataSource: DATA_SOURCE_PARAMETER,
symbol: SYMBOL_PARAMETER
});
@ -137,10 +150,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
.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'),
dataSource: DATA_SOURCE_PARAMETER.optional(),
date: z
.string()
.refine(isValidDateAfter1970)

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

@ -2,6 +2,7 @@ import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-
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 { encodeDataSource } 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';
@ -128,6 +129,22 @@ describe('McpService', () => {
);
});
it('Decodes an encoded data source', async () => {
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await mcpService.getAccounts({
userId,
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
}
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith(
expect.objectContaining({ filterByDataSource: DataSource.YAHOO })
);
});
it('Keeps the data source if the subscription is not enabled', async () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
@ -192,6 +209,21 @@ describe('McpService', () => {
);
});
it('Decodes an encoded data source', async () => {
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await getActivities({
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
}
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith(
expect.objectContaining({ filterByDataSource: DataSource.YAHOO })
);
});
it('Keeps the data source if the subscription is not enabled', async () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
@ -275,6 +307,23 @@ describe('McpService', () => {
);
});
it('Decodes an encoded data source', async () => {
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await mcpService.getPerformance({
userId,
holding: {
dataSource: encodeDataSource(DataSource.YAHOO),
symbol: 'AAPL'
},
range: 'max'
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith(
expect.objectContaining({ filterByDataSource: DataSource.YAHOO })
);
});
it('Keeps the data source if the subscription is not enabled', async () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
@ -304,14 +353,15 @@ describe('McpService', () => {
});
describe('getPortfolio', () => {
it('Gives the table of the holdings in the default language', async () => {
it('Gives the table of the holdings with the data source 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
languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true
});
});
});
@ -342,9 +392,6 @@ describe('McpService', () => {
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: [
{
@ -383,12 +430,46 @@ describe('McpService', () => {
assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK,
currency: 'USD',
dataSource: DataSource.GHOSTFOLIO,
dataSource: DataSource.YAHOO,
name: 'Apple Inc.',
symbol: 'AAPL'
}
]
});
});
it('Encodes the data source if the subscription is enabled', async () => {
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'
}
]
});
const result = await mcpService.searchAssetProfiles({
query: 'Apple',
userId
});
expect(JSON.parse(result.content[0].text)).toEqual({
assetProfiles: [
expect.objectContaining({
dataSource: encodeDataSource(DataSource.YAHOO)
})
]
});
});
});
@ -448,6 +529,27 @@ describe('McpService', () => {
);
});
it('Decodes an encoded data source', async () => {
setupUser([permissions.createActivity]);
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await mcpService.importActivities({
userId,
activities: [
createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) })
]
});
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]);

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

@ -2,8 +2,8 @@ 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
transformDataSourceInRequest,
transformDataSourceInResponse
} 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';
@ -121,7 +121,8 @@ export class McpService {
public async getPortfolio({ userId }: { userId: string }) {
const table = await this.portfolioTableService.getHoldingsTable({
userId,
languageCode: DEFAULT_LANGUAGE_CODE
languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true
});
return this.getTextResult(table);
@ -144,13 +145,11 @@ export class McpService {
permission: permissions.createActivity
});
const ghostfolioDataSources = this.getGhostfolioDataSources();
const activitiesDto = activities.map((activity) => {
return {
...activity,
dataSource: getUnmaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: transformDataSourceInRequest({
configurationService: this.configurationService,
dataSource: activity.dataSource
})
};
@ -188,8 +187,6 @@ export class McpService {
const { items } = await this.symbolService.lookup({ query, user });
const ghostfolioDataSources = this.getGhostfolioDataSources();
const assetProfiles = items.flatMap(
({
assetClass,
@ -211,9 +208,9 @@ export class McpService {
currency,
name,
symbol,
dataSource: getMaskedGhostfolioDataSource({
dataSource: transformDataSourceInResponse({
dataSource,
ghostfolioDataSources
configurationService: this.configurationService
})
}
];
@ -223,19 +220,13 @@ export class McpService {
return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2));
}
private getGhostfolioDataSources() {
return this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
}
private getHoldingFilterParameters({
holding
}: Pick<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) {
return {
filterByDataSource: getUnmaskedGhostfolioDataSource({
dataSource: holding?.dataSource,
ghostfolioDataSources: this.getGhostfolioDataSources()
filterByDataSource: transformDataSourceInRequest({
configurationService: this.configurationService,
dataSource: holding?.dataSource
}),
filterBySymbol: holding?.symbol
};

123
apps/api/src/helper/data-source.helper.spec.ts

@ -0,0 +1,123 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataSource } from '@prisma/client';
import {
encodeDataSource,
isValidEncodedDataSource,
transformDataSourceInRequest,
transformDataSourceInResponse
} from './data-source.helper';
function createConfigurationService({
isSubscriptionEnabled
}: {
isSubscriptionEnabled: boolean;
}) {
const configuration: Record<string, unknown> = {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [DataSource.YAHOO],
ENABLE_FEATURE_SUBSCRIPTION: isSubscriptionEnabled
};
return {
get: jest.fn((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService;
}
describe('DataSourceHelper', () => {
describe('isValidEncodedDataSource', () => {
it('Accepts an encoded data source', () => {
expect(isValidEncodedDataSource(encodeDataSource(DataSource.YAHOO))).toBe(
true
);
});
it('Refuses a data source which is not encoded', () => {
expect(isValidEncodedDataSource(DataSource.YAHOO)).toBe(false);
});
it('Refuses an unknown encoded data source', () => {
expect(isValidEncodedDataSource('ffffffff')).toBe(false);
});
});
describe('transformDataSourceInRequest', () => {
const configurationService = createConfigurationService({
isSubscriptionEnabled: true
});
it('Decodes an encoded data source', () => {
expect(
transformDataSourceInRequest({
configurationService,
dataSource: encodeDataSource(DataSource.COINGECKO)
})
).toBe(DataSource.COINGECKO);
});
it('Resolves the mask of the data source of the Ghostfolio data provider', () => {
expect(
transformDataSourceInRequest({
configurationService,
dataSource: DataSource.GHOSTFOLIO
})
).toBe(DataSource.YAHOO);
});
it('Keeps a data source which is not encoded', () => {
expect(
transformDataSourceInRequest({
configurationService,
dataSource: DataSource.COINGECKO
})
).toBe(DataSource.COINGECKO);
});
it('Keeps an absent data source', () => {
expect(
transformDataSourceInRequest({
configurationService,
dataSource: undefined
})
).toBeUndefined();
});
});
describe('transformDataSourceInResponse', () => {
it('Encodes the data source if the subscription is enabled', () => {
expect(
transformDataSourceInResponse({
configurationService: createConfigurationService({
isSubscriptionEnabled: true
}),
dataSource: DataSource.YAHOO
})
).toBe(encodeDataSource(DataSource.YAHOO));
});
it('Keeps the data sources GHOSTFOLIO and MANUAL if the subscription is enabled', () => {
const configurationService = createConfigurationService({
isSubscriptionEnabled: true
});
for (const dataSource of [DataSource.GHOSTFOLIO, DataSource.MANUAL]) {
expect(
transformDataSourceInResponse({ configurationService, dataSource })
).toBe(dataSource);
}
});
it('Keeps the data source if the subscription is not enabled', () => {
expect(
transformDataSourceInResponse({
configurationService: createConfigurationService({
isSubscriptionEnabled: false
}),
dataSource: DataSource.YAHOO
})
).toBe(DataSource.YAHOO);
});
});
});

65
apps/api/src/helper/data-source.helper.ts

@ -1,6 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataSource } from '@prisma/client';
import { createHash } from 'node:crypto';
const DATA_SOURCES_WITHOUT_ENCODING: DataSource[] = [
DataSource.GHOSTFOLIO,
DataSource.MANUAL
];
const encodedDataSourceByDataSource = new Map<DataSource, string>(
Object.values(DataSource).map((dataSource) => {
return [dataSource, hashDataSource(dataSource)];
@ -30,6 +37,16 @@ function deprecatedHashDataSource(dataSource: DataSource) {
return Buffer.from(dataSource, 'utf-8').toString('hex');
}
function getGhostfolioDataSources({
configurationService
}: {
configurationService: ConfigurationService;
}) {
return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')
? configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
}
function hashDataSource(dataSource: DataSource) {
return createHash('sha256').update(dataSource).digest('hex').slice(0, 8);
}
@ -77,3 +94,51 @@ export function getUnmaskedGhostfolioDataSource({
? (ghostfolioDataSources[0] as DataSource)
: dataSource;
}
export function isDataSourceEncodedInResponse(dataSource: DataSource) {
return !DATA_SOURCES_WITHOUT_ENCODING.includes(dataSource);
}
export function isValidEncodedDataSource(encodedDataSource: string) {
return dataSourceByEncodedDataSource.has(encodedDataSource);
}
/**
* Gives the data source of a request without a transformation: an encoded data
* source is decoded, and the mask of the Ghostfolio data provider is resolved
* (see the TransformDataSourceInRequestInterceptor)
*/
export function transformDataSourceInRequest({
configurationService,
dataSource
}: {
configurationService: ConfigurationService;
dataSource?: string;
}) {
if (Object.hasOwn(DataSource, dataSource)) {
return getUnmaskedGhostfolioDataSource({
dataSource: dataSource as DataSource,
ghostfolioDataSources: getGhostfolioDataSources({ configurationService })
});
}
return decodeDataSource(dataSource) as DataSource;
}
/**
* Gives the data source as a response gives it to a user who is not an admin:
* encoded if the subscription is enabled, except the data sources GHOSTFOLIO
* and MANUAL (see the TransformDataSourceInResponseInterceptor)
*/
export function transformDataSourceInResponse({
configurationService,
dataSource
}: {
configurationService: ConfigurationService;
dataSource: DataSource;
}) {
return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
isDataSourceEncodedInResponse(dataSource)
? encodeDataSource(dataSource)
: dataSource;
}

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

@ -1,6 +1,7 @@
import {
encodeDataSource,
getMaskedGhostfolioDataSource
getMaskedGhostfolioDataSource,
isDataSourceEncodedInResponse
} from '@ghostfolio/api/helper/data-source.helper';
import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -30,7 +31,7 @@ export class TransformDataSourceInResponseInterceptor<
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.encodedDataSourceMap = Object.keys(DataSource).reduce(
(encodedDataSourceMap, dataSource) => {
if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) {
if (isDataSourceEncodedInResponse(DataSource[dataSource])) {
encodedDataSourceMap[dataSource] = encodeDataSource(
DataSource[dataSource]
);

5
apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts

@ -0,0 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
export interface DataSourceTableContext {
configurationService: ConfigurationService;
}

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

@ -1,6 +1,8 @@
import { AssetClass, AssetSubClass } from '@prisma/client';
export interface HoldingsTableContext {
import { DataSourceTableContext } from './data-source-table-context.interface';
export interface HoldingsTableContext extends DataSourceTableContext {
assetClassTranslations: Record<AssetClass, string>;
assetSubClassTranslations: Record<AssetSubClass, string>;
}

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

@ -1,6 +1,7 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common';
@ -9,7 +10,13 @@ import { PortfolioTableService } from './portfolio-table.service';
@Module({
exports: [PortfolioTableService],
imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule],
imports: [
ActivitiesModule,
ConfigurationModule,
I18nModule,
PortfolioModule,
WatchlistModule
],
providers: [PortfolioTableService]
})
export class PortfolioTableModule {}

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

@ -1,12 +1,16 @@
import type { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import type { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service';
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface';
import type { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import {
DEFAULT_LANGUAGE_CODE,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import {
Activity,
HistoricalDataItem,
PortfolioPerformanceResponse,
PortfolioPosition,
@ -60,6 +64,8 @@ jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => {
};
});
const SUBSCRIPTION_CONFIGURATION = { ENABLE_FEATURE_SUBSCRIPTION: true };
function createAccount({
id = 'account-a-id',
isExcluded = false,
@ -82,15 +88,39 @@ function createAccount({
} as unknown as AccountWithValue;
}
function createActivity({
dataSource = DataSource.YAHOO,
symbol = 'AAPL'
}: {
dataSource?: DataSource;
symbol?: string;
} = {}) {
return {
account: { name: 'Account A' },
assetProfile: {
dataSource,
symbol,
currency: 'CHF',
name: `Name of ${symbol}`
},
currency: 'CHF',
date: new Date('2024-01-01'),
type: 'BUY',
unitPrice: 100
} as unknown as Activity;
}
function createHolding({
allocationInPercentage = 0.75,
assetClass = AssetClass.EQUITY,
assetSubClass = AssetSubClass.STOCK,
dataSource = DataSource.YAHOO,
symbol = 'AAPL'
}: {
allocationInPercentage?: number;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
dataSource?: DataSource;
symbol?: string;
} = {}) {
return {
@ -99,6 +129,7 @@ function createHolding({
assetProfile: {
assetClass,
assetSubClass,
dataSource,
symbol,
currency: 'CHF',
name: `Name of ${symbol}`
@ -132,18 +163,20 @@ function createPerformance({
}
function createWatchlistItem({
dataSource = DataSource.YAHOO,
name = 'Name of AAPL',
performancePercent = -0.25,
symbol = 'AAPL'
}: {
dataSource?: DataSource;
name?: string;
performancePercent?: number;
symbol?: string;
} = {}): WatchlistResponse['watchlist'][number] {
return {
dataSource,
name,
symbol,
dataSource: DataSource.YAHOO,
marketCondition: 'BEAR_MARKET',
performances: {
allTimeHigh: {
@ -158,17 +191,33 @@ function createWatchlistItem({
function createPortfolioTableService({
accounts = [],
activities = [],
chart = [{ date: '2024-01-01' }],
configuration = { ENABLE_FEATURE_SUBSCRIPTION: false },
holdings = [],
performance = createPerformance(),
watchlist = []
}: {
accounts?: AccountWithValue[];
activities?: Activity[];
chart?: HistoricalDataItem[];
configuration?: Record<string, unknown>;
holdings?: PortfolioPosition[];
performance?: PortfolioPerformanceResponse['performance'];
watchlist?: WatchlistResponse['watchlist'];
} = {}) {
const activitiesService = {
getActivities: jest
.fn()
.mockResolvedValue({ activities, count: activities.length })
} as unknown as ActivitiesService;
const configurationService = {
get: jest.fn((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService;
// 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 = {
@ -188,7 +237,8 @@ function createPortfolioTableService({
} as unknown as WatchlistService;
return new PortfolioTableService(
null,
activitiesService,
configurationService,
i18nService,
portfolioService,
watchlistService
@ -220,6 +270,7 @@ describe('PortfolioTableService', () => {
'Type',
'Name',
'Symbol',
'Data Source',
'Currency',
'Unit Price',
'Account'
@ -232,6 +283,7 @@ describe('PortfolioTableService', () => {
expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([
'Name',
'Symbol',
'Data Source',
'Currency',
'Asset Class',
'Asset Sub Class',
@ -257,6 +309,7 @@ describe('PortfolioTableService', () => {
expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([
'Name',
'Symbol',
'Data Source',
'Trend 50 Days',
'Trend 200 Days',
'Date of Last All Time High',
@ -327,14 +380,118 @@ describe('PortfolioTableService', () => {
expect(result).toContain('No accounts found.');
});
});
describe('getActivitiesTable', () => {
it('gives the data source, encoded if the subscription is enabled', async () => {
const result = await createPortfolioTableService({
activities: [
createActivity({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }),
createActivity({
dataSource: DataSource.MANUAL,
symbol: 'GF_GOLD'
})
],
configuration: SUBSCRIPTION_CONFIGURATION
}).getActivitiesTable({
take: 50,
userCurrency: 'CHF',
userId: 'user-id'
});
const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => {
return line.startsWith('| 2024-01-01');
});
expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`);
expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`);
});
});
describe('getHoldingsTable', () => {
function getHoldingsTable(holdings: PortfolioPosition[]) {
return createPortfolioTableService({ holdings }).getHoldingsTable({
function getHoldingsTable(
holdings: PortfolioPosition[],
{
configuration,
withDataSource
}: {
configuration?: Record<string, unknown>;
withDataSource?: boolean;
} = {}
) {
return createPortfolioTableService({
configuration,
holdings
}).getHoldingsTable({
withDataSource,
languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id'
});
}
it('gives no data source by default', async () => {
const result = await getHoldingsTable([createHolding()], {
configuration: SUBSCRIPTION_CONFIGURATION
});
expect(result).not.toContain('Data Source');
expect(result).not.toContain(encodeDataSource(DataSource.YAHOO));
});
it('gives the data source, encoded if the subscription is enabled', async () => {
const result = await getHoldingsTable(
[
createHolding({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }),
createHolding({
allocationInPercentage: 0.25,
dataSource: DataSource.MANUAL,
symbol: 'GF_GOLD'
})
],
{ configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true }
);
const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => {
return line.startsWith('| Name of');
});
expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`);
expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`);
});
it('gives the data source unencoded if the subscription is disabled', async () => {
const result = await getHoldingsTable([createHolding()], {
configuration: { ENABLE_FEATURE_SUBSCRIPTION: false },
withDataSource: true
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(row).toContain(`| ${DataSource.YAHOO} |`);
});
it('gives no data source of a cash position', async () => {
const result = await getHoldingsTable(
[
createHolding({
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
dataSource: DataSource.YAHOO,
symbol: 'USD'
})
],
{ configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true }
);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of USD');
});
expect(row).toContain('| USD | | CHF |');
expect(row).not.toContain(encodeDataSource(DataSource.YAHOO));
});
it('gives the translation of the asset class and of the asset sub class', async () => {
const result = await getHoldingsTable([createHolding()]);
@ -417,12 +574,39 @@ describe('PortfolioTableService', () => {
});
describe('getWatchlistTable', () => {
function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) {
return createPortfolioTableService({ watchlist }).getWatchlistTable({
function getWatchlistTable(
watchlist: WatchlistResponse['watchlist'],
configuration?: Record<string, unknown>
) {
return createPortfolioTableService({
configuration,
watchlist
}).getWatchlistTable({
userId: 'user-id'
});
}
it('gives the data source, encoded if the subscription is enabled', async () => {
const result = await getWatchlistTable(
[
createWatchlistItem(),
createWatchlistItem({
dataSource: DataSource.MANUAL,
name: 'Name of GF_GOLD',
symbol: 'GF_GOLD'
})
],
SUBSCRIPTION_CONFIGURATION
);
const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => {
return line.startsWith('| Name of');
});
expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`);
expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`);
});
it('gives the date and the change of the all time high', async () => {
const result = await getWatchlistTable([createWatchlistItem()]);

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

@ -1,10 +1,16 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { transformDataSourceInResponse } from '@ghostfolio/api/helper/data-source.helper';
import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
isAccountExcluded,
isCashPosition
} from '@ghostfolio/common/helper';
import {
Activity,
Filter,
@ -17,12 +23,31 @@ import { Injectable } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
Type as ActivityType
} from '@prisma/client';
import { format } from 'date-fns';
import { DataSourceTableContext } from './interfaces/data-source-table-context.interface';
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
const DATA_SOURCE_COLUMN_NAME = 'Data Source';
function getDataSourceColumnDefinition<T>(
getDataSource: (row: T) => DataSource | undefined
): TableColumnDefinition<T, DataSourceTableContext> {
return {
getValue: (row, { configurationService }) => {
const dataSource = getDataSource(row);
return dataSource
? transformDataSourceInResponse({ configurationService, dataSource })
: '';
},
name: DATA_SOURCE_COLUMN_NAME
};
}
function getPercentage(value: number) {
return `${(value * 100).toFixed(3)}%`;
}
@ -83,52 +108,57 @@ export class PortfolioTableService {
}
];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<Activity>[] =
[
{
getValue: ({ date }) => {
return format(date, DATE_FORMAT);
},
name: 'Date'
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
Activity,
DataSourceTableContext
>[] = [
{
getValue: ({ date }) => {
return format(date, DATE_FORMAT);
},
{
getValue: ({ type }) => {
return type;
},
name: 'Type'
name: 'Date'
},
{
getValue: ({ type }) => {
return type;
},
{
getValue: ({ assetProfile }) => {
return assetProfile.name ?? '';
},
name: 'Name'
name: 'Type'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.name ?? '';
},
{
getValue: ({ assetProfile }) => {
return assetProfile.symbol;
},
name: 'Symbol'
name: 'Name'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.symbol;
},
{
getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency;
},
name: 'Currency'
name: 'Symbol'
},
getDataSourceColumnDefinition(({ assetProfile }) => {
return assetProfile.dataSource;
}),
{
getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency;
},
{
align: 'right',
getValue: ({ unitPrice }) => {
return unitPrice.toString();
},
name: 'Unit Price'
name: 'Currency'
},
{
align: 'right',
getValue: ({ unitPrice }) => {
return unitPrice.toString();
},
{
getValue: ({ account }) => {
return account?.name ?? '';
},
name: 'Account'
}
];
name: 'Unit Price'
},
{
getValue: ({ account }) => {
return account?.name ?? '';
},
name: 'Account'
}
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[
@ -144,6 +174,11 @@ export class PortfolioTableService {
},
name: 'Symbol'
},
getDataSourceColumnDefinition(({ assetProfile }) => {
return isCashPosition(assetProfile)
? undefined
: assetProfile.dataSource;
}),
{
getValue: ({ assetProfile }) => {
return assetProfile.currency;
@ -222,7 +257,8 @@ export class PortfolioTableService {
];
private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
WatchlistResponse['watchlist'][number]
WatchlistResponse['watchlist'][number],
DataSourceTableContext
>[] = [
{
getValue: ({ name }) => {
@ -236,6 +272,9 @@ export class PortfolioTableService {
},
name: 'Symbol'
},
getDataSourceColumnDefinition(({ dataSource }) => {
return dataSource;
}),
{
getValue: ({ trend50d }) => {
return trend50d;
@ -273,6 +312,7 @@ export class PortfolioTableService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService,
private readonly watchlistService: WatchlistService
@ -399,6 +439,7 @@ export class PortfolioTableService {
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
context: { configurationService: this.configurationService },
rows: activities
})
);
@ -410,11 +451,13 @@ export class PortfolioTableService {
public async getHoldingsTable({
filters,
languageCode,
userId
userId,
withDataSource = false
}: {
filters?: Filter[];
languageCode: string;
userId: string;
withDataSource?: boolean;
}) {
const { holdings } = await this.portfolioService.getDetails({
filters,
@ -442,8 +485,16 @@ export class PortfolioTableService {
'',
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
context: { assetClassTranslations, assetSubClassTranslations },
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter(
({ name }) => {
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME;
}
),
context: {
assetClassTranslations,
assetSubClassTranslations,
configurationService: this.configurationService
},
rows: sortedHoldings
})
].join('\n');
@ -491,6 +542,7 @@ export class PortfolioTableService {
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS,
context: { configurationService: this.configurationService },
rows: watchlist
})
);

Loading…
Cancel
Save