Browse Source

Extend tools with data source

pull/8019/head
Thomas Kaul 2 weeks ago
parent
commit
8c0ba18ca0
  1. 74
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  2. 14
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  3. 111
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  4. 28
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  5. 123
      apps/api/src/helper/data-source.helper.spec.ts
  6. 65
      apps/api/src/helper/data-source.helper.ts
  7. 5
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  8. 5
      apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts
  9. 4
      apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts
  10. 9
      apps/api/src/services/portfolio-table/portfolio-table.module.ts
  11. 159
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  12. 48
      apps/api/src/services/portfolio-table/portfolio-table.service.ts

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

@ -1,3 +1,4 @@
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { import {
COMMENT_MAXIMUM_LENGTH, COMMENT_MAXIMUM_LENGTH,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
@ -18,6 +19,8 @@ import {
} from './mcp.schemas'; } from './mcp.schemas';
import { createActivity } from './mcp.test-utils'; import { createActivity } from './mcp.test-utils';
const UNKNOWN_ENCODED_DATA_SOURCE = 'ffffffff';
describe('GET_ACCOUNTS_PARAMETERS', () => { describe('GET_ACCOUNTS_PARAMETERS', () => {
it('Refuses an empty symbol of the holding', () => { it('Refuses an empty symbol of the holding', () => {
expect( expect(
@ -34,6 +37,25 @@ describe('GET_ACCOUNTS_PARAMETERS', () => {
}).success }).success
).toBe(false); ).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);
});
}); });
describe('GET_ACTIVITIES_PARAMETERS', () => { describe('GET_ACTIVITIES_PARAMETERS', () => {
@ -52,6 +74,25 @@ describe('GET_ACTIVITIES_PARAMETERS', () => {
}).success }).success
).toBe(false); ).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', () => { describe('GET_PERFORMANCE_PARAMETERS', () => {
@ -86,6 +127,25 @@ describe('GET_PERFORMANCE_PARAMETERS', () => {
}).success }).success
).toBe(false); ).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', () => { describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
@ -125,6 +185,20 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
expect(parse([createActivity({ currency: 'USD' })])).toBe(true); 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', () => { it('Refuses a date at or before the epoch', () => {
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false); expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false);
}); });

14
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 { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { isValidEncodedDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { import {
COMMENT_MAXIMUM_LENGTH, COMMENT_MAXIMUM_LENGTH,
DATE_RANGES, DATE_RANGES,
@ -17,6 +18,10 @@ import {
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
const DATA_SOURCE_PARAMETER = z
.union([z.enum(DataSource), z.string().refine(isValidEncodedDataSource)])
.describe('The data source of the asset profile');
const SYMBOL_PARAMETER = z const SYMBOL_PARAMETER = z
.string() .string()
.trim() .trim()
@ -25,9 +30,7 @@ const SYMBOL_PARAMETER = z
.describe('The symbol of the asset profile'); .describe('The symbol of the asset profile');
const HOLDING_PARAMETER = z.object({ const HOLDING_PARAMETER = z.object({
dataSource: z dataSource: DATA_SOURCE_PARAMETER,
.enum(DataSource)
.describe('The data source of the asset profile'),
symbol: SYMBOL_PARAMETER symbol: SYMBOL_PARAMETER
}); });
@ -137,10 +140,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
.describe( .describe(
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' 'The currency of the fee and of the unit price, as an ISO 4217 code in upper case'
), ),
dataSource: z dataSource: DATA_SOURCE_PARAMETER.optional(),
.enum(DataSource)
.optional()
.describe('The data source of the asset profile'),
date: z date: z
.string() .string()
.refine(isValidDateAfter1970) .refine(isValidDateAfter1970)

111
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 { ImportService } from '@ghostfolio/api/app/import/import.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.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 () => { it('Keeps the data source if the subscription is not enabled', async () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false; 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 () => { it('Keeps the data source if the subscription is not enabled', async () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false; 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 () => { it('Keeps the data source if the subscription is not enabled', async () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false; configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
@ -342,9 +391,6 @@ describe('McpService', () => {
it('Gives the import-ready asset profiles available to the user', async () => { it('Gives the import-ready asset profiles available to the user', async () => {
const user = setupUser([permissions.createActivity]); const user = setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
jest.spyOn(symbolService, 'lookup').mockResolvedValue({ jest.spyOn(symbolService, 'lookup').mockResolvedValue({
items: [ items: [
{ {
@ -383,12 +429,48 @@ describe('McpService', () => {
assetClass: AssetClass.EQUITY, assetClass: AssetClass.EQUITY,
assetSubClass: AssetSubClass.STOCK, assetSubClass: AssetSubClass.STOCK,
currency: 'USD', currency: 'USD',
dataSource: DataSource.GHOSTFOLIO, dataSource: DataSource.YAHOO,
name: 'Apple Inc.',
symbol: 'AAPL'
}
]
});
});
// A response of the instance encodes the data source for a user who is
// not an admin, and the client of the access is not a user
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.', name: 'Apple Inc.',
symbol: 'AAPL' 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 +530,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 () => { it('Keeps the data source if the subscription is not enabled', async () => {
setupUser([permissions.createActivity]); setupUser([permissions.createActivity]);

28
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 { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
getMaskedGhostfolioDataSource, transformDataSourceInRequest,
getUnmaskedGhostfolioDataSource transformDataSourceInResponse
} from '@ghostfolio/api/helper/data-source.helper'; } from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -144,13 +144,11 @@ export class McpService {
permission: permissions.createActivity permission: permissions.createActivity
}); });
const ghostfolioDataSources = this.getGhostfolioDataSources();
const activitiesDto = activities.map((activity) => { const activitiesDto = activities.map((activity) => {
return { return {
...activity, ...activity,
dataSource: getUnmaskedGhostfolioDataSource({ dataSource: transformDataSourceInRequest({
ghostfolioDataSources, configurationService: this.configurationService,
dataSource: activity.dataSource dataSource: activity.dataSource
}) })
}; };
@ -188,8 +186,6 @@ export class McpService {
const { items } = await this.symbolService.lookup({ query, user }); const { items } = await this.symbolService.lookup({ query, user });
const ghostfolioDataSources = this.getGhostfolioDataSources();
const assetProfiles = items.flatMap( const assetProfiles = items.flatMap(
({ ({
assetClass, assetClass,
@ -211,9 +207,9 @@ export class McpService {
currency, currency,
name, name,
symbol, symbol,
dataSource: getMaskedGhostfolioDataSource({ dataSource: transformDataSourceInResponse({
dataSource, dataSource,
ghostfolioDataSources configurationService: this.configurationService
}) })
} }
]; ];
@ -223,19 +219,13 @@ export class McpService {
return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); 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({ private getHoldingFilterParameters({
holding holding
}: Pick<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) { }: Pick<z.infer<typeof GET_ACCOUNTS_PARAMETERS>, 'holding'>) {
return { return {
filterByDataSource: getUnmaskedGhostfolioDataSource({ filterByDataSource: transformDataSourceInRequest({
dataSource: holding?.dataSource, configurationService: this.configurationService,
ghostfolioDataSources: this.getGhostfolioDataSources() dataSource: holding?.dataSource
}), }),
filterBySymbol: holding?.symbol 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 { DataSource } from '@prisma/client';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
const DATA_SOURCES_WITHOUT_ENCODING: DataSource[] = [
DataSource.GHOSTFOLIO,
DataSource.MANUAL
];
const encodedDataSourceByDataSource = new Map<DataSource, string>( const encodedDataSourceByDataSource = new Map<DataSource, string>(
Object.values(DataSource).map((dataSource) => { Object.values(DataSource).map((dataSource) => {
return [dataSource, hashDataSource(dataSource)]; return [dataSource, hashDataSource(dataSource)];
@ -30,6 +37,16 @@ function deprecatedHashDataSource(dataSource: DataSource) {
return Buffer.from(dataSource, 'utf-8').toString('hex'); 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) { function hashDataSource(dataSource: DataSource) {
return createHash('sha256').update(dataSource).digest('hex').slice(0, 8); return createHash('sha256').update(dataSource).digest('hex').slice(0, 8);
} }
@ -77,3 +94,51 @@ export function getUnmaskedGhostfolioDataSource({
? (ghostfolioDataSources[0] as DataSource) ? (ghostfolioDataSources[0] as DataSource)
: 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 (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 { import {
encodeDataSource, encodeDataSource,
getMaskedGhostfolioDataSource getMaskedGhostfolioDataSource,
isDataSourceEncodedInResponse
} from '@ghostfolio/api/helper/data-source.helper'; } from '@ghostfolio/api/helper/data-source.helper';
import { redactPaths } from '@ghostfolio/api/helper/object.helper'; import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -30,7 +31,7 @@ export class TransformDataSourceInResponseInterceptor<
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.encodedDataSourceMap = Object.keys(DataSource).reduce( this.encodedDataSourceMap = Object.keys(DataSource).reduce(
(encodedDataSourceMap, dataSource) => { (encodedDataSourceMap, dataSource) => {
if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) { if (isDataSourceEncodedInResponse(DataSource[dataSource])) {
encodedDataSourceMap[dataSource] = encodeDataSource( encodedDataSourceMap[dataSource] = encodeDataSource(
DataSource[dataSource] 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'; 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>; assetClassTranslations: Record<AssetClass, string>;
assetSubClassTranslations: Record<AssetSubClass, 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 { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module'; import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -9,7 +10,13 @@ import { PortfolioTableService } from './portfolio-table.service';
@Module({ @Module({
exports: [PortfolioTableService], exports: [PortfolioTableService],
imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule], imports: [
ActivitiesModule,
ConfigurationModule,
I18nModule,
PortfolioModule,
WatchlistModule
],
providers: [PortfolioTableService] providers: [PortfolioTableService]
}) })
export class PortfolioTableModule {} export class PortfolioTableModule {}

159
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 { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service';
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { 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 type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
Activity,
HistoricalDataItem, HistoricalDataItem,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
@ -60,6 +64,8 @@ jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => {
}; };
}); });
const SUBSCRIPTION_CONFIGURATION = { ENABLE_FEATURE_SUBSCRIPTION: true };
function createAccount({ function createAccount({
id = 'account-a-id', id = 'account-a-id',
isExcluded = false, isExcluded = false,
@ -82,15 +88,39 @@ function createAccount({
} as unknown as AccountWithValue; } 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({ function createHolding({
allocationInPercentage = 0.75, allocationInPercentage = 0.75,
assetClass = AssetClass.EQUITY, assetClass = AssetClass.EQUITY,
assetSubClass = AssetSubClass.STOCK, assetSubClass = AssetSubClass.STOCK,
dataSource = DataSource.YAHOO,
symbol = 'AAPL' symbol = 'AAPL'
}: { }: {
allocationInPercentage?: number; allocationInPercentage?: number;
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
dataSource?: DataSource;
symbol?: string; symbol?: string;
} = {}) { } = {}) {
return { return {
@ -99,6 +129,7 @@ function createHolding({
assetProfile: { assetProfile: {
assetClass, assetClass,
assetSubClass, assetSubClass,
dataSource,
symbol, symbol,
currency: 'CHF', currency: 'CHF',
name: `Name of ${symbol}` name: `Name of ${symbol}`
@ -132,18 +163,20 @@ function createPerformance({
} }
function createWatchlistItem({ function createWatchlistItem({
dataSource = DataSource.YAHOO,
name = 'Name of AAPL', name = 'Name of AAPL',
performancePercent = -0.25, performancePercent = -0.25,
symbol = 'AAPL' symbol = 'AAPL'
}: { }: {
dataSource?: DataSource;
name?: string; name?: string;
performancePercent?: number; performancePercent?: number;
symbol?: string; symbol?: string;
} = {}): WatchlistResponse['watchlist'][number] { } = {}): WatchlistResponse['watchlist'][number] {
return { return {
dataSource,
name, name,
symbol, symbol,
dataSource: DataSource.YAHOO,
marketCondition: 'BEAR_MARKET', marketCondition: 'BEAR_MARKET',
performances: { performances: {
allTimeHigh: { allTimeHigh: {
@ -158,17 +191,33 @@ function createWatchlistItem({
function createPortfolioTableService({ function createPortfolioTableService({
accounts = [], accounts = [],
activities = [],
chart = [{ date: '2024-01-01' }], chart = [{ date: '2024-01-01' }],
configuration = { ENABLE_FEATURE_SUBSCRIPTION: false },
holdings = [], holdings = [],
performance = createPerformance(), performance = createPerformance(),
watchlist = [] watchlist = []
}: { }: {
accounts?: AccountWithValue[]; accounts?: AccountWithValue[];
activities?: Activity[];
chart?: HistoricalDataItem[]; chart?: HistoricalDataItem[];
configuration?: Record<string, unknown>;
holdings?: PortfolioPosition[]; holdings?: PortfolioPosition[];
performance?: PortfolioPerformanceResponse['performance']; performance?: PortfolioPerformanceResponse['performance'];
watchlist?: WatchlistResponse['watchlist']; 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 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 // the translation of the asset class from that of the asset sub class
const i18nService = { const i18nService = {
@ -188,7 +237,8 @@ function createPortfolioTableService({
} as unknown as WatchlistService; } as unknown as WatchlistService;
return new PortfolioTableService( return new PortfolioTableService(
null, activitiesService,
configurationService,
i18nService, i18nService,
portfolioService, portfolioService,
watchlistService watchlistService
@ -220,6 +270,7 @@ describe('PortfolioTableService', () => {
'Type', 'Type',
'Name', 'Name',
'Symbol', 'Symbol',
'Data Source',
'Currency', 'Currency',
'Unit Price', 'Unit Price',
'Account' 'Account'
@ -232,6 +283,7 @@ describe('PortfolioTableService', () => {
expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([
'Name', 'Name',
'Symbol', 'Symbol',
'Data Source',
'Currency', 'Currency',
'Asset Class', 'Asset Class',
'Asset Sub Class', 'Asset Sub Class',
@ -257,6 +309,7 @@ describe('PortfolioTableService', () => {
expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([
'Name', 'Name',
'Symbol', 'Symbol',
'Data Source',
'Trend 50 Days', 'Trend 50 Days',
'Trend 200 Days', 'Trend 200 Days',
'Date of Last All Time High', 'Date of Last All Time High',
@ -327,14 +380,81 @@ describe('PortfolioTableService', () => {
expect(result).toContain('No accounts found.'); expect(result).toContain('No accounts found.');
}); });
}); });
describe('getActivitiesTable', () => {
// The holding parameter of the tools takes the data source, which a
// response of the instance encodes, hence the table encodes it alike
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', () => { describe('getHoldingsTable', () => {
function getHoldingsTable(holdings: PortfolioPosition[]) { function getHoldingsTable(
return createPortfolioTableService({ holdings }).getHoldingsTable({ holdings: PortfolioPosition[],
configuration?: Record<string, unknown>
) {
return createPortfolioTableService({
configuration,
holdings
}).getHoldingsTable({
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id' userId: 'user-id'
}); });
} }
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'
})
],
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 data source unencoded if the subscription is disabled', async () => {
const result = await getHoldingsTable([createHolding()], {
ENABLE_FEATURE_SUBSCRIPTION: false
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(row).toContain(`| ${DataSource.YAHOO} |`);
});
it('gives the translation of the asset class and of the asset sub class', async () => { it('gives the translation of the asset class and of the asset sub class', async () => {
const result = await getHoldingsTable([createHolding()]); const result = await getHoldingsTable([createHolding()]);
@ -417,12 +537,39 @@ describe('PortfolioTableService', () => {
}); });
describe('getWatchlistTable', () => { describe('getWatchlistTable', () => {
function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) { function getWatchlistTable(
return createPortfolioTableService({ watchlist }).getWatchlistTable({ watchlist: WatchlistResponse['watchlist'],
configuration?: Record<string, unknown>
) {
return createPortfolioTableService({
configuration,
watchlist
}).getWatchlistTable({
userId: 'user-id' 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 () => { it('gives the date and the change of the all time high', async () => {
const result = await getWatchlistTable([createWatchlistItem()]); const result = await getWatchlistTable([createWatchlistItem()]);

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

@ -1,8 +1,10 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; 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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import { import {
@ -21,6 +23,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { DataSourceTableContext } from './interfaces/data-source-table-context.interface';
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
function getPercentage(value: number) { function getPercentage(value: number) {
@ -83,8 +86,10 @@ export class PortfolioTableService {
} }
]; ];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<Activity>[] = private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
[ Activity,
DataSourceTableContext
>[] = [
{ {
getValue: ({ date }) => { getValue: ({ date }) => {
return format(date, DATE_FORMAT); return format(date, DATE_FORMAT);
@ -109,6 +114,15 @@ export class PortfolioTableService {
}, },
name: 'Symbol' name: 'Symbol'
}, },
{
getValue: ({ assetProfile }, { configurationService }) => {
return transformDataSourceInResponse({
configurationService,
dataSource: assetProfile.dataSource
});
},
name: 'Data Source'
},
{ {
getValue: ({ assetProfile, currency }) => { getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency; return currency ?? assetProfile.currency;
@ -144,6 +158,15 @@ export class PortfolioTableService {
}, },
name: 'Symbol' name: 'Symbol'
}, },
{
getValue: ({ assetProfile }, { configurationService }) => {
return transformDataSourceInResponse({
configurationService,
dataSource: assetProfile.dataSource
});
},
name: 'Data Source'
},
{ {
getValue: ({ assetProfile }) => { getValue: ({ assetProfile }) => {
return assetProfile.currency; return assetProfile.currency;
@ -222,7 +245,8 @@ export class PortfolioTableService {
]; ];
private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
WatchlistResponse['watchlist'][number] WatchlistResponse['watchlist'][number],
DataSourceTableContext
>[] = [ >[] = [
{ {
getValue: ({ name }) => { getValue: ({ name }) => {
@ -236,6 +260,15 @@ export class PortfolioTableService {
}, },
name: 'Symbol' name: 'Symbol'
}, },
{
getValue: ({ dataSource }, { configurationService }) => {
return transformDataSourceInResponse({
configurationService,
dataSource
});
},
name: 'Data Source'
},
{ {
getValue: ({ trend50d }) => { getValue: ({ trend50d }) => {
return trend50d; return trend50d;
@ -273,6 +306,7 @@ export class PortfolioTableService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
private readonly watchlistService: WatchlistService private readonly watchlistService: WatchlistService
@ -399,6 +433,7 @@ export class PortfolioTableService {
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
context: { configurationService: this.configurationService },
rows: activities rows: activities
}) })
); );
@ -443,7 +478,11 @@ export class PortfolioTableService {
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
context: { assetClassTranslations, assetSubClassTranslations }, context: {
assetClassTranslations,
assetSubClassTranslations,
configurationService: this.configurationService
},
rows: sortedHoldings rows: sortedHoldings
}) })
].join('\n'); ].join('\n');
@ -491,6 +530,7 @@ export class PortfolioTableService {
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS,
context: { configurationService: this.configurationService },
rows: watchlist rows: watchlist
}) })
); );

Loading…
Cancel
Save