2021-04-21 20:27:39 +02:00
|
|
|
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';
|
|
|
|
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
2021-05-16 21:20:59 +02:00
|
|
|
import {
|
|
|
|
PortfolioItem,
|
|
|
|
PortfolioOverview
|
2021-05-16 22:11:14 +02:00
|
|
|
} 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';
|
2021-05-27 20:50:10 +02:00
|
|
|
import { DataSource } from '@prisma/client';
|
2021-04-13 21:53:58 +02:00
|
|
|
import {
|
|
|
|
add,
|
|
|
|
format,
|
|
|
|
getDate,
|
|
|
|
getMonth,
|
|
|
|
getYear,
|
|
|
|
isAfter,
|
|
|
|
isSameDay,
|
2021-06-05 17:17:53 +02:00
|
|
|
parse,
|
2021-04-13 21:53:58 +02:00
|
|
|
parseISO,
|
|
|
|
setDate,
|
|
|
|
setMonth,
|
|
|
|
sub
|
|
|
|
} from 'date-fns';
|
|
|
|
import { isEmpty } from 'lodash';
|
|
|
|
import * as roundTo from 'round-to';
|
|
|
|
|
|
|
|
import { OrderService } from '../order/order.service';
|
|
|
|
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
|
|
|
import { UserService } from '../user/user.service';
|
|
|
|
import {
|
|
|
|
HistoricalDataItem,
|
|
|
|
PortfolioPositionDetail
|
|
|
|
} from './interfaces/portfolio-position-detail.interface';
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
export class PortfolioService {
|
|
|
|
public constructor(
|
|
|
|
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
|
|
|
|
) {}
|
|
|
|
|
|
|
|
public async createPortfolio(aUserId: string): Promise<Portfolio> {
|
|
|
|
let portfolio: Portfolio;
|
|
|
|
let stringifiedPortfolio = await this.redisCacheService.get(
|
|
|
|
`${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
|
|
|
|
);
|
|
|
|
|
|
|
|
portfolio = new Portfolio(
|
|
|
|
this.dataProviderService,
|
|
|
|
this.exchangeRateDataService,
|
|
|
|
this.rulesService
|
|
|
|
).createFromData({ orders, portfolioItems, user });
|
|
|
|
} else {
|
|
|
|
// Get portfolio from database
|
|
|
|
const orders = await this.orderService.orders({
|
|
|
|
include: {
|
2021-06-06 15:31:28 +02:00
|
|
|
Account: true,
|
|
|
|
SymbolProfile: true
|
2021-04-13 21:53:58 +02:00
|
|
|
},
|
|
|
|
orderBy: { date: 'asc' },
|
|
|
|
where: { userId: aUserId }
|
|
|
|
});
|
|
|
|
|
|
|
|
portfolio = new Portfolio(
|
|
|
|
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
|
|
|
|
return await portfolio.addCurrentPortfolioItems();
|
|
|
|
}
|
|
|
|
|
|
|
|
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
|
|
|
|
try {
|
|
|
|
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
|
|
|
aImpersonationId,
|
|
|
|
this.request.user.id
|
|
|
|
);
|
|
|
|
|
|
|
|
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
|
|
|
|
);
|
|
|
|
|
|
|
|
const portfolio = await this.createPortfolio(
|
|
|
|
impersonationUserId || this.request.user.id
|
|
|
|
);
|
|
|
|
|
|
|
|
if (portfolio.getOrders().length <= 0) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
const dateRangeDate = this.convertDateRangeToDate(
|
|
|
|
aDateRange,
|
|
|
|
portfolio.getMinDate()
|
|
|
|
);
|
|
|
|
|
|
|
|
return portfolio
|
|
|
|
.get()
|
|
|
|
.filter((portfolioItem) => {
|
|
|
|
if (dateRangeDate === undefined) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
isSameDay(parseISO(portfolioItem.date), dateRangeDate) ||
|
|
|
|
isAfter(parseISO(portfolioItem.date), dateRangeDate)
|
|
|
|
);
|
|
|
|
})
|
|
|
|
.map((portfolioItem) => {
|
|
|
|
return {
|
|
|
|
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
|
|
|
|
grossPerformancePercent: portfolioItem.grossPerformancePercent,
|
2021-05-24 16:24:54 +02:00
|
|
|
marketPrice: portfolioItem.value ?? null,
|
|
|
|
value: portfolioItem.value - portfolioItem.investment ?? null
|
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
|
|
|
|
);
|
|
|
|
|
|
|
|
const portfolio = await this.createPortfolio(
|
|
|
|
impersonationUserId || this.request.user.id
|
|
|
|
);
|
|
|
|
|
|
|
|
const committedFunds = portfolio.getCommittedFunds();
|
|
|
|
const fees = portfolio.getFees();
|
|
|
|
|
|
|
|
return {
|
|
|
|
committedFunds,
|
|
|
|
fees,
|
|
|
|
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
|
|
|
|
);
|
|
|
|
|
|
|
|
const portfolio = await this.createPortfolio(
|
|
|
|
impersonationUserId || this.request.user.id
|
|
|
|
);
|
|
|
|
|
|
|
|
const positions = portfolio.getPositions(new Date())[aSymbol];
|
|
|
|
|
|
|
|
if (positions) {
|
|
|
|
let {
|
|
|
|
averagePrice,
|
|
|
|
currency,
|
|
|
|
firstBuyDate,
|
|
|
|
investment,
|
|
|
|
marketPrice,
|
2021-04-30 21:08:43 +02:00
|
|
|
quantity,
|
|
|
|
transactionCount
|
2021-04-13 21:53:58 +02:00
|
|
|
} = portfolio.getPositions(new Date())[aSymbol];
|
|
|
|
|
2021-06-05 17:17:53 +02:00
|
|
|
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[] = [];
|
2021-06-05 17:17:53 +02:00
|
|
|
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-06-05 17:17:53 +02:00
|
|
|
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
|
|
|
|
if (
|
|
|
|
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
|
|
|
|
isAfter(currentDate, parseISO(orders[0]?.getDate()))
|
|
|
|
) {
|
|
|
|
// Get snapshot of first day of month
|
|
|
|
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
|
|
|
|
.positions[aSymbol];
|
|
|
|
orders.shift();
|
|
|
|
|
|
|
|
if (snapshot?.averagePrice) {
|
|
|
|
currentAveragePrice = snapshot?.averagePrice;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-04-13 21:53:58 +02:00
|
|
|
historicalDataArray.push({
|
|
|
|
date,
|
2021-06-05 17:17:53 +02:00
|
|
|
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,
|
2021-04-30 21:08:43 +02:00
|
|
|
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(
|
2021-05-27 20:50:10 +02:00
|
|
|
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
2021-04-13 21:53:58 +02:00
|
|
|
portfolio.getMinDate(),
|
|
|
|
new Date()
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const historicalDataArray: HistoricalDataItem[] = [];
|
|
|
|
|
|
|
|
for (const [date, { marketPrice, performance }] of Object.entries(
|
|
|
|
historicalData[aSymbol]
|
|
|
|
).reverse()) {
|
|
|
|
historicalDataArray.push({
|
|
|
|
date,
|
|
|
|
value: marketPrice
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
averagePrice: undefined,
|
|
|
|
currency: currentData[aSymbol].currency,
|
|
|
|
firstBuyDate: undefined,
|
|
|
|
grossPerformance: undefined,
|
|
|
|
grossPerformancePercent: undefined,
|
|
|
|
historicalData: historicalDataArray,
|
|
|
|
investment: undefined,
|
|
|
|
marketPrice: currentData[aSymbol].marketPrice,
|
|
|
|
maxPrice: undefined,
|
|
|
|
minPrice: undefined,
|
|
|
|
quantity: undefined,
|
2021-04-30 21:08:43 +02:00
|
|
|
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,
|
2021-04-30 21:08:43 +02:00
|
|
|
symbol: aSymbol,
|
|
|
|
transactionCount: undefined
|
2021-04-13 21:53:58 +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-04-13 21:53:58 +02:00
|
|
|
}
|