add current position calculation with holding period return calculation
This commit is contained in:
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());
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const marketValue = marketValues[item.symbol];
|
||||
const grossPerformance = marketValue
|
||||
? new Big(marketValue.marketPrice)
|
||||
.mul(item.quantity)
|
||||
.minus(item.investment)
|
||||
: null;
|
||||
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…
x
Reference in New Issue
Block a user