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.
612 lines
15 KiB
612 lines
15 KiB
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
|
|
import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service';
|
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
|
import { transformDataSourceInResponse } from '@ghostfolio/api/helper/data-source.helper';
|
|
import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
|
|
import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper';
|
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
|
import {
|
|
DATE_FORMAT,
|
|
isAccountExcluded,
|
|
isCashPosition
|
|
} from '@ghostfolio/common/helper';
|
|
import {
|
|
Activity,
|
|
Filter,
|
|
PortfolioPerformance,
|
|
WatchlistResponse
|
|
} from '@ghostfolio/common/interfaces';
|
|
import { AccountWithValue, DateRange } from '@ghostfolio/common/types';
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
import {
|
|
AssetClass,
|
|
AssetSubClass,
|
|
DataSource,
|
|
Type as ActivityType
|
|
} from '@prisma/client';
|
|
import { format } from 'date-fns';
|
|
|
|
import { DataSourceTableContext } from './interfaces/data-source-table-context.interface';
|
|
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
|
|
|
|
const DATA_SOURCE_COLUMN_NAME = 'Data Source';
|
|
|
|
function getDataSourceColumnDefinition<T>(
|
|
getDataSource: (row: T) => DataSource | undefined
|
|
): TableColumnDefinition<T, DataSourceTableContext> {
|
|
return {
|
|
getValue: (row, { configurationService }) => {
|
|
const dataSource = getDataSource(row);
|
|
|
|
return dataSource
|
|
? transformDataSourceInResponse({ configurationService, dataSource })
|
|
: '';
|
|
},
|
|
name: DATA_SOURCE_COLUMN_NAME
|
|
};
|
|
}
|
|
|
|
function getPercentage(value: number) {
|
|
return `${(value * 100).toFixed(3)}%`;
|
|
}
|
|
|
|
/**
|
|
* Renders the accounts, the activities, the holdings and the performance of a
|
|
* portfolio and the watchlist of its user 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 getPercentage(allocationInPercentage);
|
|
},
|
|
name: 'Allocation in Percentage'
|
|
},
|
|
{
|
|
getValue: ({ tags }) => {
|
|
return isAccountExcluded({ tags }).toString();
|
|
},
|
|
name: 'Excluded from Analysis'
|
|
}
|
|
];
|
|
|
|
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
|
|
Activity,
|
|
DataSourceTableContext
|
|
>[] = [
|
|
{
|
|
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'
|
|
},
|
|
getDataSourceColumnDefinition(({ assetProfile }) => {
|
|
return assetProfile.dataSource;
|
|
}),
|
|
{
|
|
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'
|
|
},
|
|
getDataSourceColumnDefinition(({ assetProfile }) => {
|
|
return isCashPosition(assetProfile)
|
|
? undefined
|
|
: assetProfile.dataSource;
|
|
}),
|
|
{
|
|
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 getPercentage(allocationInPercentage);
|
|
},
|
|
name: 'Allocation in Percentage'
|
|
}
|
|
];
|
|
|
|
private static readonly PERFORMANCE_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<PortfolioPerformance>[] =
|
|
[
|
|
{
|
|
align: 'right',
|
|
getValue: ({ netPerformancePercentage }) => {
|
|
return getPercentage(netPerformancePercentage);
|
|
},
|
|
name: 'Asset Performance in Percentage'
|
|
},
|
|
{
|
|
align: 'right',
|
|
getValue: ({
|
|
netPerformancePercentage,
|
|
netPerformancePercentageWithCurrencyEffect
|
|
}) => {
|
|
const currencyPerformancePercentage = getPercentage(
|
|
netPerformancePercentageWithCurrencyEffect -
|
|
netPerformancePercentage
|
|
);
|
|
|
|
return Number.parseFloat(currencyPerformancePercentage) === 0
|
|
? getPercentage(0)
|
|
: currencyPerformancePercentage;
|
|
},
|
|
name: 'Currency Performance in Percentage'
|
|
},
|
|
{
|
|
align: 'right',
|
|
getValue: ({ netPerformancePercentageWithCurrencyEffect }) => {
|
|
return getPercentage(netPerformancePercentageWithCurrencyEffect);
|
|
},
|
|
name: 'Net Performance in Percentage'
|
|
}
|
|
];
|
|
|
|
private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
|
|
WatchlistResponse['watchlist'][number],
|
|
DataSourceTableContext
|
|
>[] = [
|
|
{
|
|
getValue: ({ name }) => {
|
|
return name ?? '';
|
|
},
|
|
name: 'Name'
|
|
},
|
|
{
|
|
getValue: ({ symbol }) => {
|
|
return symbol;
|
|
},
|
|
name: 'Symbol'
|
|
},
|
|
getDataSourceColumnDefinition(({ dataSource }) => {
|
|
return dataSource;
|
|
}),
|
|
{
|
|
getValue: ({ trend50d }) => {
|
|
return trend50d;
|
|
},
|
|
name: 'Trend 50 Days'
|
|
},
|
|
{
|
|
getValue: ({ trend200d }) => {
|
|
return trend200d;
|
|
},
|
|
name: 'Trend 200 Days'
|
|
},
|
|
{
|
|
getValue: ({ performances }) => {
|
|
return performances.allTimeHigh.date
|
|
? format(performances.allTimeHigh.date, DATE_FORMAT)
|
|
: '';
|
|
},
|
|
name: 'Date of Last All Time High'
|
|
},
|
|
{
|
|
align: 'right',
|
|
getValue: ({ performances }) => {
|
|
return getPercentage(performances.allTimeHigh.performancePercent);
|
|
},
|
|
name: 'Change from All Time High'
|
|
},
|
|
{
|
|
getValue: ({ marketCondition }) => {
|
|
return marketCondition;
|
|
},
|
|
name: 'Market Condition'
|
|
}
|
|
];
|
|
|
|
public constructor(
|
|
private readonly activitiesService: ActivitiesService,
|
|
private readonly configurationService: ConfigurationService,
|
|
private readonly i18nService: I18nService,
|
|
private readonly portfolioService: PortfolioService,
|
|
private readonly watchlistService: WatchlistService
|
|
) {}
|
|
|
|
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 static getPerformanceTableColumnNames() {
|
|
return PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS.map(
|
|
({ name }) => {
|
|
return name;
|
|
}
|
|
);
|
|
}
|
|
|
|
public static getWatchlistTableColumnNames() {
|
|
return PortfolioTableService.WATCHLIST_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,
|
|
context: { configurationService: this.configurationService },
|
|
rows: activities
|
|
})
|
|
);
|
|
}
|
|
|
|
return activitiesSection.join('\n');
|
|
}
|
|
|
|
public async getHoldingsTable({
|
|
filters,
|
|
languageCode,
|
|
userId,
|
|
withDataSource = false
|
|
}: {
|
|
filters?: Filter[];
|
|
languageCode: string;
|
|
userId: string;
|
|
withDataSource?: boolean;
|
|
}) {
|
|
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.filter(
|
|
({ name }) => {
|
|
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME;
|
|
}
|
|
),
|
|
context: {
|
|
assetClassTranslations,
|
|
assetSubClassTranslations,
|
|
configurationService: this.configurationService
|
|
},
|
|
rows: sortedHoldings
|
|
})
|
|
].join('\n');
|
|
}
|
|
|
|
public async getPerformanceTable({
|
|
dateRange,
|
|
filters,
|
|
userId
|
|
}: {
|
|
dateRange: DateRange;
|
|
filters?: Filter[];
|
|
userId: string;
|
|
}) {
|
|
const { chart, performance } = await this.portfolioService.getPerformance({
|
|
dateRange,
|
|
filters,
|
|
userId
|
|
});
|
|
|
|
const performanceSection = ['## Performance', ''];
|
|
|
|
if (chart?.length > 0) {
|
|
performanceSection.push(
|
|
await getMarkdownTable({
|
|
columnDefinitions:
|
|
PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS,
|
|
rows: [performance]
|
|
})
|
|
);
|
|
} else {
|
|
performanceSection.push('No performance found.');
|
|
}
|
|
|
|
return performanceSection.join('\n');
|
|
}
|
|
|
|
public async getWatchlistTable({ userId }: { userId: string }) {
|
|
const watchlist = await this.watchlistService.getWatchlistItems(userId);
|
|
|
|
const watchlistSection = ['## Watchlist', ''];
|
|
|
|
if (watchlist.length > 0) {
|
|
watchlistSection.push(
|
|
await getMarkdownTable({
|
|
columnDefinitions:
|
|
PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS,
|
|
context: { configurationService: this.configurationService },
|
|
rows: watchlist
|
|
})
|
|
);
|
|
} else {
|
|
watchlistSection.push('No watchlist items found.');
|
|
}
|
|
|
|
return watchlistSection.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>
|
|
);
|
|
}
|
|
}
|
|
|