Browse Source

Various improvements

pull/7789/head
Thomas Kaul 1 month ago
parent
commit
35df45f25c
  1. 18
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  2. 154
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  3. 18
      apps/api/src/app/endpoints/mcp/mcp.test-utils.ts
  4. 7
      apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts
  5. 6
      apps/api/src/helper/interfaces/table-column-definition.interface.ts
  6. 7
      apps/api/src/helper/interfaces/table-parameters.interface.ts
  7. 77
      apps/api/src/helper/markdown-table.helper.spec.ts
  8. 58
      apps/api/src/helper/markdown-table.helper.ts
  9. 6
      apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts
  10. 162
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  11. 46
      apps/api/src/services/portfolio-table/portfolio-table.service.ts
  12. 9
      apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts

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

@ -1,21 +1,7 @@
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { Type as ActivityType } from '@prisma/client';
import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas';
function createActivity(overrides: Record<string, unknown> = {}) {
return {
currency: 'USD',
date: '2024-01-01',
fee: 0,
quantity: 1,
symbol: 'AAPL',
type: ActivityType.BUY,
unitPrice: 100,
...overrides
};
}
import { createActivity } from './mcp.test-utils';
describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
function parse(activities: unknown[]) {
@ -45,7 +31,7 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
it('Removes a tag, because the tool takes no tag', () => {
expect(
IMPORT_ACTIVITIES_PARAMETERS.parse({
activities: [createActivity({ tags: ['tag-id'] })]
activities: [{ ...createActivity(), tags: ['tag-id'] }]
}).activities[0]
).not.toHaveProperty('tags');
});

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

@ -1,44 +1,34 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Activity } from '@ghostfolio/common/interfaces';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import {
DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { UserWithSettings } from '@ghostfolio/common/types';
import { HttpException } from '@nestjs/common';
import { DataSource, Type as ActivityType } from '@prisma/client';
import { z } from 'zod';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas';
import { McpService } from './mcp.service';
type ActivityToImport = z.infer<
typeof IMPORT_ACTIVITIES_PARAMETERS
>['activities'][number];
function createActivity(
overrides: Partial<ActivityToImport> = {}
): ActivityToImport {
return {
currency: 'USD',
date: '2024-01-01',
fee: 0,
quantity: 1,
symbol: 'AAPL',
type: ActivityType.BUY,
unitPrice: 100,
...overrides
};
}
import { createActivity } from './mcp.test-utils';
describe('McpService', () => {
const filters: Filter[] = [{ id: 'account-id', type: 'ACCOUNT' }];
const userCurrency = 'USD';
const userId = 'user-id';
let apiService: ApiService;
let configuration: Record<string, unknown>;
let configurationService: ConfigurationService;
let importService: ImportService;
let mcpService: McpService;
let portfolioTableService: PortfolioTableService;
let userService: UserService;
function setupUser(userPermissions: string[]) {
@ -59,17 +49,27 @@ describe('McpService', () => {
})
} as unknown as ConfigurationService;
apiService = {
buildFiltersFromQueryParams: jest.fn().mockReturnValue(filters)
} as unknown as ApiService;
importService = {
import: jest.fn().mockResolvedValue([])
} as unknown as ImportService;
portfolioTableService = {
getAccountsTable: jest.fn().mockResolvedValue('## Accounts'),
getActivitiesTable: jest.fn().mockResolvedValue('## Activities'),
getHoldingsTable: jest.fn().mockResolvedValue('## Holdings')
} as unknown as PortfolioTableService;
userService = { user: jest.fn() } as unknown as UserService;
mcpService = new McpService(
undefined,
apiService,
configurationService,
importService,
undefined,
portfolioTableService,
userService
);
});
@ -78,6 +78,110 @@ describe('McpService', () => {
jest.restoreAllMocks();
});
describe('getAccounts', () => {
it('Maps the parameters of the tool to the filters', async () => {
await mcpService.getAccounts({
userId,
accountIds: ['account-id'],
assetClasses: [AssetClass.EQUITY],
holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({
filterByAccounts: ['account-id'],
filterByAssetClasses: [AssetClass.EQUITY],
filterByDataSource: DataSource.YAHOO,
filterBySymbol: 'AAPL'
});
});
it('Gives the table of the accounts of the filters', async () => {
expect(await mcpService.getAccounts({ userId })).toEqual({
content: [{ text: '## Accounts', type: 'text' }]
});
expect(portfolioTableService.getAccountsTable).toHaveBeenCalledWith({
filters,
userId
});
});
});
describe('getActivities', () => {
function getActivities(
parameters: Partial<Parameters<McpService['getActivities']>[0]> = {}
) {
return mcpService.getActivities({
userCurrency,
userId,
take: MCP_MAX_ACTIVITIES,
...parameters
});
}
it('Maps the parameters of the tool to the filters', async () => {
await getActivities({
assetClasses: [AssetClass.EQUITY],
holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({
filterByAssetClasses: [AssetClass.EQUITY],
filterByDataSource: DataSource.YAHOO,
filterBySymbol: 'AAPL'
});
});
it('Changes the range into the start date and the end date', async () => {
await getActivities({ range: '2024' });
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith(
expect.objectContaining({
endDate: new Date('2024-12-31T23:59:59.999Z'),
startDate: new Date('2023-12-31T23:59:59.999Z')
})
);
});
it('Gives no date if the range is absent', async () => {
await getActivities();
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith(
expect.objectContaining({ endDate: undefined, startDate: undefined })
);
});
it('Gives the table of the activities of the filters', async () => {
expect(
await getActivities({ activityTypes: [ActivityType.BUY], skip: 10 })
).toEqual({ content: [{ text: '## Activities', type: 'text' }] });
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith({
filters,
userCurrency,
userId,
endDate: undefined,
skip: 10,
startDate: undefined,
take: MCP_MAX_ACTIVITIES,
types: [ActivityType.BUY]
});
});
});
describe('getPortfolio', () => {
it('Gives the table of the holdings in the default language', async () => {
expect(await mcpService.getPortfolio({ userId })).toEqual({
content: [{ text: '## Holdings', type: 'text' }]
});
expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({
userId,
languageCode: DEFAULT_LANGUAGE_CODE
});
});
});
describe('importActivities', () => {
it('Refuses a user without the permission to create an activity', async () => {
setupUser([]);

18
apps/api/src/app/endpoints/mcp/mcp.test-utils.ts

@ -0,0 +1,18 @@
import { Type as ActivityType } from '@prisma/client';
import { ActivityToImport } from './types/activity-to-import.type';
export function createActivity(
overrides: Partial<ActivityToImport> = {}
): ActivityToImport {
return {
currency: 'USD',
date: '2024-01-01',
fee: 0,
quantity: 1,
symbol: 'AAPL',
type: ActivityType.BUY,
unitPrice: 100,
...overrides
};
}

7
apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts

@ -0,0 +1,7 @@
import { z } from 'zod';
import { IMPORT_ACTIVITIES_PARAMETERS } from '../mcp.schemas';
export type ActivityToImport = z.infer<
typeof IMPORT_ACTIVITIES_PARAMETERS
>['activities'][number];

6
apps/api/src/helper/interfaces/table-column-definition.interface.ts

@ -0,0 +1,6 @@
import type { ColumnDescriptor } from 'tablemark';
export interface TableColumnDefinition<T, C = void> extends ColumnDescriptor {
getValue: (item: T, context: C) => string;
name: string;
}

7
apps/api/src/helper/interfaces/table-parameters.interface.ts

@ -0,0 +1,7 @@
import { TableColumnDefinition } from './table-column-definition.interface';
export interface TableParameters<T, C> {
columnDefinitions: readonly TableColumnDefinition<T, C>[];
context?: C;
rows: readonly T[];
}

77
apps/api/src/helper/markdown-table.helper.spec.ts

@ -0,0 +1,77 @@
import { TableColumnDefinition } from './interfaces/table-column-definition.interface';
import { getTableInput } from './markdown-table.helper';
interface Holding {
name: string;
quantity: number;
}
interface HoldingContext {
currency: string;
}
describe('getTableInput', () => {
const holdings: Holding[] = [
{ name: 'Apple', quantity: 2 },
{ name: 'Microsoft', quantity: 30 }
];
const columnDefinitions: TableColumnDefinition<Holding, HoldingContext>[] = [
{
getValue: ({ name }) => {
return name;
},
name: 'Name'
},
{
align: 'right',
getValue: ({ quantity }) => {
return quantity.toString();
},
name: 'Quantity'
},
{
getValue: (_, { currency }) => {
return currency;
},
name: 'Currency'
}
];
function getInput(rows: Holding[] = holdings) {
return getTableInput({
columnDefinitions,
rows,
context: { currency: 'USD' }
});
}
it('Gives a column for each definition, in the sequence of the definitions', () => {
expect(getInput().columns).toEqual([
{ align: 'left', name: 'Name' },
{ align: 'right', name: 'Quantity' },
{ align: 'left', name: 'Currency' }
]);
});
it('Gives a row for each row, with the name of the column as the key', () => {
expect(getInput().rows).toEqual([
{ Currency: 'USD', Name: 'Apple', Quantity: '2' },
{ Currency: 'USD', Name: 'Microsoft', Quantity: '30' }
]);
});
it('Gives the keys of a row in the sequence of the columns', () => {
const [firstRow] = getInput().rows;
expect(Object.keys(firstRow)).toEqual(
getInput().columns.map(({ name }) => {
return name;
})
);
});
it('Gives no row if there is no row', () => {
expect(getInput([]).rows).toEqual([]);
});
});

58
apps/api/src/helper/markdown-table.helper.ts

@ -1,44 +1,44 @@
import type { ColumnDescriptor } from 'tablemark';
export interface TableColumnDefinition<T> extends ColumnDescriptor {
getValue: (item: T) => string;
name: string;
}
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
import { TableParameters } from './interfaces/table-parameters.interface';
/**
* Renders the rows as a markdown table with one column per definition, which
* gives the title of the column and reads the value of a row
* Gives the columns and the rows in the form which the renderer takes, with
* one column per definition, which gives the title of the column and reads
* the value of a row
*/
export async function getMarkdownTable<T>({
export function getTableInput<T, C = void>({
columnDefinitions,
context,
rows
}: {
columnDefinitions: readonly TableColumnDefinition<T>[];
rows: readonly T[];
}) {
const { tablemark } = await dynamicImport('tablemark');
return tablemark(
rows.map((row) => {
}: TableParameters<T, C>) {
return {
columns: columnDefinitions.map(({ align, name }) => {
return { name, align: align ?? 'left' };
}),
rows: rows.map((row) => {
return columnDefinitions.reduce(
(tableRow, { getValue, name }) => {
tableRow[name] = getValue(row);
tableRow[name] = getValue(row, context);
return tableRow;
},
{} as Record<string, string>
);
}),
{
columns: columnDefinitions.map(({ align, name }) => {
return { name, align: align ?? 'left' };
})
};
}
);
export async function getMarkdownTable<T, C = void>(
parameters: TableParameters<T, C>
) {
const { columns, rows } = getTableInput(parameters);
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
const { tablemark } = await dynamicImport('tablemark');
return tablemark(rows, { columns });
}

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

@ -0,0 +1,6 @@
import { AssetClass, AssetSubClass } from '@prisma/client';
export interface HoldingsTableContext {
assetClassTranslations: Record<AssetClass, string>;
assetSubClassTranslations: Record<AssetSubClass, string>;
}

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

@ -1,42 +1,53 @@
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface';
import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import {
DEFAULT_LANGUAGE_CODE,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass } from '@prisma/client';
import { PortfolioTableService } from './portfolio-table.service';
/**
* The markdown table is rendered by a package which ships as an ECMAScript
* module only, which Jest cannot run. The mock renders the columns and the
* rows in a simple form, so that the tests can read them.
* module only, which Jest cannot run. The mock keeps the mapping of the
* columns and of the rows and writes them in the same shape as the renderer
*/
jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => {
const { getTableInput } = jest.requireActual<
typeof import('@ghostfolio/api/helper/markdown-table.helper')
>('@ghostfolio/api/helper/markdown-table.helper');
return {
getTableInput,
getMarkdownTable: jest.fn(
({
columnDefinitions,
rows
}: {
columnDefinitions: {
getValue: (row: unknown) => string;
name: string;
}[];
rows: unknown[];
}) => {
(parameters: TableParameters<unknown, unknown>) => {
const { columns, rows } = getTableInput(parameters);
const names = columns.map(({ name }) => {
return name;
});
return Promise.resolve(
[
columnDefinitions
.map(({ name }) => {
return name;
})
.join(' | '),
names,
names.map(() => {
return '---';
}),
...rows.map((row) => {
return columnDefinitions
.map(({ getValue }) => {
return getValue(row);
return names.map((name) => {
return row[name];
});
})
.join(' | ');
]
.map((cells) => {
return `| ${cells.join(' | ')} |`;
})
].join('\n')
.join('\n')
);
}
)
@ -65,12 +76,56 @@ function createAccount({
} as unknown as AccountWithValue;
}
function createPortfolioTableService(accounts: AccountWithValue[]) {
function createHolding({
allocationInPercentage = 0.75,
assetClass = AssetClass.EQUITY,
assetSubClass = AssetSubClass.STOCK,
symbol = 'AAPL'
}: {
allocationInPercentage?: number;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
symbol?: string;
} = {}) {
return {
allocationInPercentage,
activitiesCount: 3,
assetProfile: {
assetClass,
assetSubClass,
symbol,
currency: 'CHF',
name: `Name of ${symbol}`
},
dateOfFirstActivity: new Date('2024-01-01'),
grossPerformance: 100,
netPerformance: 90,
quantity: 5,
valueInBaseCurrency: 2000
} as unknown as PortfolioPosition;
}
function createPortfolioTableService({
accounts = [],
holdings = []
}: {
accounts?: AccountWithValue[];
holdings?: PortfolioPosition[];
} = {}) {
// The mock gives the identifier of the translation, so that a test can tell
// the translation of the asset class from that of the asset sub class
const i18nService = {
getTranslation: jest.fn(({ id }: { id: string }) => {
return `translation of ${id}`;
})
} as unknown as I18nService;
const portfolioService = {
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts })
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }),
getDetails: jest.fn().mockResolvedValue({ holdings })
} as unknown as PortfolioService;
return new PortfolioTableService(null, null, portfolioService);
return new PortfolioTableService(null, i18nService, portfolioService);
}
describe('PortfolioTableService', () => {
@ -122,9 +177,9 @@ describe('PortfolioTableService', () => {
describe('getAccountsTable', () => {
it('gives no cash balance and no value of an account', async () => {
const portfolioTableService = createPortfolioTableService([
createAccount()
]);
const portfolioTableService = createPortfolioTableService({
accounts: [createAccount()]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
@ -138,9 +193,9 @@ describe('PortfolioTableService', () => {
// The accountIds parameter of the tool takes the identifiers, hence the
// table has to give them
it('gives the identifier of an account', async () => {
const portfolioTableService = createPortfolioTableService([
createAccount()
]);
const portfolioTableService = createPortfolioTableService({
accounts: [createAccount()]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
@ -150,10 +205,12 @@ describe('PortfolioTableService', () => {
});
it('marks an account which is excluded from the analysis', async () => {
const portfolioTableService = createPortfolioTableService([
const portfolioTableService = createPortfolioTableService({
accounts: [
createAccount({ isExcluded: true }),
createAccount({ id: 'account-b-id', name: 'Account B' })
]);
]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
@ -162,7 +219,7 @@ describe('PortfolioTableService', () => {
const [rowOfAccountA, rowOfAccountB] = result
.split('\n')
.filter((line) => {
return line.startsWith('account-');
return line.startsWith('| account-');
});
expect(rowOfAccountA).toContain('true');
@ -170,7 +227,7 @@ describe('PortfolioTableService', () => {
});
it('tells that no accounts are found if the result is empty', async () => {
const portfolioTableService = createPortfolioTableService([]);
const portfolioTableService = createPortfolioTableService();
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
@ -179,4 +236,37 @@ describe('PortfolioTableService', () => {
expect(result).toContain('No accounts found.');
});
});
describe('getHoldingsTable', () => {
function getHoldingsTable(holdings: PortfolioPosition[]) {
return createPortfolioTableService({ holdings }).getHoldingsTable({
languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id'
});
}
it('gives the translation of the asset class and of the asset sub class', async () => {
const result = await getHoldingsTable([createHolding()]);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(row).toContain('translation of assetClass.EQUITY');
expect(row).toContain('translation of assetSubClass.STOCK');
});
it('gives the holding with the largest allocation first', async () => {
const result = await getHoldingsTable([
createHolding({ allocationInPercentage: 0.25, symbol: 'MSFT' }),
createHolding({ allocationInPercentage: 0.75, symbol: 'AAPL' })
]);
const [firstRow, secondRow] = result.split('\n').filter((line) => {
return line.startsWith('| Name of');
});
expect(firstRow).toContain('AAPL');
expect(secondRow).toContain('MSFT');
});
});
});

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

@ -1,16 +1,10 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import {
getMarkdownTable,
TableColumnDefinition
} from '@ghostfolio/api/helper/markdown-table.helper';
import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import {
Activity,
Filter,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -21,14 +15,7 @@ import {
} from '@prisma/client';
import { format } from 'date-fns';
/**
* A holding with the asset class and the asset sub class in the language of
* the caller, because the column of a table cannot translate a value itself
*/
type HoldingWithTranslations = PortfolioPosition & {
assetClassLabel: string;
assetSubClassLabel: string;
};
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
function getAllocationInPercentage(allocationInPercentage: number) {
return `${(allocationInPercentage * 100).toFixed(3)}%`;
@ -136,7 +123,7 @@ export class PortfolioTableService {
}
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<HoldingWithTranslations>[] =
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[
{
getValue: ({ assetProfile }) => {
@ -157,14 +144,14 @@ export class PortfolioTableService {
name: 'Currency'
},
{
getValue: ({ assetClassLabel }) => {
return assetClassLabel;
getValue: ({ assetProfile }, { assetClassTranslations }) => {
return assetClassTranslations[assetProfile.assetClass] ?? '';
},
name: 'Asset Class'
},
{
getValue: ({ assetSubClassLabel }) => {
return assetSubClassLabel;
getValue: ({ assetProfile }, { assetSubClassTranslations }) => {
return assetSubClassTranslations[assetProfile.assetSubClass] ?? '';
},
name: 'Asset Sub Class'
},
@ -337,18 +324,8 @@ export class PortfolioTableService {
values: Object.values(AssetSubClass)
});
const holdingsWithTranslations = [...holdings]
.sort((a, b) => {
const sortedHoldings = [...holdings].sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
.map((holding) => {
return {
...holding,
assetClassLabel:
assetClassTranslations[holding.assetProfile.assetClass] ?? '',
assetSubClassLabel:
assetSubClassTranslations[holding.assetProfile.assetSubClass] ?? ''
};
});
return [
@ -357,7 +334,8 @@ export class PortfolioTableService {
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
rows: holdingsWithTranslations
context: { assetClassTranslations, assetSubClassTranslations },
rows: sortedHoldings
})
].join('\n');
}

9
apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts

@ -0,0 +1,9 @@
import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { HoldingsTableContext } from '../interfaces/holdings-table-context.interface';
export type HoldingsTableColumnDefinition = TableColumnDefinition<
PortfolioPosition,
HoldingsTableContext
>;
Loading…
Cancel
Save