Browse Source

Render the tables from one column definition with a value function

pull/7789/head
Thomas Kaul 1 month ago
parent
commit
1a997ba0c2
  1. 44
      apps/api/src/helper/markdown-table.helper.ts
  2. 89
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  3. 448
      apps/api/src/services/portfolio-table/portfolio-table.service.ts

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

@ -0,0 +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')>;
/**
* 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
*/
export async function getMarkdownTable<T>({
columnDefinitions,
rows
}: {
columnDefinitions: readonly TableColumnDefinition<T>[];
rows: readonly T[];
}) {
const { tablemark } = await dynamicImport('tablemark');
return tablemark(
rows.map((row) => {
return columnDefinitions.reduce(
(tableRow, { getValue, name }) => {
tableRow[name] = getValue(row);
return tableRow;
},
{} as Record<string, string>
);
}),
{
columns: columnDefinitions.map(({ align, name }) => {
return { name, align: align ?? 'left' };
})
}
);
}

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

@ -6,16 +6,42 @@ import { PortfolioTableService } from './portfolio-table.service';
/** /**
* The markdown table is rendered by a package which ships as an ECMAScript * The markdown table is rendered by a package which ships as an ECMAScript
* module only, hence the service loads it with a dynamic import which Jest * module only, which Jest cannot run. The mock renders the columns and the
* cannot run. The tests replace the method by a simple renderer, so that they * rows in a simple form, so that the tests can read them.
* can read the columns and the rows which the service gives to it.
*/ */
interface PortfolioTableServiceWithMarkdownTable { jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => {
getMarkdownTable(parameters: { return {
columnDefinitions: readonly { name: string }[]; getMarkdownTable: jest.fn(
rows: Record<string, string>[]; ({
}): Promise<string>; columnDefinitions,
} rows
}: {
columnDefinitions: {
getValue: (row: unknown) => string;
name: string;
}[];
rows: unknown[];
}) => {
return Promise.resolve(
[
columnDefinitions
.map(({ name }) => {
return name;
})
.join(' | '),
...rows.map((row) => {
return columnDefinitions
.map(({ getValue }) => {
return getValue(row);
})
.join(' | ');
})
].join('\n')
);
}
)
};
});
function createAccount({ function createAccount({
id = 'account-a-id', id = 'account-a-id',
@ -44,35 +70,7 @@ function createPortfolioTableService(accounts: AccountWithValue[]) {
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }) getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts })
} as unknown as PortfolioService; } as unknown as PortfolioService;
const portfolioTableService = new PortfolioTableService( return new PortfolioTableService(null, null, portfolioService);
null,
null,
portfolioService
);
jest
.spyOn(
portfolioTableService as unknown as PortfolioTableServiceWithMarkdownTable,
'getMarkdownTable'
)
.mockImplementation(async ({ columnDefinitions, rows }) => {
const columnNames = columnDefinitions.map(({ name }) => {
return name;
});
return [
columnNames.join(' | '),
...rows.map((row) => {
return columnNames
.map((columnName) => {
return row[columnName];
})
.join(' | ');
})
].join('\n');
});
return portfolioTableService;
} }
describe('PortfolioTableService', () => { describe('PortfolioTableService', () => {
@ -107,6 +105,21 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getHoldingsTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([
'Name',
'Symbol',
'Currency',
'Asset Class',
'Asset Sub Class',
'Date of First Activity',
'Activities Count',
'Allocation in Percentage'
]);
});
});
describe('getAccountsTable', () => { describe('getAccountsTable', () => {
it('gives no cash balance and no value of an account', async () => { it('gives no cash balance and no value of an account', async () => {
const portfolioTableService = createPortfolioTableService([ const portfolioTableService = createPortfolioTableService([

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

@ -1,8 +1,17 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import {
getMarkdownTable,
TableColumnDefinition
} from '@ghostfolio/api/helper/markdown-table.helper';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import {
Activity,
Filter,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
@ -11,7 +20,19 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import type { ColumnDescriptor } from 'tablemark';
/**
* 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;
};
function getAllocationInPercentage(allocationInPercentage: number) {
return `${(allocationInPercentage * 100).toFixed(3)}%`;
}
/** /**
* Renders the accounts, the activities and the holdings of a portfolio as a * Renders the accounts, the activities and the holdings of a portfolio as a
@ -20,72 +41,156 @@ import type { ColumnDescriptor } from 'tablemark';
*/ */
@Injectable() @Injectable()
export class PortfolioTableService { export class PortfolioTableService {
private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({ private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<AccountWithValue>[] =
key: [
| 'ACTIVITIES_COUNT' {
| 'ALLOCATION_PERCENTAGE' getValue: ({ id }) => {
| 'CURRENCY' return id;
| 'EXCLUDED_FROM_ANALYSIS' },
| 'ID' name: 'Id'
| 'NAME' },
| 'PLATFORM'; {
} & ColumnDescriptor)[] = [ getValue: ({ name }) => {
{ key: 'ID', name: 'Id' }, return name ?? '';
{ key: 'NAME', name: 'Name' }, },
{ key: 'CURRENCY', name: 'Currency' }, name: 'Name'
{ key: 'PLATFORM', name: 'Platform' }, },
{ align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, {
{ getValue: ({ currency }) => {
align: 'right', return currency ?? '';
key: 'ALLOCATION_PERCENTAGE', },
name: 'Allocation in Percentage' name: 'Currency'
}, },
{ key: 'EXCLUDED_FROM_ANALYSIS', name: 'Excluded from Analysis' } {
]; getValue: ({ platform }) => {
return platform?.name ?? '';
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({ },
key: name: 'Platform'
| 'ACCOUNT' },
| 'CURRENCY' {
| 'DATE' align: 'right',
| 'NAME' getValue: ({ activitiesCount }) => {
| 'SYMBOL' return activitiesCount.toString();
| 'TYPE' },
| 'UNIT_PRICE'; name: 'Activities Count'
} & ColumnDescriptor)[] = [ },
{ key: 'DATE', name: 'Date' }, {
{ key: 'TYPE', name: 'Type' }, align: 'right',
{ key: 'NAME', name: 'Name' }, getValue: ({ allocationInPercentage }) => {
{ key: 'SYMBOL', name: 'Symbol' }, return getAllocationInPercentage(allocationInPercentage);
{ key: 'CURRENCY', name: 'Currency' }, },
{ align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' }, name: 'Allocation in Percentage'
{ key: 'ACCOUNT', name: 'Account' } },
]; {
getValue: ({ tags }) => {
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ return isAccountExcluded({ tags }).toString();
key: },
| 'ACTIVITIES_COUNT' name: 'Excluded from Analysis'
| 'ALLOCATION_PERCENTAGE' }
| 'ASSET_CLASS' ];
| 'ASSET_SUB_CLASS'
| 'CURRENCY' private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<Activity>[] =
| 'DATE_OF_FIRST_ACTIVITY' [
| 'NAME' {
| 'SYMBOL'; getValue: ({ date }) => {
} & ColumnDescriptor)[] = [ return format(date, DATE_FORMAT);
{ key: 'NAME', name: 'Name' }, },
{ key: 'SYMBOL', name: 'Symbol' }, name: 'Date'
{ key: 'CURRENCY', name: 'Currency' }, },
{ key: 'ASSET_CLASS', name: 'Asset Class' }, {
{ key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' }, getValue: ({ type }) => {
{ key: 'DATE_OF_FIRST_ACTIVITY', name: 'Date of First Activity' }, return type;
{ align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, },
{ name: 'Type'
align: 'right', },
key: 'ALLOCATION_PERCENTAGE', {
name: 'Allocation in Percentage' getValue: ({ assetProfile }) => {
} return assetProfile.name ?? '';
]; },
name: 'Name'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.symbol;
},
name: 'Symbol'
},
{
getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency;
},
name: 'Currency'
},
{
align: 'right',
getValue: ({ unitPrice }) => {
return unitPrice.toString();
},
name: 'Unit Price'
},
{
getValue: ({ account }) => {
return account?.name ?? '';
},
name: 'Account'
}
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<HoldingWithTranslations>[] =
[
{
getValue: ({ assetProfile }) => {
return assetProfile.name;
},
name: 'Name'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.symbol;
},
name: 'Symbol'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.currency;
},
name: 'Currency'
},
{
getValue: ({ assetClassLabel }) => {
return assetClassLabel;
},
name: 'Asset Class'
},
{
getValue: ({ assetSubClassLabel }) => {
return assetSubClassLabel;
},
name: 'Asset Sub Class'
},
{
getValue: ({ dateOfFirstActivity }) => {
return dateOfFirstActivity
? format(dateOfFirstActivity, DATE_FORMAT)
: '';
},
name: 'Date of First Activity'
},
{
align: 'right',
getValue: ({ activitiesCount }) => {
return activitiesCount.toString();
},
name: 'Activities Count'
},
{
align: 'right',
getValue: ({ allocationInPercentage }) => {
return getAllocationInPercentage(allocationInPercentage);
},
name: 'Allocation in Percentage'
}
];
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
@ -131,67 +236,14 @@ export class PortfolioTableService {
withExcludedAccounts: true withExcludedAccounts: true
}); });
const accountsTableRows = accounts.map(
({
activitiesCount,
allocationInPercentage,
currency,
id,
name: label,
platform,
tags
}) => {
return PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACTIVITIES_COUNT':
row[name] = activitiesCount.toString();
break;
case 'ALLOCATION_PERCENTAGE':
row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`;
break;
case 'CURRENCY':
row[name] = currency ?? '';
break;
case 'EXCLUDED_FROM_ANALYSIS':
row[name] = isAccountExcluded({ tags }).toString();
break;
case 'ID':
row[name] = id;
break;
case 'NAME':
row[name] = label ?? '';
break;
case 'PLATFORM':
row[name] = platform?.name ?? '';
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
const accountsSection = ['## Accounts', '']; const accountsSection = ['## Accounts', ''];
if (accountsTableRows.length > 0) { if (accounts.length > 0) {
accountsSection.push( accountsSection.push(
await this.getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS,
rows: accountsTableRows rows: accounts
}) })
); );
} else { } else {
@ -235,51 +287,6 @@ export class PortfolioTableService {
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
const activitiesTableRows = activities.map(
({ account, assetProfile, currency, date, type, unitPrice }) => {
return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACCOUNT':
row[name] = account?.name ?? '';
break;
case 'CURRENCY':
row[name] = currency ?? assetProfile.currency;
break;
case 'DATE':
row[name] = format(date, DATE_FORMAT);
break;
case 'NAME':
row[name] = assetProfile.name ?? '';
break;
case 'SYMBOL':
row[name] = assetProfile.symbol;
break;
case 'TYPE':
row[name] = type;
break;
case 'UNIT_PRICE':
row[name] = unitPrice.toString();
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
const activitiesSection = [ const activitiesSection = [
'## Activities', '## Activities',
'', '',
@ -290,13 +297,13 @@ export class PortfolioTableService {
}) })
]; ];
if (activitiesTableRows.length > 0) { if (activities.length > 0) {
activitiesSection.push( activitiesSection.push(
'', '',
await this.getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
rows: activitiesTableRows rows: activities
}) })
); );
} }
@ -330,79 +337,27 @@ export class PortfolioTableService {
values: Object.values(AssetSubClass) values: Object.values(AssetSubClass)
}); });
const holdingsTableRows = [...holdings] const holdingsWithTranslations = [...holdings]
.sort((a, b) => { .sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage; return b.allocationInPercentage - a.allocationInPercentage;
}) })
.map( .map((holding) => {
({ return {
activitiesCount, ...holding,
allocationInPercentage, assetClassLabel:
assetProfile: { assetClassTranslations[holding.assetProfile.assetClass] ?? '',
assetClass, assetSubClassLabel:
assetSubClass, assetSubClassTranslations[holding.assetProfile.assetSubClass] ?? ''
currency, };
name: label, });
symbol
},
dateOfFirstActivity
}) => {
return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACTIVITIES_COUNT':
row[name] = activitiesCount.toString();
break;
case 'ALLOCATION_PERCENTAGE':
row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`;
break;
case 'ASSET_CLASS':
row[name] = assetClassTranslations[assetClass] ?? '';
break;
case 'ASSET_SUB_CLASS':
row[name] = assetSubClassTranslations[assetSubClass] ?? '';
break;
case 'CURRENCY':
row[name] = currency;
break;
case 'DATE_OF_FIRST_ACTIVITY':
row[name] = dateOfFirstActivity
? format(dateOfFirstActivity, DATE_FORMAT)
: '';
break;
case 'NAME':
row[name] = label;
break;
case 'SYMBOL':
row[name] = symbol;
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
return [ return [
'## Holdings', '## Holdings',
'', '',
await this.getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
rows: holdingsTableRows rows: holdingsWithTranslations
}) })
].join('\n'); ].join('\n');
} }
@ -463,25 +418,4 @@ export class PortfolioTableService {
{} as Record<T, string> {} as Record<T, string>
); );
} }
private async getMarkdownTable({
columnDefinitions,
rows
}: {
columnDefinitions: readonly ColumnDescriptor[];
rows: Record<string, 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')>;
const { tablemark } = await dynamicImport('tablemark');
return tablemark(rows, {
columns: columnDefinitions.map(({ align, name }) => {
return { name, align: align ?? 'left' };
})
});
}
} }

Loading…
Cancel
Save