Browse Source

Get asset profiles of user in matching of assets

pull/8197/head
Thomas Kaul 1 day ago
parent
commit
b537c21005
  1. 140
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  2. 126
      apps/api/src/app/endpoints/ai/ai.service.ts
  3. 2
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  4. 64
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  5. 29
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  6. 73
      apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts
  7. 36
      apps/api/src/services/symbol-profile/symbol-profile.service.ts

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

@ -106,9 +106,7 @@ describe('AiService', () => {
getAccounts: jest.fn().mockResolvedValue({ getAccounts: jest.fn().mockResolvedValue({
content: [{ text: '## Accounts', type: 'text' }] content: [{ text: '## Accounts', type: 'text' }]
}), }),
getPortfolio: jest.fn().mockResolvedValue({ getAssetProfilesOfUser: jest.fn().mockResolvedValue([]),
content: [{ text: '## Holdings', type: 'text' }]
}),
searchAssetProfiles: jest.fn().mockResolvedValue({ searchAssetProfiles: jest.fn().mockResolvedValue({
content: [{ text: '{ "assetProfiles": [] }', type: 'text' }] content: [{ text: '{ "assetProfiles": [] }', type: 'text' }]
}) })
@ -538,24 +536,22 @@ describe('AiService', () => {
function mockHoldings({ function mockHoldings({
dataSource = 'YAHOO' dataSource = 'YAHOO'
}: { dataSource?: string } = {}) { }: { dataSource?: string } = {}) {
jest.mocked(mcpService.getPortfolio).mockResolvedValue({ jest.mocked(mcpService.getAssetProfilesOfUser).mockResolvedValue([
content: [ {
{ dataSource,
text: [ currency: 'USD',
'## Holdings', isin: 'IE00BK5BQT80',
'', name: 'Vanguard FTSE All-World UCITS ETF',
'| Name | ISIN | Symbol | Data Source | Currency | Asset Class | Asset Sub Class | Date of First Activity | Activities Count | Allocation in Percentage |', symbol: 'VWRA.SW'
'| :-------------------------------- | :----------- | :------ | :---------- | :------- | :---------- | :-------------- | :--------------------- | ---------------: | -----------------------: |', },
'| Vanguard FTSE All-World UCITS ETF | IE00BK5BQT80 | VWRA.SW | YAHOO | USD | Equity | ETF | 2021-03-15 | 4 | 70.000% |', {
'| Apple Inc. | | AAPL | YAHOO | USD | Equity | Stock | 2022-06-01 | 2 | 20.000% |', dataSource,
'| US Dollar | | USD | | USD | Liquidity | Cash | 2021-03-15 | 1 | 10.000% |' currency: 'USD',
] isin: null,
.join('\n') name: 'Apple Inc.',
.replaceAll('| YAHOO |', `| ${dataSource.padEnd(11)} |`), symbol: 'AAPL'
type: 'text' }
} ]);
]
});
} }
it('Gives an activity for each row whose asset matches an asset profile', async () => { it('Gives an activity for each row whose asset matches an asset profile', async () => {
@ -874,31 +870,6 @@ describe('AiService', () => {
).resolves.toEqual({ activities: [], rowsWithoutMatch: [row] }); ).resolves.toEqual({ activities: [], rowsWithoutMatch: [row] });
}); });
it('Gives a row whose asset match has the symbol in the currency column of a holding as a row without a match', async () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
const row = createActivityRow({
accountName: null,
isin: 'IE00BK5BQT80'
});
mockRowMatches({
assetProfilesOfSearch: [],
assets: [{ currency: 'USD', dataSource: 'YAHOO', id: 0, symbol: 'USD' }]
});
mockHoldings();
await expect(
aiService.getActivitiesFromRows({
accounts,
userCurrency,
userId,
rows: [row]
})
).resolves.toEqual({ activities: [], rowsWithoutMatch: [row] });
});
it('Gives a row whose asset match is a holding with another ISIN as a row without a match and logs the left out match', async () => { it('Gives a row whose asset match is a holding with another ISIN as a row without a match and logs the left out match', async () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(); jest.spyOn(Logger.prototype, 'warn').mockImplementation();
@ -1009,28 +980,6 @@ describe('AiService', () => {
expect(JSON.parse(prompt as string)).toHaveProperty('holdings', []); expect(JSON.parse(prompt as string)).toHaveProperty('holdings', []);
}); });
it('Gives a row whose asset match is a cash position of the holdings as a row without a match', async () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
const row = createActivityRow({ accountName: null });
mockRowMatches({
assetProfilesOfSearch: [],
assets: [{ currency: 'USD', dataSource: '', id: 0, symbol: 'USD' }]
});
mockHoldings();
await expect(
aiService.getActivitiesFromRows({
accounts,
userCurrency,
userId,
rows: [row]
})
).resolves.toEqual({ activities: [], rowsWithoutMatch: [row] });
});
it('Matches a row without an account name by the broker of the file', async () => { it('Matches a row without an account name by the broker of the file', async () => {
mockRowMatches({ mockRowMatches({
accounts: [{ accountId: 'account-id', id: 0 }], accounts: [{ accountId: 'account-id', id: 0 }],
@ -1438,7 +1387,7 @@ describe('AiService', () => {
); );
}); });
it('Gets the holdings with the tool of the server of the Model Context Protocol without values', async () => { it('Gets the holdings of the user from the server of the Model Context Protocol', async () => {
mockRowMatches({}); mockRowMatches({});
await aiService.getActivitiesFromRows({ await aiService.getActivitiesFromRows({
@ -1448,13 +1397,12 @@ describe('AiService', () => {
rows: [createActivityRow()] rows: [createActivityRow()]
}); });
expect(mcpService.getPortfolio).toHaveBeenCalledWith({ expect(mcpService.getAssetProfilesOfUser).toHaveBeenCalledWith({
userId, userId
withValues: false
}); });
}); });
it('Passes the currency, the data source, the ISIN, the name and the symbol of each holding without the cash position to the model', async () => { it('Passes the currency, the data source, the ISIN, the name and the symbol of each holding to the model', async () => {
mockRowMatches({}); mockRowMatches({});
mockHoldings(); mockHoldings();
@ -1486,44 +1434,6 @@ describe('AiService', () => {
]); ]);
}); });
it('Restores an escaped pipe in a value of the holdings', async () => {
mockRowMatches({});
jest.mocked(mcpService.getPortfolio).mockResolvedValue({
content: [
{
text: [
'## Holdings',
'',
'| Name | ISIN | Symbol | Data Source | Currency |',
'| :---------- | :--- | :----- | :---------- | :------- |',
'| Fund A \\| B | | FAB | YAHOO | USD |'
].join('\n'),
type: 'text'
}
]
});
await aiService.getActivitiesFromRows({
accounts,
userCurrency,
userId,
rows: [createActivityRow()]
});
const { prompt } = jest.mocked(generateText).mock.calls[0][0];
expect(JSON.parse(prompt as string)).toHaveProperty('holdings', [
{
currency: 'USD',
dataSource: 'YAHOO',
isin: null,
name: 'Fund A | B',
symbol: 'FAB'
}
]);
});
it('Does not get the holdings without an asset', async () => { it('Does not get the holdings without an asset', async () => {
mockRowMatches({}); mockRowMatches({});
@ -1542,7 +1452,7 @@ describe('AiService', () => {
] ]
}); });
expect(mcpService.getPortfolio).not.toHaveBeenCalled(); expect(mcpService.getAssetProfilesOfUser).not.toHaveBeenCalled();
expect(dataProviderService.getDataSources).not.toHaveBeenCalled(); expect(dataProviderService.getDataSources).not.toHaveBeenCalled();
const { prompt } = jest.mocked(generateText).mock.calls[0][0]; const { prompt } = jest.mocked(generateText).mock.calls[0][0];
@ -1560,8 +1470,8 @@ describe('AiService', () => {
}); });
jest jest
.mocked(mcpService.getPortfolio) .mocked(mcpService.getAssetProfilesOfUser)
.mockRejectedValueOnce(new Error('Calculation failed')); .mockRejectedValueOnce(new Error('Query failed'));
const { activities } = await aiService.getActivitiesFromRows({ const { activities } = await aiService.getActivitiesFromRows({
accounts, accounts,
@ -1577,7 +1487,7 @@ describe('AiService', () => {
expect(JSON.parse(prompt as string)).not.toHaveProperty('holdings'); expect(JSON.parse(prompt as string)).not.toHaveProperty('holdings');
expect(Logger.prototype.warn).toHaveBeenCalledWith( expect(Logger.prototype.warn).toHaveBeenCalledWith(
'Could not get the holdings: Calculation failed' 'Could not get the holdings: Query failed'
); );
}); });

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

@ -136,15 +136,14 @@ export class AiService {
/** /**
* Matches the rows of a file to the asset profiles with the agent model, the * Matches the rows of a file to the asset profiles with the agent model, the
* tool search-asset-profiles and the holdings of the tool get-portfolio, and * tool search-asset-profiles and the holdings of the user, and to the
* to the accounts of the user. Each account name of the rows, also a missing * accounts of the user. Each account name of the rows, also a missing one,
* one, gives an account hint with the broker of the file, as a file often * gives an account hint with the broker of the file, as a file often shows
* shows the broker only, for example as a logo. A row which does not give a * the broker only, for example as a logo. A row which does not give a valid
* valid activity, for example because its asset has no match, is a row * activity, for example because its asset has no match, is a row without a
* without a match. An account id which is not the id of an account of the * match. An account id which is not the id of an account of the user is
* user is ignored. The fee, the quantity and the unit price are taken as * ignored. The fee, the quantity and the unit price are taken as absolute
* absolute values, as a broker can give a sell or a fee as a negative * values, as a broker can give a sell or a fee as a negative number.
* number.
*/ */
public async getActivitiesFromRows({ public async getActivitiesFromRows({
abortSignal, abortSignal,
@ -414,7 +413,7 @@ export class AiService {
* Gives each fee, interest and liability a custom asset profile with its * Gives each fee, interest and liability a custom asset profile with its
* description as name, as the import after the dry run requires a valid * description as name, as the import after the dry run requires a valid
* symbol for the data source MANUAL. The data source of every other activity * symbol for the data source MANUAL. The data source of every other activity
* is given as search-asset-profiles and get-portfolio give it, which POST * is given as search-asset-profiles and the holdings give it, which POST
* api/v1/import transforms back. * api/v1/import transforms back.
*/ */
private getActivitiesWithCustomAssetProfiles( private getActivitiesWithCustomAssetProfiles(
@ -472,67 +471,20 @@ export class AiService {
} }
/** /**
* Gives the currency, the data source, the ISIN, the name and the symbol of * Gives the holdings of the user from the server of the Model Context
* each holding of the table of the holdings which has a data source and a * Protocol, also the closed positions and the holdings of the excluded
* symbol. A cash position has no data source, hence it is left out. The ISIN * accounts, without a portfolio calculation and without the holdings whose
* is null if the asset profile has none, which is the case for most stocks * data source the import refuses
* of Yahoo Finance. The columns are found by the header line. A line is
* split at each pipe which is not escaped, as the renderer escapes a pipe in
* a value, and the escaped pipes are restored.
*/
private getHoldingsFromTable(holdings = '') {
const [headerLine, , ...lines] = holdings.split('\n').filter((line) => {
return line.startsWith('|');
});
if (!headerLine) {
return [];
}
const getCells = (line: string) => {
return line
.split(/(?<!\\)\|/)
.slice(1, -1)
.map((cell) => {
return cell.trim().replaceAll('\\|', '|');
});
};
const columnNames = getCells(headerLine);
return lines
.map((line) => {
const cells = getCells(line);
return {
currency: cells[columnNames.indexOf('Currency')],
dataSource: cells[columnNames.indexOf('Data Source')],
isin: cells[columnNames.indexOf('ISIN')] || null,
name: cells[columnNames.indexOf('Name')],
symbol: cells[columnNames.indexOf('Symbol')]
};
})
.filter(({ dataSource, symbol }) => {
return dataSource && symbol;
});
}
/**
* Gives the holdings of the user with the tool get-portfolio of the server
* of the Model Context Protocol, without the holdings whose data source the
* import refuses
*/ */
private async getHoldingsToMatch({ userId }: { userId: string }) { private async getHoldingsToMatch({ userId }: { userId: string }) {
const [{ content }, dataSources] = await Promise.all([ const [assetProfiles, dataSources] = await Promise.all([
this.mcpService.getPortfolio({ userId, withValues: false }), this.mcpService.getAssetProfilesOfUser({ userId }),
this.dataProviderService.getDataSources() this.dataProviderService.getDataSources()
]); ]);
return this.getHoldingsFromTable(content[0].text).filter( return assetProfiles.filter(({ dataSource }) => {
({ dataSource }) => { return this.isValidDataSourceForImport({ dataSource, dataSources });
return this.isValidDataSourceForImport({ dataSource, dataSources }); });
}
);
} }
/** /**
@ -585,27 +537,27 @@ export class AiService {
} }
/** /**
* Matches the assets and the account hints with the agent model. The code * Matches the assets and the account hints with the agent model. The code and
* and not the model calls the tools search-asset-profiles, get-accounts and * not the model calls the tools search-asset-profiles and get-accounts of the
* get-portfolio of the server of the Model Context Protocol and passes their * server of the Model Context Protocol, gets the holdings from it as well and
* results to the model, as some providers ignore a required tool call, for * passes their results to the model, as some providers ignore a required tool
* example if the output has a schema. The accounts are given without * call, for example if the output has a schema. The accounts are given
* values, the holdings with their currency, data source, ISIN, name and * without values, the holdings with their currency, data source, ISIN, name
* symbol only. The holdings let the model select an asset profile which the * and symbol only. The holdings let the model select an asset profile which
* user already has, also if no search gives it, for example as no data * the user already has, also if no search gives it, for example as no data
* provider finds its ISIN. A holding whose data source the import refuses * provider finds its ISIN. A holding whose data source the import refuses is
* is not given to the model, so that the model selects an asset profile of * not given to the model, so that the model selects an asset profile of the
* the search instead and the whole import does not fail. * search instead and the whole import does not fail. If get-accounts fails,
* If get-accounts fails, the model gets no accounts, so that only the * the model gets no accounts, so that only the account hints have no match.
* account hints have no match. If get-portfolio or the data sources fail, * If the holdings or the data sources cannot be got, the model gets no
* the model gets no holdings. The reasoning is disabled, as it can add * holdings. The reasoning is disabled, as it can add thousands of output
* thousands of output tokens. An asset match is left out if neither the * tokens. An asset match is left out if neither the search of its own asset
* search of its own asset nor the holdings gave its asset profile, or if its * nor the holdings gave its asset profile, or if its holding has another ISIN
* holding has another ISIN than its own asset, as the import refuses a * than its own asset, as the import refuses a symbol which the data source
* symbol which the data source cannot resolve and as the model can mix up * cannot resolve and as the model can mix up the ids of the assets. The ISINs
* the ids of the assets. The ISINs are compared without spaces and in upper * are compared without spaces and in upper case. A holding without an ISIN is
* case. A holding without an ISIN is matched by the model by its name only, * matched by the model by its name only, hence the code cannot check it
* hence the code cannot check it against the asset. * against the asset.
*/ */
private async getRowMatches({ private async getRowMatches({
abortSignal, abortSignal,

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

@ -8,6 +8,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module'; import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -29,6 +30,7 @@ import { McpService } from './mcp.service';
ImportModule, ImportModule,
PortfolioTableModule, PortfolioTableModule,
SymbolModule, SymbolModule,
SymbolProfileModule,
UserModule UserModule
], ],
providers: [ providers: [

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

@ -6,6 +6,7 @@ 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';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACTIVITIES MCP_MAX_ACTIVITIES
@ -36,6 +37,7 @@ describe('McpService', () => {
let importService: ImportService; let importService: ImportService;
let mcpService: McpService; let mcpService: McpService;
let portfolioTableService: PortfolioTableService; let portfolioTableService: PortfolioTableService;
let symbolProfileService: SymbolProfileService;
let symbolService: SymbolService; let symbolService: SymbolService;
let userService: UserService; let userService: UserService;
@ -78,6 +80,10 @@ describe('McpService', () => {
getWatchlistTable: jest.fn().mockResolvedValue('## Watchlist') getWatchlistTable: jest.fn().mockResolvedValue('## Watchlist')
} as unknown as PortfolioTableService; } as unknown as PortfolioTableService;
symbolProfileService = {
getSymbolProfilesOfUser: jest.fn().mockResolvedValue([])
} as unknown as SymbolProfileService;
symbolService = { symbolService = {
lookup: jest.fn().mockResolvedValue({ items: [] }) lookup: jest.fn().mockResolvedValue({ items: [] })
} as unknown as SymbolService; } as unknown as SymbolService;
@ -89,6 +95,7 @@ describe('McpService', () => {
configurationService, configurationService,
importService, importService,
portfolioTableService, portfolioTableService,
symbolProfileService,
symbolService, symbolService,
userService userService
); );
@ -290,6 +297,63 @@ describe('McpService', () => {
}); });
}); });
describe('getAssetProfilesOfUser', () => {
it('Gives the currency, the data source, the ISIN, the name and the symbol of the asset profiles of the user', async () => {
jest
.spyOn(symbolProfileService, 'getSymbolProfilesOfUser')
.mockResolvedValue([
{
currency: 'USD',
dataSource: DataSource.YAHOO,
isin: 'US0378331005',
name: 'Apple Inc.',
symbol: 'AAPL'
}
]);
await expect(
mcpService.getAssetProfilesOfUser({ userId })
).resolves.toEqual([
{
currency: 'USD',
dataSource: DataSource.YAHOO,
isin: 'US0378331005',
name: 'Apple Inc.',
symbol: 'AAPL'
}
]);
expect(symbolProfileService.getSymbolProfilesOfUser).toHaveBeenCalledWith(
{ userId }
);
});
it('Encodes the data source if the subscription is enabled', async () => {
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
jest
.spyOn(symbolProfileService, 'getSymbolProfilesOfUser')
.mockResolvedValue([
{
currency: 'USD',
dataSource: DataSource.YAHOO,
isin: 'US0378331005',
name: 'Apple Inc.',
symbol: 'AAPL'
}
]);
await expect(
mcpService.getAssetProfilesOfUser({ userId })
).resolves.toEqual([
expect.objectContaining({
dataSource: encodeDataSource(DataSource.YAHOO)
})
]);
});
});
describe('getPerformance', () => { describe('getPerformance', () => {
it('Maps the parameters of the tool to the filters', async () => { it('Maps the parameters of the tool to the filters', async () => {
await mcpService.getPerformance({ await mcpService.getPerformance({

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

@ -8,6 +8,7 @@ import {
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';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -31,6 +32,7 @@ export class McpService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly importService: ImportService, private readonly importService: ImportService,
private readonly portfolioTableService: PortfolioTableService, private readonly portfolioTableService: PortfolioTableService,
private readonly symbolProfileService: SymbolProfileService,
private readonly symbolService: SymbolService, private readonly symbolService: SymbolService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -104,6 +106,33 @@ export class McpService {
return this.getTextResult(table); return this.getTextResult(table);
} }
/**
* Gives the currency, the data source, the ISIN, the name and the symbol of
* the asset profiles of the investment activities of the user, also of the
* closed positions and of the excluded accounts. It is not a tool: the
* import of activities with Ghostfolio AI uses it to match the assets
* without the portfolio calculation of the tool get-portfolio.
*/
public async getAssetProfilesOfUser({ userId }: { userId: string }) {
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesOfUser({ userId });
return symbolProfiles.map(
({ currency, dataSource, isin, name, symbol }) => {
return {
currency,
isin,
name,
symbol,
dataSource: transformDataSourceInResponse({
dataSource,
configurationService: this.configurationService
})
};
}
);
}
public async getPerformance({ public async getPerformance({
accountIds, accountIds,
assetClasses, assetClasses,

73
apps/api/src/services/symbol-profile/symbol-profile.service.spec.ts

@ -0,0 +1,73 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config';
import { DataSource } from '@prisma/client';
import { SymbolProfileService } from './symbol-profile.service';
describe('SymbolProfileService', () => {
describe('getSymbolProfilesOfUser', () => {
const userId = 'user-id';
let findManySymbolProfiles: jest.Mock;
let symbolProfileService: SymbolProfileService;
beforeEach(() => {
findManySymbolProfiles = jest.fn().mockResolvedValue([
{
assetProfileOverrides: null,
currency: 'USD',
dataSource: DataSource.YAHOO,
isin: 'US0378331005',
name: 'Apple Inc.',
symbol: 'AAPL'
}
]);
symbolProfileService = new SymbolProfileService({
symbolProfile: { findMany: findManySymbolProfiles }
} as unknown as PrismaService);
});
it('gives the asset profiles of the investment activities of the user', async () => {
await expect(
symbolProfileService.getSymbolProfilesOfUser({ userId })
).resolves.toEqual([
{
currency: 'USD',
dataSource: DataSource.YAHOO,
isin: 'US0378331005',
name: 'Apple Inc.',
symbol: 'AAPL'
}
]);
expect(findManySymbolProfiles).toHaveBeenCalledWith(
expect.objectContaining({
where: {
activities: {
some: { userId, type: { in: INVESTMENT_ACTIVITY_TYPES } }
}
}
})
);
});
it('gives the name of the overrides', async () => {
findManySymbolProfiles.mockResolvedValue([
{
assetProfileOverrides: { name: 'Apple' },
currency: 'USD',
dataSource: DataSource.YAHOO,
isin: 'US0378331005',
name: 'Apple Inc.',
symbol: 'AAPL'
}
]);
await expect(
symbolProfileService.getSymbolProfilesOfUser({ userId })
).resolves.toEqual([expect.objectContaining({ name: 'Apple' })]);
});
});
});

36
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -1,5 +1,8 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import {
INVESTMENT_ACTIVITY_TYPES,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { applyAssetProfileOverrides } from '@ghostfolio/common/helper'; import { applyAssetProfileOverrides } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -182,6 +185,37 @@ export class SymbolProfileService {
}); });
} }
/**
* Gives the currency, the data source, the ISIN, the name and the symbol of
* the asset profiles of the investment activities of the user, also of the
* closed positions and of the excluded accounts, with the overrides
*/
public async getSymbolProfilesOfUser({ userId }: { userId: string }) {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
assetProfileOverrides: true,
currency: true,
dataSource: true,
isin: true,
name: true,
symbol: true
},
where: {
activities: {
some: { userId, type: { in: INVESTMENT_ACTIVITY_TYPES } }
}
}
});
return symbolProfiles.map(({ assetProfileOverrides, ...symbolProfile }) => {
const { currency, dataSource, isin, name, symbol } =
applyAssetProfileOverrides(symbolProfile, assetProfileOverrides);
return { currency, dataSource, isin, name, symbol };
});
}
public updateAssetProfileIdentifier( public updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier, oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier newAssetProfileIdentifier: AssetProfileIdentifier

Loading…
Cancel
Save