add current position calculation with holding period return calculation

pull/239/head
Valentin Zickner 3 years ago committed by Thomas
parent ee89822bfe
commit 852902d1ab

@ -106,8 +106,10 @@ describe('CurrentRateService', () => {
expect(
await currentRateService.getValues({
currencies: { AMZN: Currency.USD },
dateRangeEnd: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
dateRangeStart: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
dateQuery: {
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
},
symbols: ['AMZN'],
userCurrency: Currency.CHF
})

@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { isToday } from 'date-fns';
import { MarketDataService } from './market-data.service';
import { DateQuery, MarketDataService } from './market-data.service';
@Injectable()
export class CurrentRateService {
@ -52,14 +52,12 @@ export class CurrentRateService {
public async getValues({
currencies,
dateRangeEnd,
dateRangeStart,
dateQuery,
symbols,
userCurrency
}: GetValuesParams): Promise<GetValueObject[]> {
const marketData = await this.marketDataService.getRange({
dateRangeEnd,
dateRangeStart,
dateQuery,
symbols
});
@ -77,11 +75,7 @@ export class CurrentRateService {
});
}
throw new Error(
`Values not found for symbols ${symbols.join(', ')} from ${resetHours(
dateRangeStart
)} to ${resetHours(dateRangeEnd)}`
);
throw new Error(`Values not found for symbols ${symbols.join(', ')}`);
}
}
@ -93,8 +87,7 @@ export interface GetValueParams {
}
export interface GetValuesParams {
dateRangeEnd: Date;
dateRangeStart: Date;
dateQuery: DateQuery;
symbols: string[];
currencies: { [symbol: string]: Currency };
userCurrency: Currency;

@ -2,7 +2,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { MarketData } from '@prisma/client';
import { endOfDay } from 'date-fns';
@Injectable()
export class MarketDataService {
@ -24,12 +23,10 @@ export class MarketDataService {
}
public async getRange({
dateRangeEnd,
dateRangeStart,
dateQuery,
symbols
}: {
dateRangeEnd: Date;
dateRangeStart: Date;
dateQuery: DateQuery;
symbols: string[];
}): Promise<MarketData[]> {
return await this.prisma.marketData.findMany({
@ -42,10 +39,7 @@ export class MarketDataService {
}
],
where: {
date: {
gte: dateRangeStart,
lt: endOfDay(dateRangeEnd)
},
date: dateQuery,
symbol: {
in: symbols
}
@ -53,3 +47,9 @@ export class MarketDataService {
});
}
}
export interface DateQuery {
gte?: Date;
lt?: Date;
in?: Date[];
}

@ -77,23 +77,34 @@ jest.mock('@ghostfolio/api/app/core/current-rate.service', () => {
},
getValues: ({
currencies,
dateRangeEnd,
dateRangeStart,
dateQuery,
symbols,
userCurrency
}: GetValuesParams) => {
const result = [];
for (
let date = resetHours(dateRangeStart);
isBefore(date, endOfDay(dateRangeEnd));
date = addDays(date, 1)
) {
for (const symbol of symbols) {
result.push({
date,
symbol,
marketPrice: mockGetValue(symbol, date).marketPrice
});
if (dateQuery.lt) {
for (
let date = resetHours(dateQuery.gte);
isBefore(date, endOfDay(dateQuery.lt));
date = addDays(date, 1)
) {
for (const symbol of symbols) {
result.push({
date,
symbol,
marketPrice: mockGetValue(symbol, date).marketPrice
});
}
}
} else {
for (const date of dateQuery.in) {
for (const symbol of symbols) {
result.push({
date,
symbol,
marketPrice: mockGetValue(symbol, date).marketPrice
});
}
}
}
return Promise.resolve(result);
@ -605,7 +616,14 @@ describe('PortfolioCalculator', () => {
Currency.USD
);
portfolioCalculator.setTransactionPoints(ordersVTITransactionPoints);
const currentPositions = await portfolioCalculator.getCurrentPositions();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => 1603490400000); // 2020-10-24
const currentPositions = await portfolioCalculator.getCurrentPositions(
parse('2019-01-01', 'yyyy-MM-dd', new Date())
);
spy.mockRestore();
expect(currentPositions).toEqual({
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -613,10 +631,13 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('178.438'),
currency: 'USD',
firstBuyDate: '2019-02-01',
grossPerformance: new Big('872.05'), // 213.32*25-4460.95
grossPerformancePercentage: new Big('0.19548526659119694236'), // 872.05/4460.95
// see next test for details about how to calculate this
grossPerformance: new Big('265.2'),
grossPerformancePercentage: new Big(
'0.37322057787174066244232522865731355471028555367747465860626740684417274277219590953836818016777856'
),
investment: new Big('4460.95'),
marketPrice: 213.32,
marketPrice: 194.86,
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('25'),
symbol: 'VTI',
@ -624,6 +645,78 @@ describe('PortfolioCalculator', () => {
}
});
});
it('with performance since Jan 1st, 2020', async () => {
const portfolioCalculator = new PortfolioCalculator(
currentRateService,
Currency.USD
);
const transactionPoints = [
{
date: '2019-02-01',
items: [
{
quantity: new Big('10'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
symbol: 'VTI',
investment: new Big('1443.8'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 1
}
]
},
{
date: '2020-08-03',
items: [
{
quantity: new Big('20'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
symbol: 'VTI',
investment: new Big('2923.7'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 2
}
]
}
];
portfolioCalculator.setTransactionPoints(transactionPoints);
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => 1603490400000); // 2020-10-24
// 2020-01-01 -> days 334 => value: VTI: 144.38+334*0.08=171.1 => 10*171.10=1711
// 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 10*188.30=1883 => 1883/1711=1.100526008 - 1 = 0.100526008
// 2020-08-03 -> days 549 => value: VTI: 144.38+549*0.08=188.3 => 20*188.30=3766
// 2020-10-24 [today] -> days 631 => value: VTI: 144.38+631*0.08=194.86 => 20*194.86=3897.2 => 3897.2/3766=1.034838024 - 1 = 0.034838024
// gross performance: 1883-1711 + 3897.2-3766 = 303.2
// gross performance percentage: 1.100526008 * 1.034838024 = 1.138866159 => 13.89 %
const currentPositions = await portfolioCalculator.getCurrentPositions(
parse('2020-01-01', 'yyyy-MM-dd', new Date())
);
spy.mockRestore();
expect(currentPositions).toEqual({
VTI: {
averagePrice: new Big('146.185'),
firstBuyDate: '2019-02-01',
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
marketPrice: 194.86,
transactionCount: 2,
grossPerformance: new Big('303.2'),
grossPerformancePercentage: new Big(
'0.1388661601402688486251911721754180022242'
),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
currency: 'USD'
}
});
});
});
describe('calculate timeline', () => {

@ -3,7 +3,7 @@ import {
GetValueObject
} from '@ghostfolio/api/app/core/current-rate.service';
import { OrderType } from '@ghostfolio/api/models/order-type';
import { resetHours } from '@ghostfolio/common/helper';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import Big from 'big.js';
@ -17,17 +17,10 @@ import {
isBefore,
max,
min,
parse,
subDays
} from 'date-fns';
import { flatten } from 'lodash';
const DATE_FORMAT = 'yyyy-MM-dd';
function dparse(date: string) {
return parse(date, DATE_FORMAT, new Date());
}
export class PortfolioCalculator {
private transactionPoints: TransactionPoint[];
@ -115,7 +108,7 @@ export class PortfolioCalculator {
return this.transactionPoints;
}
public async getCurrentPositions(): Promise<{
public async getCurrentPositions(start: Date): Promise<{
[symbol: string]: TimelinePosition;
}> {
if (!this.transactionPoints?.length) {
@ -126,29 +119,117 @@ export class PortfolioCalculator {
this.transactionPoints[this.transactionPoints.length - 1];
const result: { [symbol: string]: TimelinePosition } = {};
const marketValues = await this.getMarketValues(
lastTransactionPoint,
resetHours(subDays(new Date(), 3)),
endOfDay(new Date())
);
// use Date.now() to use the mock for today
const today = new Date(Date.now());
for (const item of lastTransactionPoint.items) {
const marketValue = marketValues[item.symbol];
const grossPerformance = marketValue
? new Big(marketValue.marketPrice)
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length;
const dates = [];
const symbols = new Set<string>();
const currencies: { [symbol: string]: Currency } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
symbols.add(item.symbol);
currencies[item.symbol] = item.currency;
}
for (let i = 0; i < this.transactionPoints.length; i++) {
if (
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
firstTransactionPoint === null
) {
firstTransactionPoint = this.transactionPoints[i];
firstIndex = i;
}
if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
}
}
const yesterday = resetHours(subDays(today, 1));
if (dates.indexOf(yesterday) === -1) {
dates.push(yesterday);
}
dates.push(resetHours(today));
const marketSymbols = await this.currentRateService.getValues({
currencies,
dateQuery: {
in: dates
},
symbols: Array.from(symbols),
userCurrency: this.currency
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
const startString = format(start, DATE_FORMAT);
const holdingPeriodReturns: { [symbol: string]: Big } = {};
const grossPerformance: { [symbol: string]: Big } = {};
let todayString = format(today, DATE_FORMAT);
// in case no symbols are there for today, use yesterday
if (!marketSymbolMap[todayString]) {
todayString = format(subDays(today, 1), DATE_FORMAT);
}
if (firstIndex > 0) {
firstIndex--;
}
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
const currentDate =
i === firstIndex ? startString : this.transactionPoints[i].date;
const nextDate =
i + 1 < this.transactionPoints.length
? this.transactionPoints[i + 1].date
: todayString;
const items = this.transactionPoints[i].items;
for (const item of items) {
let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol];
if (!oldHoldingPeriodReturn) {
oldHoldingPeriodReturn = new Big(1);
}
holdingPeriodReturns[item.symbol] = oldHoldingPeriodReturn.mul(
marketSymbolMap[nextDate][item.symbol].div(
marketSymbolMap[currentDate][item.symbol]
)
);
let oldGrossPerformance = grossPerformance[item.symbol];
if (!oldGrossPerformance) {
oldGrossPerformance = new Big(0);
}
grossPerformance[item.symbol] = oldGrossPerformance.plus(
marketSymbolMap[nextDate][item.symbol]
.minus(marketSymbolMap[currentDate][item.symbol])
.mul(item.quantity)
.minus(item.investment)
: null;
);
}
}
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString][item.symbol];
result[item.symbol] = {
averagePrice: item.investment.div(item.quantity),
currency: item.currency,
firstBuyDate: item.firstBuyDate,
grossPerformance,
grossPerformancePercentage: marketValue
? grossPerformance.div(item.investment)
grossPerformance: grossPerformance[item.symbol] ?? null,
grossPerformancePercentage: holdingPeriodReturns[item.symbol]
? holdingPeriodReturns[item.symbol].minus(1)
: null,
investment: item.investment,
marketPrice: marketValue?.marketPrice,
marketPrice: marketValue.toNumber(),
name: item.name,
quantity: item.quantity,
symbol: item.symbol,
@ -170,8 +251,8 @@ export class PortfolioCalculator {
console.time('calculate-timeline-calculations');
const startDate = timelineSpecification[0].start;
const start = dparse(startDate);
const end = dparse(endDate);
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = [];
let i = 0;
@ -189,7 +270,7 @@ export class PortfolioCalculator {
}
while (
j + 1 < this.transactionPoints.length &&
!isAfter(dparse(this.transactionPoints[j + 1].date), currentDate)
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
) {
j++;
}
@ -198,7 +279,7 @@ export class PortfolioCalculator {
if (timelineSpecification[i].accuracy === 'day') {
let nextEndDate = end;
if (j + 1 < this.transactionPoints.length) {
nextEndDate = dparse(this.transactionPoints[j + 1].date);
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
}
periodEndDate = min([
addMonths(currentDate, 3),
@ -242,8 +323,10 @@ export class PortfolioCalculator {
currencies[item.symbol] = item.currency;
}
const values = await this.currentRateService.getValues({
dateRangeStart,
dateRangeEnd,
dateQuery: {
gte: dateRangeStart,
lt: endOfDay(dateRangeEnd)
},
symbols,
currencies,
userCurrency: this.currency
@ -280,8 +363,10 @@ export class PortfolioCalculator {
if (symbols.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
dateRangeStart: startDate,
dateRangeEnd: endDate,
dateQuery: {
gte: startDate,
lt: endOfDay(endDate)
},
symbols,
currencies,
userCurrency: this.currency
@ -376,7 +461,7 @@ export class PortfolioCalculator {
) {
return (
i + 1 < timelineSpecification.length &&
!isBefore(currentDate, dparse(timelineSpecification[i + 1].start))
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
);
}
}

@ -52,6 +52,7 @@ import {
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { parseDate } from '@ghostfolio/common/helper';
@Injectable()
export class PortfolioService {
@ -416,9 +417,9 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints);
// TODO: get positions for date range
console.log('Date range:', aDateRange);
const positions = await portfolioCalculator.getCurrentPositions();
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const positions = await portfolioCalculator.getCurrentPositions(startDate);
return Object.values(positions).map((position) => {
return {

@ -1,5 +1,5 @@
import { Currency } from '@prisma/client';
import { getDate, getMonth, getYear, subDays } from 'date-fns';
import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
import { ghostfolioScraperApiSymbolPrefix } from './config';
@ -137,3 +137,9 @@ export function resolveFearAndGreedIndex(aValue: number) {
return { emoji: '🤪', text: 'Extreme Greed' };
}
}
export const DATE_FORMAT = 'yyyy-MM-dd';
export function parseDate(date: string) {
return parse(date, DATE_FORMAT, new Date());
}

Loading…
Cancel
Save