mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
399 lines
9.7 KiB
399 lines
9.7 KiB
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
|
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
|
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 } from '@ghostfolio/common/interfaces';
|
|
import { AccountWithValue } from '@ghostfolio/common/types';
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
import {
|
|
AssetClass,
|
|
AssetSubClass,
|
|
Type as ActivityType
|
|
} from '@prisma/client';
|
|
import { format } from 'date-fns';
|
|
|
|
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
|
|
|
|
function getAllocationInPercentage(allocationInPercentage: number) {
|
|
return `${(allocationInPercentage * 100).toFixed(3)}%`;
|
|
}
|
|
|
|
/**
|
|
* 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: TableColumnDefinition<AccountWithValue>[] =
|
|
[
|
|
{
|
|
getValue: ({ id }) => {
|
|
return id;
|
|
},
|
|
name: 'Id'
|
|
},
|
|
{
|
|
getValue: ({ name }) => {
|
|
return name ?? '';
|
|
},
|
|
name: 'Name'
|
|
},
|
|
{
|
|
getValue: ({ currency }) => {
|
|
return currency ?? '';
|
|
},
|
|
name: 'Currency'
|
|
},
|
|
{
|
|
getValue: ({ platform }) => {
|
|
return platform?.name ?? '';
|
|
},
|
|
name: 'Platform'
|
|
},
|
|
{
|
|
align: 'right',
|
|
getValue: ({ activitiesCount }) => {
|
|
return activitiesCount.toString();
|
|
},
|
|
name: 'Activities Count'
|
|
},
|
|
{
|
|
align: 'right',
|
|
getValue: ({ allocationInPercentage }) => {
|
|
return getAllocationInPercentage(allocationInPercentage);
|
|
},
|
|
name: 'Allocation in Percentage'
|
|
},
|
|
{
|
|
getValue: ({ tags }) => {
|
|
return isAccountExcluded({ tags }).toString();
|
|
},
|
|
name: 'Excluded from Analysis'
|
|
}
|
|
];
|
|
|
|
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<Activity>[] =
|
|
[
|
|
{
|
|
getValue: ({ date }) => {
|
|
return format(date, DATE_FORMAT);
|
|
},
|
|
name: 'Date'
|
|
},
|
|
{
|
|
getValue: ({ type }) => {
|
|
return type;
|
|
},
|
|
name: 'Type'
|
|
},
|
|
{
|
|
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: HoldingsTableColumnDefinition[] =
|
|
[
|
|
{
|
|
getValue: ({ assetProfile }) => {
|
|
return assetProfile.name;
|
|
},
|
|
name: 'Name'
|
|
},
|
|
{
|
|
getValue: ({ assetProfile }) => {
|
|
return assetProfile.symbol;
|
|
},
|
|
name: 'Symbol'
|
|
},
|
|
{
|
|
getValue: ({ assetProfile }) => {
|
|
return assetProfile.currency;
|
|
},
|
|
name: 'Currency'
|
|
},
|
|
{
|
|
getValue: ({ assetProfile }, { assetClassTranslations }) => {
|
|
return assetClassTranslations[assetProfile.assetClass] ?? '';
|
|
},
|
|
name: 'Asset Class'
|
|
},
|
|
{
|
|
getValue: ({ assetProfile }, { assetSubClassTranslations }) => {
|
|
return assetSubClassTranslations[assetProfile.assetSubClass] ?? '';
|
|
},
|
|
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(
|
|
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 accountsSection = ['## Accounts', ''];
|
|
|
|
if (accounts.length > 0) {
|
|
accountsSection.push(
|
|
await getMarkdownTable({
|
|
columnDefinitions:
|
|
PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS,
|
|
rows: accounts
|
|
})
|
|
);
|
|
} 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 activitiesSection = [
|
|
'## Activities',
|
|
'',
|
|
this.getActivitiesSummary({
|
|
count,
|
|
skip,
|
|
numberOfActivities: activities.length
|
|
})
|
|
];
|
|
|
|
if (activities.length > 0) {
|
|
activitiesSection.push(
|
|
'',
|
|
await getMarkdownTable({
|
|
columnDefinitions:
|
|
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
|
|
rows: activities
|
|
})
|
|
);
|
|
}
|
|
|
|
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 sortedHoldings = [...holdings].sort((a, b) => {
|
|
return b.allocationInPercentage - a.allocationInPercentage;
|
|
});
|
|
|
|
return [
|
|
'## Holdings',
|
|
'',
|
|
await getMarkdownTable({
|
|
columnDefinitions:
|
|
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
|
|
context: { assetClassTranslations, assetSubClassTranslations },
|
|
rows: sortedHoldings
|
|
})
|
|
].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>
|
|
);
|
|
}
|
|
}
|
|
|