ghostfolio/apps/api/src/app/portfolio/portfolio.service.ts

746 lines
22 KiB
TypeScript
Raw Normal View History

import { AccountService } from '@ghostfolio/api/app/account/account.service';
2021-07-09 21:35:06 +02:00
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
2021-07-28 15:41:38 +02:00
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
2021-07-28 15:45:11 +02:00
import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
2021-07-20 22:52:50 +02:00
import { OrderType } from '@ghostfolio/api/models/order-type';
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { IOrder, Type } from '@ghostfolio/api/services/interfaces/interfaces';
import { RulesService } from '@ghostfolio/api/services/rules.service';
2021-07-28 16:11:19 +02:00
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
PortfolioItem,
2021-07-24 10:53:15 +02:00
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
Position,
TimelinePosition
} from '@ghostfolio/common/interfaces';
import {
DateRange,
OrderWithAccount,
RequestWithUser
} from '@ghostfolio/common/types';
2021-04-13 21:53:58 +02:00
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { DataSource } from '@prisma/client';
2021-07-09 21:35:06 +02:00
import Big from 'big.js';
2021-04-13 21:53:58 +02:00
import {
add,
addMonths,
2021-04-13 21:53:58 +02:00
format,
getDate,
getMonth,
getYear,
isAfter,
isSameDay,
max,
parse,
2021-04-13 21:53:58 +02:00
parseISO,
setDate,
setDayOfYear,
2021-04-13 21:53:58 +02:00
setMonth,
sub,
subDays,
subYears
2021-04-13 21:53:58 +02:00
} from 'date-fns';
import { isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import {
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
2021-04-13 21:53:58 +02:00
@Injectable()
export class PortfolioService {
public constructor(
private readonly accountService: AccountService,
2021-07-28 16:11:19 +02:00
private readonly currentRateService: CurrentRateService,
2021-04-13 21:53:58 +02:00
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService,
private readonly userService: UserService,
private readonly symbolProfileService: SymbolProfileService
2021-04-13 21:53:58 +02:00
) {}
public async createPortfolio(aUserId: string): Promise<Portfolio> {
let portfolio: Portfolio;
const stringifiedPortfolio = await this.redisCacheService.get(
2021-04-13 21:53:58 +02:00
`${aUserId}.portfolio`
);
const user = await this.userService.user({ id: aUserId });
if (stringifiedPortfolio) {
// Get portfolio from redis
const {
orders,
portfolioItems
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } =
JSON.parse(stringifiedPortfolio);
2021-04-13 21:53:58 +02:00
portfolio = new Portfolio(
this.accountService,
2021-04-13 21:53:58 +02:00
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
).createFromData({ orders, portfolioItems, user });
} else {
// Get portfolio from database
2021-07-11 20:59:23 +02:00
const orders = await this.getOrders(aUserId);
2021-04-13 21:53:58 +02:00
portfolio = new Portfolio(
this.accountService,
2021-04-13 21:53:58 +02:00
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
);
portfolio.setUser(user);
await portfolio.setOrders(orders);
// Cache data for the next time...
const portfolioData = {
orders: portfolio.getOrders(),
portfolioItems: portfolio.getPortfolioItems()
};
await this.redisCacheService.set(
`${aUserId}.portfolio`,
JSON.stringify(portfolioData)
);
}
// Enrich portfolio with current data
await portfolio.addCurrentPortfolioItems();
// Enrich portfolio with future data
await portfolio.addFuturePortfolioItems();
return portfolio;
2021-04-13 21:53:58 +02:00
}
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
try {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
2021-04-13 21:53:58 +02:00
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
return portfolio.get();
} catch (error) {
console.error(error);
}
}
public async getChart(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
2021-04-13 21:53:58 +02:00
2021-07-11 20:59:23 +02:00
const userId = impersonationUserId || this.request.user.id;
2021-04-13 21:53:58 +02:00
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const { transactionPoints } = await this.getTransactionPoints(userId);
2021-07-20 22:52:50 +02:00
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return [];
}
let portfolioStart = parse(
transactionPoints[0].date,
2021-07-28 16:11:19 +02:00
DATE_FORMAT,
new Date()
);
portfolioStart = this.getStartDate(aDateRange, portfolioStart);
const timelineSpecification: TimelineSpecification[] = [
{
2021-07-28 16:11:19 +02:00
start: format(portfolioStart, DATE_FORMAT),
accuracy: 'day'
}
];
const timeline = await portfolioCalculator.calculateTimeline(
timelineSpecification,
2021-07-28 16:11:19 +02:00
format(new Date(), DATE_FORMAT)
);
2021-07-13 22:51:32 +02:00
return timeline
.filter((timelineItem) => timelineItem !== null)
.map((timelineItem) => ({
date: timelineItem.date,
2021-07-28 16:11:19 +02:00
marketPrice: timelineItem.value,
value: timelineItem.grossPerformance.toNumber()
2021-07-13 22:51:32 +02:00
}));
}
2021-04-13 21:53:58 +02:00
public async getOverview(
aImpersonationId: string
): Promise<PortfolioOverview> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
2021-04-13 21:53:58 +02:00
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const { balance } = await this.accountService.getCashDetails(
impersonationUserId || this.request.user.id,
this.request.user.Settings.currency
);
2021-04-13 21:53:58 +02:00
const committedFunds = portfolio.getCommittedFunds();
const fees = portfolio.getFees();
return {
committedFunds,
fees,
cash: balance,
2021-04-13 21:53:58 +02:00
ordersCount: portfolio.getOrders().length,
totalBuy: portfolio.getTotalBuy(),
totalSell: portfolio.getTotalSell()
};
}
public async getDetails(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ [symbol: string]: PortfolioPosition }> {
const userId = await this.getUserId(aImpersonationId);
const userCurrency = this.request.user.Settings.currency;
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
userCurrency
);
const { transactionPoints, orders } = await this.getTransactionPoints(
userId
);
if (transactionPoints?.length <= 0) {
return {};
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
if (currentPositions.hasErrors) {
throw new Error('Missing information');
}
const result: { [symbol: string]: PortfolioPosition } = {};
const totalValue = currentPositions.currentValue;
const symbols = currentPositions.positions.map(
(position) => position.symbol
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(symbols),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
}
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency);
for (const item of currentPositions.positions) {
const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol];
result[item.symbol] = {
accounts,
allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment
.div(currentPositions.totalInvestment)
.toNumber(),
countries: symbolProfile.countries,
currency: item.currency,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance.toNumber(),
grossPerformancePercent: item.grossPerformancePercentage.toNumber(),
investment: item.investment.toNumber(),
marketPrice: item.marketPrice,
marketState: dataProviderResponse.marketState,
name: item.name,
quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors,
symbol: item.symbol,
transactionCount: item.transactionCount,
type: dataProviderResponse.type,
value: value.toNumber()
};
}
return result;
}
2021-04-13 21:53:58 +02:00
public async getPosition(
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const userId = await this.getUserId(aImpersonationId);
const portfolio = await this.createPortfolio(userId);
2021-04-13 21:53:58 +02:00
const position = portfolio.getPositions(new Date())[aSymbol];
2021-04-13 21:53:58 +02:00
if (position) {
const {
2021-04-13 21:53:58 +02:00
averagePrice,
currency,
firstBuyDate,
investment,
quantity,
transactionCount
} = position;
let marketPrice = position.marketPrice;
const orders = portfolio.getOrders(aSymbol);
2021-04-13 21:53:58 +02:00
const historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
parseISO(firstBuyDate),
new Date()
);
if (marketPrice === 0) {
marketPrice = averagePrice;
}
const historicalDataArray: HistoricalDataItem[] = [];
let currentAveragePrice: number;
2021-04-13 21:53:58 +02:00
let maxPrice = marketPrice;
let minPrice = marketPrice;
if (historicalData[aSymbol]) {
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
)) {
2021-07-28 16:11:19 +02:00
const currentDate = parse(date, DATE_FORMAT, new Date());
if (
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
) {
// Get snapshot of first day of next month
const snapshot = portfolio.get(
addMonths(setDate(currentDate, 1), 1)
)?.[0]?.positions[aSymbol];
orders.shift();
if (snapshot?.averagePrice) {
currentAveragePrice = snapshot.averagePrice;
}
}
2021-04-13 21:53:58 +02:00
historicalDataArray.push({
date,
averagePrice: currentAveragePrice,
2021-04-13 21:53:58 +02:00
value: marketPrice
});
if (
marketPrice &&
(marketPrice > maxPrice || maxPrice === undefined)
) {
maxPrice = marketPrice;
}
if (
marketPrice &&
(marketPrice < minPrice || minPrice === undefined)
) {
minPrice = marketPrice;
}
}
}
return {
averagePrice,
currency,
firstBuyDate,
investment,
marketPrice,
maxPrice,
minPrice,
quantity,
transactionCount,
2021-04-13 21:53:58 +02:00
grossPerformance: this.exchangeRateDataService.toCurrency(
marketPrice - averagePrice,
currency,
this.request.user.Settings.currency
),
grossPerformancePercent: roundTo(
(marketPrice - averagePrice) / averagePrice,
4
),
historicalData: historicalDataArray,
symbol: aSymbol
};
} else if (portfolio.getMinDate()) {
const currentData = await this.dataProviderService.get([aSymbol]);
let historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
portfolio.getMinDate(),
new Date()
);
if (isEmpty(historicalData)) {
historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
2021-04-13 21:53:58 +02:00
portfolio.getMinDate(),
new Date()
);
}
const historicalDataArray: HistoricalDataItem[] = [];
for (const [date, { marketPrice }] of Object.entries(
2021-04-13 21:53:58 +02:00
historicalData[aSymbol]
).reverse()) {
historicalDataArray.push({
date,
value: marketPrice
});
}
return {
averagePrice: undefined,
currency: currentData[aSymbol]?.currency,
2021-04-13 21:53:58 +02:00
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: undefined,
marketPrice: currentData[aSymbol]?.marketPrice,
2021-04-13 21:53:58 +02:00
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol,
transactionCount: undefined
2021-04-13 21:53:58 +02:00
};
}
return {
averagePrice: undefined,
currency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: [],
investment: undefined,
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol,
transactionCount: undefined
2021-04-13 21:53:58 +02:00
};
}
2021-04-20 21:52:01 +02:00
2021-07-25 13:31:44 +02:00
public async getPositions(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(aImpersonationId);
2021-07-25 13:31:44 +02:00
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const { transactionPoints } = await this.getTransactionPoints(userId);
2021-07-25 13:31:44 +02:00
2021-07-31 09:48:48 +02:00
if (transactionPoints?.length <= 0) {
return {
hasErrors: false,
positions: []
};
}
2021-07-25 13:31:44 +02:00
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
2021-07-25 13:31:44 +02:00
return {
hasErrors: currentPositions.hasErrors,
positions: currentPositions.positions.map((position) => {
return {
...position,
averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage:
position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(),
name: position.name,
quantity: new Big(position.quantity).toNumber(),
type: Type.Unknown, // TODO
url: '' // TODO
};
})
};
2021-07-25 13:31:44 +02:00
}
public async getPerformance(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const userId = impersonationUserId || this.request.user.id;
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const { transactionPoints } = await this.getTransactionPoints(userId);
2021-07-31 09:31:45 +02:00
if (transactionPoints?.length <= 0) {
return {
hasErrors: false,
performance: {
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
}
};
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
const hasErrors = currentPositions.hasErrors;
const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance =
currentPositions.grossPerformance.toNumber();
const currentGrossPerformancePercent =
currentPositions.grossPerformancePercentage.toNumber();
return {
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
currentGrossPerformance,
currentGrossPerformancePercent,
// TODO: the next two should include fees
currentNetPerformance: currentGrossPerformance,
currentNetPerformancePercent: currentGrossPerformancePercent,
currentValue: currentValue
}
};
}
2021-07-25 13:31:44 +02:00
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) {
case '1d':
portfolioStart = max([portfolioStart, subDays(new Date(), 1)]);
break;
case 'ytd':
portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]);
break;
case '1y':
portfolioStart = max([portfolioStart, subYears(new Date(), 1)]);
break;
case '5y':
portfolioStart = max([portfolioStart, subYears(new Date(), 5)]);
break;
}
return portfolioStart;
}
private async getTransactionPoints(userId: string): Promise<{
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
}> {
2021-07-25 13:31:44 +02:00
const orders = await this.getOrders(userId);
if (orders.length <= 0) {
return { transactionPoints: [], orders: [] };
2021-07-25 13:31:44 +02:00
}
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
2021-07-28 16:11:19 +02:00
date: format(order.date, DATE_FORMAT),
2021-07-25 13:31:44 +02:00
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
type: <OrderType>order.type,
unitPrice: new Big(order.unitPrice)
}));
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
portfolioCalculator.computeTransactionPoints(portfolioOrders);
return {
transactionPoints: portfolioCalculator.getTransactionPoints(),
orders
};
2021-07-25 13:31:44 +02:00
}
2021-04-20 21:52:01 +02:00
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
let currentDate = new Date();
const normalizedMinDate =
getDate(aMinDate) === 1
? aMinDate
: add(setDate(aMinDate, 1), { months: 1 });
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
currentDate = new Date(Date.UTC(year, month, day, 0));
switch (aDateRange) {
case '1d':
return sub(currentDate, {
days: 1
});
case 'ytd':
currentDate = setDate(currentDate, 1);
currentDate = setMonth(currentDate, 0);
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '1y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 1
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '5y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 5
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
default:
// Gets handled as all data
return undefined;
}
}
2021-07-11 20:59:23 +02:00
private getAccounts(
orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency
) {
const accounts: PortfolioPosition['accounts'] = {};
for (const order of orders) {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
order.currency,
userCurrency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
userCurrency
);
if (order.type === 'SELL') {
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1;
}
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[order.Account?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
}
}
return accounts;
}
2021-07-11 20:59:23 +02:00
private getOrders(aUserId: string) {
return this.orderService.orders({
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
});
}
private async getUserId(aImpersonationId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
return impersonationUserId || this.request.user.id;
}
2021-04-13 21:53:58 +02:00
}