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

609 lines
18 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 } from '@ghostfolio/api/services/interfaces/interfaces';
2021-07-25 13:31:44 +02:00
import { 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,
2021-07-24 10:53:15 +02:00
Position
} from '@ghostfolio/common/interfaces';
import { DateRange, 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';
@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,
2021-07-28 16:11:19 +02:00
private readonly userService: UserService
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
);
2021-07-20 22:52:50 +02:00
const transactionPoints = await this.getTransactionPoints(userId);
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 getPosition(
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
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 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[] }> {
2021-07-25 13:31:44 +02:00
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: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) {
const orders = await this.getOrders(userId);
if (orders.length <= 0) {
return [];
}
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 portfolioCalculator.getTransactionPoints();
}
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 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 }
});
}
2021-04-13 21:53:58 +02:00
}