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.
494 lines
15 KiB
494 lines
15 KiB
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|
import {
|
|
hasNotDefinedValuesInObject,
|
|
nullifyValuesInObject
|
|
} from '@ghostfolio/api/helper/object.helper';
|
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
|
import {
|
|
PortfolioDetails,
|
|
PortfolioDividends,
|
|
PortfolioInvestments,
|
|
PortfolioPerformanceResponse,
|
|
PortfolioPublicDetails,
|
|
PortfolioReport
|
|
} from '@ghostfolio/common/interfaces';
|
|
import type {
|
|
DateRange,
|
|
GroupBy,
|
|
RequestWithUser
|
|
} from '@ghostfolio/common/types';
|
|
import {
|
|
Controller,
|
|
Get,
|
|
Headers,
|
|
HttpException,
|
|
Inject,
|
|
Param,
|
|
Query,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
Version
|
|
} from '@nestjs/common';
|
|
import { REQUEST } from '@nestjs/core';
|
|
import { AuthGuard } from '@nestjs/passport';
|
|
import Big from 'big.js';
|
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
|
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
|
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
|
import { PortfolioService } from './portfolio.service';
|
|
|
|
@Controller('portfolio')
|
|
export class PortfolioController {
|
|
private baseCurrency: string;
|
|
|
|
public constructor(
|
|
private readonly accessService: AccessService,
|
|
private readonly apiService: ApiService,
|
|
private readonly configurationService: ConfigurationService,
|
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
private readonly portfolioService: PortfolioService,
|
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
|
private readonly userService: UserService
|
|
) {
|
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
|
}
|
|
|
|
@Get('details')
|
|
@UseGuards(AuthGuard('jwt'))
|
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
public async getDetails(
|
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
|
@Query('accounts') filterByAccounts?: string,
|
|
@Query('assetClasses') filterByAssetClasses?: string,
|
|
@Query('range') dateRange: DateRange = 'max',
|
|
@Query('tags') filterByTags?: string
|
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
|
let hasDetails = true;
|
|
let hasError = false;
|
|
|
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
hasDetails = this.request.user.subscription.type === 'Premium';
|
|
}
|
|
|
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
filterByAccounts,
|
|
filterByAssetClasses,
|
|
filterByTags
|
|
});
|
|
|
|
const {
|
|
accounts,
|
|
filteredValueInBaseCurrency,
|
|
filteredValueInPercentage,
|
|
hasErrors,
|
|
holdings,
|
|
summary,
|
|
totalValueInBaseCurrency
|
|
} = await this.portfolioService.getDetails({
|
|
dateRange,
|
|
filters,
|
|
impersonationId,
|
|
userId: this.request.user.id
|
|
});
|
|
|
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
|
hasError = true;
|
|
}
|
|
|
|
let portfolioSummary = summary;
|
|
|
|
if (
|
|
impersonationId ||
|
|
this.userService.isRestrictedView(this.request.user)
|
|
) {
|
|
const totalInvestment = Object.values(holdings)
|
|
.map((portfolioPosition) => {
|
|
return portfolioPosition.investment;
|
|
})
|
|
.reduce((a, b) => a + b, 0);
|
|
|
|
const totalValue = Object.values(holdings)
|
|
.map((portfolioPosition) => {
|
|
return this.exchangeRateDataService.toCurrency(
|
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
|
portfolioPosition.currency,
|
|
this.request.user.Settings.settings.baseCurrency
|
|
);
|
|
})
|
|
.reduce((a, b) => a + b, 0);
|
|
|
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
|
portfolioPosition.grossPerformance = null;
|
|
portfolioPosition.investment =
|
|
portfolioPosition.investment / totalInvestment;
|
|
portfolioPosition.netPerformance = null;
|
|
portfolioPosition.quantity = null;
|
|
portfolioPosition.valueInPercentage =
|
|
portfolioPosition.value / totalValue;
|
|
}
|
|
|
|
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
|
|
accounts[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
|
}
|
|
}
|
|
|
|
if (
|
|
hasDetails === false ||
|
|
impersonationId ||
|
|
this.userService.isRestrictedView(this.request.user)
|
|
) {
|
|
portfolioSummary = nullifyValuesInObject(summary, [
|
|
'cash',
|
|
'committedFunds',
|
|
'currentGrossPerformance',
|
|
'currentNetPerformance',
|
|
'currentValue',
|
|
'dividend',
|
|
'emergencyFund',
|
|
'excludedAccountsAndActivities',
|
|
'fees',
|
|
'items',
|
|
'netWorth',
|
|
'totalBuy',
|
|
'totalSell'
|
|
]);
|
|
}
|
|
|
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
|
holdings[symbol] = {
|
|
...portfolioPosition,
|
|
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
|
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
|
|
countries: hasDetails ? portfolioPosition.countries : [],
|
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
|
sectors: hasDetails ? portfolioPosition.sectors : []
|
|
};
|
|
}
|
|
|
|
return {
|
|
accounts,
|
|
filteredValueInBaseCurrency,
|
|
filteredValueInPercentage,
|
|
hasError,
|
|
holdings,
|
|
totalValueInBaseCurrency,
|
|
summary: portfolioSummary
|
|
};
|
|
}
|
|
|
|
@Get('dividends')
|
|
@UseGuards(AuthGuard('jwt'))
|
|
public async getDividends(
|
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
|
@Query('accounts') filterByAccounts?: string,
|
|
@Query('assetClasses') filterByAssetClasses?: string,
|
|
@Query('groupBy') groupBy?: GroupBy,
|
|
@Query('range') dateRange: DateRange = 'max',
|
|
@Query('tags') filterByTags?: string
|
|
): Promise<PortfolioDividends> {
|
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
filterByAccounts,
|
|
filterByAssetClasses,
|
|
filterByTags
|
|
});
|
|
|
|
let dividends = await this.portfolioService.getDividends({
|
|
dateRange,
|
|
filters,
|
|
groupBy,
|
|
impersonationId
|
|
});
|
|
|
|
if (
|
|
impersonationId ||
|
|
this.userService.isRestrictedView(this.request.user)
|
|
) {
|
|
const maxDividend = dividends.reduce(
|
|
(investment, item) => Math.max(investment, item.investment),
|
|
1
|
|
);
|
|
|
|
dividends = dividends.map((item) => ({
|
|
date: item.date,
|
|
investment: item.investment / maxDividend
|
|
}));
|
|
}
|
|
|
|
if (
|
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
this.request.user.subscription.type === 'Basic'
|
|
) {
|
|
dividends = dividends.map((item) => {
|
|
return nullifyValuesInObject(item, ['investment']);
|
|
});
|
|
}
|
|
|
|
return { dividends };
|
|
}
|
|
|
|
@Get('investments')
|
|
@UseGuards(AuthGuard('jwt'))
|
|
public async getInvestments(
|
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
|
@Query('accounts') filterByAccounts?: string,
|
|
@Query('assetClasses') filterByAssetClasses?: string,
|
|
@Query('groupBy') groupBy?: GroupBy,
|
|
@Query('range') dateRange: DateRange = 'max',
|
|
@Query('tags') filterByTags?: string
|
|
): Promise<PortfolioInvestments> {
|
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
filterByAccounts,
|
|
filterByAssetClasses,
|
|
filterByTags
|
|
});
|
|
|
|
let investments = await this.portfolioService.getInvestments({
|
|
dateRange,
|
|
filters,
|
|
groupBy,
|
|
impersonationId
|
|
});
|
|
|
|
if (
|
|
impersonationId ||
|
|
this.userService.isRestrictedView(this.request.user)
|
|
) {
|
|
const maxInvestment = investments.reduce(
|
|
(investment, item) => Math.max(investment, item.investment),
|
|
1
|
|
);
|
|
|
|
investments = investments.map((item) => ({
|
|
date: item.date,
|
|
investment: item.investment / maxInvestment
|
|
}));
|
|
}
|
|
|
|
if (
|
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
this.request.user.subscription.type === 'Basic'
|
|
) {
|
|
investments = investments.map((item) => {
|
|
return nullifyValuesInObject(item, ['investment']);
|
|
});
|
|
}
|
|
|
|
return { investments };
|
|
}
|
|
|
|
@Get('performance')
|
|
@UseGuards(AuthGuard('jwt'))
|
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
@Version('2')
|
|
public async getPerformanceV2(
|
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
|
@Query('accounts') filterByAccounts?: string,
|
|
@Query('assetClasses') filterByAssetClasses?: string,
|
|
@Query('range') dateRange: DateRange = 'max',
|
|
@Query('tags') filterByTags?: string
|
|
): Promise<PortfolioPerformanceResponse> {
|
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
filterByAccounts,
|
|
filterByAssetClasses,
|
|
filterByTags
|
|
});
|
|
|
|
const performanceInformation = await this.portfolioService.getPerformance({
|
|
dateRange,
|
|
filters,
|
|
impersonationId,
|
|
userId: this.request.user.id
|
|
});
|
|
|
|
if (
|
|
impersonationId ||
|
|
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
|
this.userService.isRestrictedView(this.request.user)
|
|
) {
|
|
performanceInformation.chart = performanceInformation.chart.map(
|
|
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
|
|
return {
|
|
date,
|
|
netPerformanceInPercentage,
|
|
totalInvestment: new Big(totalInvestment)
|
|
.div(performanceInformation.performance.totalInvestment)
|
|
.toNumber(),
|
|
valueInPercentage: new Big(value)
|
|
.div(performanceInformation.performance.currentValue)
|
|
.toNumber()
|
|
};
|
|
}
|
|
);
|
|
|
|
performanceInformation.performance = nullifyValuesInObject(
|
|
performanceInformation.performance,
|
|
[
|
|
'currentGrossPerformance',
|
|
'currentNetPerformance',
|
|
'currentValue',
|
|
'totalInvestment'
|
|
]
|
|
);
|
|
}
|
|
|
|
if (
|
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
this.request.user.subscription.type === 'Basic'
|
|
) {
|
|
performanceInformation.chart = performanceInformation.chart.map(
|
|
(item) => {
|
|
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
|
}
|
|
);
|
|
}
|
|
|
|
return performanceInformation;
|
|
}
|
|
|
|
@Get('positions')
|
|
@UseGuards(AuthGuard('jwt'))
|
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
public async getPositions(
|
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
|
@Query('accounts') filterByAccounts?: string,
|
|
@Query('assetClasses') filterByAssetClasses?: string,
|
|
@Query('range') dateRange: DateRange = 'max',
|
|
@Query('tags') filterByTags?: string
|
|
): Promise<PortfolioPositions> {
|
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
filterByAccounts,
|
|
filterByAssetClasses,
|
|
filterByTags
|
|
});
|
|
|
|
return this.portfolioService.getPositions({
|
|
dateRange,
|
|
filters,
|
|
impersonationId
|
|
});
|
|
}
|
|
|
|
@Get('public/:accessId')
|
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
public async getPublic(
|
|
@Param('accessId') accessId
|
|
): Promise<PortfolioPublicDetails> {
|
|
const access = await this.accessService.access({ id: accessId });
|
|
const user = await this.userService.user({
|
|
id: access.userId
|
|
});
|
|
|
|
if (!access) {
|
|
throw new HttpException(
|
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
StatusCodes.NOT_FOUND
|
|
);
|
|
}
|
|
|
|
let hasDetails = true;
|
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
hasDetails = user.subscription.type === 'Premium';
|
|
}
|
|
|
|
const { holdings } = await this.portfolioService.getDetails({
|
|
dateRange: 'max',
|
|
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
|
impersonationId: access.userId,
|
|
userId: user.id
|
|
});
|
|
|
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
|
hasDetails,
|
|
alias: access.alias,
|
|
holdings: {}
|
|
};
|
|
|
|
const totalValue = Object.values(holdings)
|
|
.map((portfolioPosition) => {
|
|
return this.exchangeRateDataService.toCurrency(
|
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
|
portfolioPosition.currency,
|
|
this.request.user?.Settings?.settings.baseCurrency ??
|
|
this.baseCurrency
|
|
);
|
|
})
|
|
.reduce((a, b) => a + b, 0);
|
|
|
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
|
portfolioPublicDetails.holdings[symbol] = {
|
|
allocationInPercentage: portfolioPosition.value / totalValue,
|
|
countries: hasDetails ? portfolioPosition.countries : [],
|
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
|
dataSource: portfolioPosition.dataSource,
|
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
|
name: portfolioPosition.name,
|
|
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
|
symbol: portfolioPosition.symbol,
|
|
url: portfolioPosition.url,
|
|
valueInPercentage: portfolioPosition.value / totalValue
|
|
};
|
|
}
|
|
|
|
return portfolioPublicDetails;
|
|
}
|
|
|
|
@Get('position/:dataSource/:symbol')
|
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
@UseGuards(AuthGuard('jwt'))
|
|
public async getPosition(
|
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
|
@Param('dataSource') dataSource,
|
|
@Param('symbol') symbol
|
|
): Promise<PortfolioPositionDetail> {
|
|
const position = await this.portfolioService.getPosition(
|
|
dataSource,
|
|
impersonationId,
|
|
symbol
|
|
);
|
|
|
|
if (position) {
|
|
return position;
|
|
}
|
|
|
|
throw new HttpException(
|
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
StatusCodes.NOT_FOUND
|
|
);
|
|
}
|
|
|
|
@Get('report')
|
|
@UseGuards(AuthGuard('jwt'))
|
|
public async getReport(
|
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
|
): Promise<PortfolioReport> {
|
|
const report = await this.portfolioService.getReport(impersonationId);
|
|
|
|
if (
|
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
this.request.user.subscription.type === 'Basic'
|
|
) {
|
|
for (const rule in report.rules) {
|
|
if (report.rules[rule]) {
|
|
report.rules[rule] = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
return report;
|
|
}
|
|
}
|
|
|