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 ### Changed
- Improved the label of the cash positions in the holdings charts and table - 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 `@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 `ai` from version `7.0.37` to `7.0.114`
- Upgraded `dotenv` from version `17.4.2` to `18.0.3` - 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 { 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,40 @@ 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);
});
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', () => { describe('GET_ACTIVITIES_PARAMETERS', () => {
@ -52,6 +89,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 +142,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 +200,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);
}); });

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 { 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,20 @@ 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_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 const SYMBOL_PARAMETER = z
.string() .string()
.trim() .trim()
@ -25,9 +40,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 +150,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)

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 { 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;
@ -304,14 +353,15 @@ describe('McpService', () => {
}); });
describe('getPortfolio', () => { 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({ expect(await mcpService.getPortfolio({ userId })).toEqual({
content: [{ text: '## Holdings', type: 'text' }] content: [{ text: '## Holdings', type: 'text' }]
}); });
expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({ expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({
userId, 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 () => { 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 +430,46 @@ 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'
}
]
});
});
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 +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 () => { it('Keeps the data source if the subscription is not enabled', async () => {
setupUser([permissions.createActivity]); 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 { 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';
@ -121,7 +121,8 @@ export class McpService {
public async getPortfolio({ userId }: { userId: string }) { public async getPortfolio({ userId }: { userId: string }) {
const table = await this.portfolioTableService.getHoldingsTable({ const table = await this.portfolioTableService.getHoldingsTable({
userId, userId,
languageCode: DEFAULT_LANGUAGE_CODE languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true
}); });
return this.getTextResult(table); return this.getTextResult(table);
@ -144,13 +145,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 +187,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 +208,9 @@ export class McpService {
currency, currency,
name, name,
symbol, symbol,
dataSource: getMaskedGhostfolioDataSource({ dataSource: transformDataSourceInResponse({
dataSource, dataSource,
ghostfolioDataSources configurationService: this.configurationService
}) })
} }
]; ];
@ -223,19 +220,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 (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 { 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 {}

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 { 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,118 @@ describe('PortfolioTableService', () => {
expect(result).toContain('No accounts found.'); 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', () => { describe('getHoldingsTable', () => {
function getHoldingsTable(holdings: PortfolioPosition[]) { function getHoldingsTable(
return createPortfolioTableService({ holdings }).getHoldingsTable({ holdings: PortfolioPosition[],
{
configuration,
withDataSource
}: {
configuration?: Record<string, unknown>;
withDataSource?: boolean;
} = {}
) {
return createPortfolioTableService({
configuration,
holdings
}).getHoldingsTable({
withDataSource,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id' 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 () => { 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 +574,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()]);

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 { 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,
isCashPosition
} from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
Filter, Filter,
@ -17,12 +23,31 @@ import { Injectable } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource,
Type as ActivityType Type as ActivityType
} 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';
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) { function getPercentage(value: number) {
return `${(value * 100).toFixed(3)}%`; return `${(value * 100).toFixed(3)}%`;
} }
@ -83,52 +108,57 @@ export class PortfolioTableService {
} }
]; ];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<Activity>[] = private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
[ Activity,
{ DataSourceTableContext
getValue: ({ date }) => { >[] = [
return format(date, DATE_FORMAT); {
}, getValue: ({ date }) => {
name: 'Date' return format(date, DATE_FORMAT);
}, },
{ name: 'Date'
getValue: ({ type }) => { },
return type; {
}, getValue: ({ type }) => {
name: 'Type' return type;
}, },
{ name: 'Type'
getValue: ({ assetProfile }) => { },
return assetProfile.name ?? ''; {
}, getValue: ({ assetProfile }) => {
name: 'Name' return assetProfile.name ?? '';
}, },
{ name: 'Name'
getValue: ({ assetProfile }) => { },
return assetProfile.symbol; {
}, getValue: ({ assetProfile }) => {
name: 'Symbol' return assetProfile.symbol;
}, },
{ name: 'Symbol'
getValue: ({ assetProfile, currency }) => { },
return currency ?? assetProfile.currency; getDataSourceColumnDefinition(({ assetProfile }) => {
}, return assetProfile.dataSource;
name: 'Currency' }),
{
getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency;
}, },
{ name: 'Currency'
align: 'right', },
getValue: ({ unitPrice }) => { {
return unitPrice.toString(); align: 'right',
}, getValue: ({ unitPrice }) => {
name: 'Unit Price' return unitPrice.toString();
}, },
{ name: 'Unit Price'
getValue: ({ account }) => { },
return account?.name ?? ''; {
}, getValue: ({ account }) => {
name: 'Account' return account?.name ?? '';
} },
]; name: 'Account'
}
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[ [
@ -144,6 +174,11 @@ export class PortfolioTableService {
}, },
name: 'Symbol' name: 'Symbol'
}, },
getDataSourceColumnDefinition(({ assetProfile }) => {
return isCashPosition(assetProfile)
? undefined
: assetProfile.dataSource;
}),
{ {
getValue: ({ assetProfile }) => { getValue: ({ assetProfile }) => {
return assetProfile.currency; return assetProfile.currency;
@ -222,7 +257,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 +272,9 @@ export class PortfolioTableService {
}, },
name: 'Symbol' name: 'Symbol'
}, },
getDataSourceColumnDefinition(({ dataSource }) => {
return dataSource;
}),
{ {
getValue: ({ trend50d }) => { getValue: ({ trend50d }) => {
return trend50d; return trend50d;
@ -273,6 +312,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 +439,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
}) })
); );
@ -410,11 +451,13 @@ export class PortfolioTableService {
public async getHoldingsTable({ public async getHoldingsTable({
filters, filters,
languageCode, languageCode,
userId userId,
withDataSource = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
languageCode: string; languageCode: string;
userId: string; userId: string;
withDataSource?: boolean;
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters, filters,
@ -442,8 +485,16 @@ export class PortfolioTableService {
'', '',
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter(
context: { assetClassTranslations, assetSubClassTranslations }, ({ name }) => {
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME;
}
),
context: {
assetClassTranslations,
assetSubClassTranslations,
configurationService: this.configurationService
},
rows: sortedHoldings rows: sortedHoldings
}) })
].join('\n'); ].join('\n');
@ -491,6 +542,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