mirror of https://github.com/ghostfolio/ghostfolio
10 changed files with 568 additions and 575 deletions
@ -0,0 +1,14 @@ |
|||
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; |
|||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; |
|||
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { PortfolioTableService } from './portfolio-table.service'; |
|||
|
|||
@Module({ |
|||
exports: [PortfolioTableService], |
|||
imports: [ActivitiesModule, I18nModule, PortfolioModule], |
|||
providers: [PortfolioTableService] |
|||
}) |
|||
export class PortfolioTableModule {} |
|||
@ -0,0 +1,487 @@ |
|||
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; |
|||
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; |
|||
import { Filter } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { |
|||
AssetClass, |
|||
AssetSubClass, |
|||
Type as ActivityType |
|||
} from '@prisma/client'; |
|||
import { format } from 'date-fns'; |
|||
import type { ColumnDescriptor } from 'tablemark'; |
|||
|
|||
/** |
|||
* Renders the accounts, the activities and the holdings of a portfolio as a |
|||
* markdown table. No table has a column with a quantity or with a monetary |
|||
* value, except the unit price of an activity. |
|||
*/ |
|||
@Injectable() |
|||
export class PortfolioTableService { |
|||
private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({ |
|||
key: |
|||
| 'ACTIVITIES_COUNT' |
|||
| 'ALLOCATION_PERCENTAGE' |
|||
| 'CURRENCY' |
|||
| 'EXCLUDED_FROM_ANALYSIS' |
|||
| 'ID' |
|||
| 'NAME' |
|||
| 'PLATFORM'; |
|||
} & ColumnDescriptor)[] = [ |
|||
{ key: 'ID', name: 'Id' }, |
|||
{ key: 'NAME', name: 'Name' }, |
|||
{ key: 'CURRENCY', name: 'Currency' }, |
|||
{ key: 'PLATFORM', name: 'Platform' }, |
|||
{ align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, |
|||
{ |
|||
align: 'right', |
|||
key: 'ALLOCATION_PERCENTAGE', |
|||
name: 'Allocation in Percentage' |
|||
}, |
|||
{ key: 'EXCLUDED_FROM_ANALYSIS', name: 'Excluded from Analysis' } |
|||
]; |
|||
|
|||
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({ |
|||
key: |
|||
| 'ACCOUNT' |
|||
| 'CURRENCY' |
|||
| 'DATE' |
|||
| 'NAME' |
|||
| 'SYMBOL' |
|||
| 'TYPE' |
|||
| 'UNIT_PRICE'; |
|||
} & ColumnDescriptor)[] = [ |
|||
{ key: 'DATE', name: 'Date' }, |
|||
{ key: 'TYPE', name: 'Type' }, |
|||
{ key: 'NAME', name: 'Name' }, |
|||
{ key: 'SYMBOL', name: 'Symbol' }, |
|||
{ key: 'CURRENCY', name: 'Currency' }, |
|||
{ align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' }, |
|||
{ key: 'ACCOUNT', name: 'Account' } |
|||
]; |
|||
|
|||
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ |
|||
key: |
|||
| 'ACTIVITIES_COUNT' |
|||
| 'ALLOCATION_PERCENTAGE' |
|||
| 'ASSET_CLASS' |
|||
| 'ASSET_SUB_CLASS' |
|||
| 'CURRENCY' |
|||
| 'DATE_OF_FIRST_ACTIVITY' |
|||
| 'NAME' |
|||
| 'SYMBOL'; |
|||
} & ColumnDescriptor)[] = [ |
|||
{ key: 'NAME', name: 'Name' }, |
|||
{ key: 'SYMBOL', name: 'Symbol' }, |
|||
{ key: 'CURRENCY', name: 'Currency' }, |
|||
{ key: 'ASSET_CLASS', name: 'Asset Class' }, |
|||
{ key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' }, |
|||
{ key: 'DATE_OF_FIRST_ACTIVITY', name: 'Date of First Activity' }, |
|||
{ align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, |
|||
{ |
|||
align: 'right', |
|||
key: 'ALLOCATION_PERCENTAGE', |
|||
name: 'Allocation in Percentage' |
|||
} |
|||
]; |
|||
|
|||
public constructor( |
|||
private readonly activitiesService: ActivitiesService, |
|||
private readonly i18nService: I18nService, |
|||
private readonly portfolioService: PortfolioService |
|||
) {} |
|||
|
|||
public static getAccountsTableColumnNames() { |
|||
return PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map( |
|||
({ name }) => { |
|||
return name; |
|||
} |
|||
); |
|||
} |
|||
|
|||
public static getActivitiesTableColumnNames() { |
|||
return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map( |
|||
({ name }) => { |
|||
return name; |
|||
} |
|||
); |
|||
} |
|||
|
|||
public static getHoldingsTableColumnNames() { |
|||
return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map( |
|||
({ name }) => { |
|||
return name; |
|||
} |
|||
); |
|||
} |
|||
|
|||
public async getAccountsTable({ |
|||
filters, |
|||
userId |
|||
}: { |
|||
filters?: Filter[]; |
|||
userId: string; |
|||
}) { |
|||
const { accounts } = |
|||
await this.portfolioService.getAccountsWithAggregations({ |
|||
filters, |
|||
userId, |
|||
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', '']; |
|||
|
|||
if (accountsTableRows.length > 0) { |
|||
accountsSection.push( |
|||
await this.getMarkdownTable({ |
|||
columnDefinitions: |
|||
PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, |
|||
rows: accountsTableRows |
|||
}) |
|||
); |
|||
} else { |
|||
accountsSection.push('No accounts found.'); |
|||
} |
|||
|
|||
return accountsSection.join('\n'); |
|||
} |
|||
|
|||
public async getActivitiesTable({ |
|||
endDate, |
|||
filters, |
|||
skip = 0, |
|||
startDate, |
|||
take, |
|||
types, |
|||
userCurrency, |
|||
userId |
|||
}: { |
|||
endDate?: Date; |
|||
filters?: Filter[]; |
|||
skip?: number; |
|||
startDate?: Date; |
|||
take: number; |
|||
types?: ActivityType[]; |
|||
userCurrency: string; |
|||
userId: string; |
|||
}) { |
|||
const { activities, count } = await this.activitiesService.getActivities({ |
|||
endDate, |
|||
filters, |
|||
skip, |
|||
startDate, |
|||
take, |
|||
types, |
|||
userCurrency, |
|||
userId, |
|||
includeDrafts: true, |
|||
sortColumn: 'date', |
|||
sortDirection: 'desc', |
|||
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 = [ |
|||
'## Activities', |
|||
'', |
|||
this.getActivitiesSummary({ |
|||
count, |
|||
skip, |
|||
numberOfActivities: activities.length |
|||
}) |
|||
]; |
|||
|
|||
if (activitiesTableRows.length > 0) { |
|||
activitiesSection.push( |
|||
'', |
|||
await this.getMarkdownTable({ |
|||
columnDefinitions: |
|||
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, |
|||
rows: activitiesTableRows |
|||
}) |
|||
); |
|||
} |
|||
|
|||
return activitiesSection.join('\n'); |
|||
} |
|||
|
|||
public async getHoldingsTable({ |
|||
filters, |
|||
languageCode, |
|||
userId |
|||
}: { |
|||
filters?: Filter[]; |
|||
languageCode: string; |
|||
userId: string; |
|||
}) { |
|||
const { holdings } = await this.portfolioService.getDetails({ |
|||
filters, |
|||
userId |
|||
}); |
|||
|
|||
const assetClassTranslations = this.getEnumTranslations({ |
|||
languageCode, |
|||
id: 'assetClass', |
|||
values: Object.values(AssetClass) |
|||
}); |
|||
|
|||
const assetSubClassTranslations = this.getEnumTranslations({ |
|||
languageCode, |
|||
id: 'assetSubClass', |
|||
values: Object.values(AssetSubClass) |
|||
}); |
|||
|
|||
const holdingsTableRows = [...holdings] |
|||
.sort((a, b) => { |
|||
return b.allocationInPercentage - a.allocationInPercentage; |
|||
}) |
|||
.map( |
|||
({ |
|||
activitiesCount, |
|||
allocationInPercentage, |
|||
assetProfile: { |
|||
assetClass, |
|||
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 [ |
|||
'## Holdings', |
|||
'', |
|||
await this.getMarkdownTable({ |
|||
columnDefinitions: |
|||
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, |
|||
rows: holdingsTableRows |
|||
}) |
|||
].join('\n'); |
|||
} |
|||
|
|||
private getActivitiesSummary({ |
|||
count, |
|||
numberOfActivities, |
|||
skip |
|||
}: { |
|||
count: number; |
|||
numberOfActivities: number; |
|||
skip: number; |
|||
}) { |
|||
if (count === 0) { |
|||
return 'No activities found.'; |
|||
} |
|||
|
|||
if (numberOfActivities === 0) { |
|||
return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`; |
|||
} |
|||
|
|||
if (numberOfActivities === count) { |
|||
return `Showing all ${count} activities, the most recent first.`; |
|||
} |
|||
|
|||
const lastActivity = skip + numberOfActivities; |
|||
|
|||
const summary = `Showing the activities ${ |
|||
skip + 1 |
|||
} to ${lastActivity} of ${count}, the most recent first.`;
|
|||
|
|||
if (lastActivity === count) { |
|||
return summary; |
|||
} |
|||
|
|||
return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`; |
|||
} |
|||
|
|||
private getEnumTranslations<T extends string>({ |
|||
id, |
|||
languageCode, |
|||
values |
|||
}: { |
|||
id: string; |
|||
languageCode: string; |
|||
values: T[]; |
|||
}) { |
|||
return values.reduce( |
|||
(translations, value) => { |
|||
translations[value] = |
|||
this.i18nService.getTranslation({ |
|||
languageCode, |
|||
id: `${id}.${value}` |
|||
}) || value; |
|||
|
|||
return translations; |
|||
}, |
|||
{} 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…
Reference in new issue