Switch to new calculation engine (#814)
* Switch to new calculation engine * Clean up old portfolio calculation engine (#815) * Rename new portfolio calculation engine (#816) * Update changelog
This commit is contained in:
parent
3d3a6c1204
commit
67f2b326f3
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Switched to the new calculation engine
|
||||||
- Improved the 4% rule in the _FIRE_ section
|
- Improved the 4% rule in the _FIRE_ section
|
||||||
|
|
||||||
## 1.133.0 - 07.04.2022
|
## 1.133.0 - 07.04.2022
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import {
|
||||||
nullifyValuesInObject,
|
nullifyValuesInObject,
|
||||||
@ -35,7 +35,7 @@ export class AccountController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
@ -91,9 +91,10 @@ export class AccountController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations = await this.portfolioServiceStrategy
|
let accountsWithAggregations =
|
||||||
.get()
|
await this.portfolioService.getAccountsWithAggregations(
|
||||||
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
|
impersonationUserId || this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
|
@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
|
|||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculatorNew', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy and sell', async () => {
|
it.only('with BALN.SW buy and sell', async () => {
|
||||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: [
|
orders: [
|
||||||
@ -52,13 +52,13 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculatorNew.computeTransactionPoints();
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
parseDate('2021-11-22')
|
parseDate('2021-11-22')
|
||||||
);
|
);
|
||||||
|
|
@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
|
|||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculatorNew', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy', async () => {
|
it.only('with BALN.SW buy', async () => {
|
||||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: [
|
orders: [
|
||||||
@ -41,13 +41,13 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculatorNew.computeTransactionPoints();
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
parseDate('2021-11-30')
|
parseDate('2021-11-30')
|
||||||
);
|
);
|
||||||
|
|
@ -1,73 +0,0 @@
|
|||||||
import Big from 'big.js';
|
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
|
||||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
|
||||||
|
|
||||||
describe('PortfolioCalculatorNew', () => {
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
currentRateService = new CurrentRateService(null, null, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('annualized performance percentage', () => {
|
|
||||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
|
||||||
currentRateService,
|
|
||||||
currency: 'USD',
|
|
||||||
orders: []
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Get annualized performance', async () => {
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
|
||||||
netPerformancePercent: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 0,
|
|
||||||
netPerformancePercent: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 65, // < 1 year
|
|
||||||
netPerformancePercent: new Big(0.1025)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.729705);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 365, // 1 year
|
|
||||||
netPerformancePercent: new Big(0.05)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.05);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 575, // > 1 year
|
|
||||||
netPerformancePercent: new Big(0.2374)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.145);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,997 +0,0 @@
|
|||||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
|
||||||
ResponseError,
|
|
||||||
TimelinePosition,
|
|
||||||
UniqueAsset
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Type as TypeOfOrder } from '@prisma/client';
|
|
||||||
import Big from 'big.js';
|
|
||||||
import {
|
|
||||||
addDays,
|
|
||||||
addMilliseconds,
|
|
||||||
addMonths,
|
|
||||||
addYears,
|
|
||||||
endOfDay,
|
|
||||||
format,
|
|
||||||
isAfter,
|
|
||||||
isBefore,
|
|
||||||
max,
|
|
||||||
min
|
|
||||||
} from 'date-fns';
|
|
||||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
|
||||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
|
||||||
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
|
|
||||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
|
||||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
|
||||||
import {
|
|
||||||
Accuracy,
|
|
||||||
TimelineSpecification
|
|
||||||
} from './interfaces/timeline-specification.interface';
|
|
||||||
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
|
|
||||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
|
||||||
|
|
||||||
export class PortfolioCalculatorNew {
|
|
||||||
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
|
|
||||||
true;
|
|
||||||
|
|
||||||
private static readonly ENABLE_LOGGING = false;
|
|
||||||
|
|
||||||
private currency: string;
|
|
||||||
private currentRateService: CurrentRateService;
|
|
||||||
private orders: PortfolioOrder[];
|
|
||||||
private transactionPoints: TransactionPoint[];
|
|
||||||
|
|
||||||
public constructor({
|
|
||||||
currency,
|
|
||||||
currentRateService,
|
|
||||||
orders
|
|
||||||
}: {
|
|
||||||
currency: string;
|
|
||||||
currentRateService: CurrentRateService;
|
|
||||||
orders: PortfolioOrder[];
|
|
||||||
}) {
|
|
||||||
this.currency = currency;
|
|
||||||
this.currentRateService = currentRateService;
|
|
||||||
this.orders = orders;
|
|
||||||
|
|
||||||
this.orders.sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
}
|
|
||||||
|
|
||||||
public computeTransactionPoints() {
|
|
||||||
this.transactionPoints = [];
|
|
||||||
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
|
||||||
|
|
||||||
let lastDate: string = null;
|
|
||||||
let lastTransactionPoint: TransactionPoint = null;
|
|
||||||
for (const order of this.orders) {
|
|
||||||
const currentDate = order.date;
|
|
||||||
|
|
||||||
let currentTransactionPointItem: TransactionPointSymbol;
|
|
||||||
const oldAccumulatedSymbol = symbols[order.symbol];
|
|
||||||
|
|
||||||
const factor = this.getFactor(order.type);
|
|
||||||
const unitPrice = new Big(order.unitPrice);
|
|
||||||
if (oldAccumulatedSymbol) {
|
|
||||||
const newQuantity = order.quantity
|
|
||||||
.mul(factor)
|
|
||||||
.plus(oldAccumulatedSymbol.quantity);
|
|
||||||
currentTransactionPointItem = {
|
|
||||||
currency: order.currency,
|
|
||||||
dataSource: order.dataSource,
|
|
||||||
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
|
||||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
|
||||||
investment: newQuantity.eq(0)
|
|
||||||
? new Big(0)
|
|
||||||
: unitPrice
|
|
||||||
.mul(order.quantity)
|
|
||||||
.mul(factor)
|
|
||||||
.plus(oldAccumulatedSymbol.investment),
|
|
||||||
quantity: newQuantity,
|
|
||||||
symbol: order.symbol,
|
|
||||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
currentTransactionPointItem = {
|
|
||||||
currency: order.currency,
|
|
||||||
dataSource: order.dataSource,
|
|
||||||
fee: order.fee,
|
|
||||||
firstBuyDate: order.date,
|
|
||||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
|
||||||
quantity: order.quantity.mul(factor),
|
|
||||||
symbol: order.symbol,
|
|
||||||
transactionCount: 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
symbols[order.symbol] = currentTransactionPointItem;
|
|
||||||
|
|
||||||
const items = lastTransactionPoint?.items ?? [];
|
|
||||||
const newItems = items.filter(
|
|
||||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
|
||||||
);
|
|
||||||
newItems.push(currentTransactionPointItem);
|
|
||||||
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
|
||||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
|
||||||
lastTransactionPoint = {
|
|
||||||
date: currentDate,
|
|
||||||
items: newItems
|
|
||||||
};
|
|
||||||
this.transactionPoints.push(lastTransactionPoint);
|
|
||||||
} else {
|
|
||||||
lastTransactionPoint.items = newItems;
|
|
||||||
}
|
|
||||||
lastDate = currentDate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket,
|
|
||||||
netPerformancePercent
|
|
||||||
}: {
|
|
||||||
daysInMarket: number;
|
|
||||||
netPerformancePercent: Big;
|
|
||||||
}): Big {
|
|
||||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
|
||||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
|
||||||
return new Big(
|
|
||||||
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
|
|
||||||
).minus(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Big(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTransactionPoints(): TransactionPoint[] {
|
|
||||||
return this.transactionPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
|
|
||||||
this.transactionPoints = transactionPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
|
||||||
if (!this.transactionPoints?.length) {
|
|
||||||
return {
|
|
||||||
currentValue: new Big(0),
|
|
||||||
hasErrors: false,
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0),
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
positions: [],
|
|
||||||
totalInvestment: new Big(0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastTransactionPoint =
|
|
||||||
this.transactionPoints[this.transactionPoints.length - 1];
|
|
||||||
|
|
||||||
// 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 dataGatheringItems: IDataGatheringItem[] = [];
|
|
||||||
const currencies: { [symbol: string]: string } = {};
|
|
||||||
|
|
||||||
dates.push(resetHours(start));
|
|
||||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
|
||||||
dataGatheringItems.push({
|
|
||||||
dataSource: item.dataSource,
|
|
||||||
symbol: 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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dates.push(resetHours(today));
|
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
|
||||||
currencies,
|
|
||||||
dataGatheringItems,
|
|
||||||
dateQuery: {
|
|
||||||
in: dates
|
|
||||||
},
|
|
||||||
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] = {};
|
|
||||||
}
|
|
||||||
if (marketSymbol.marketPrice) {
|
|
||||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
|
||||||
marketSymbol.marketPrice
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const todayString = format(today, DATE_FORMAT);
|
|
||||||
|
|
||||||
if (firstIndex > 0) {
|
|
||||||
firstIndex--;
|
|
||||||
}
|
|
||||||
const initialValues: { [symbol: string]: Big } = {};
|
|
||||||
|
|
||||||
const positions: TimelinePosition[] = [];
|
|
||||||
let hasAnySymbolMetricsErrors = false;
|
|
||||||
|
|
||||||
const errors: ResponseError['errors'] = [];
|
|
||||||
|
|
||||||
for (const item of lastTransactionPoint.items) {
|
|
||||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
|
||||||
|
|
||||||
const {
|
|
||||||
grossPerformance,
|
|
||||||
grossPerformancePercentage,
|
|
||||||
hasErrors,
|
|
||||||
initialValue,
|
|
||||||
netPerformance,
|
|
||||||
netPerformancePercentage
|
|
||||||
} = this.getSymbolMetrics({
|
|
||||||
marketSymbolMap,
|
|
||||||
start,
|
|
||||||
symbol: item.symbol
|
|
||||||
});
|
|
||||||
|
|
||||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
|
||||||
initialValues[item.symbol] = initialValue;
|
|
||||||
|
|
||||||
positions.push({
|
|
||||||
averagePrice: item.quantity.eq(0)
|
|
||||||
? new Big(0)
|
|
||||||
: item.investment.div(item.quantity),
|
|
||||||
currency: item.currency,
|
|
||||||
dataSource: item.dataSource,
|
|
||||||
firstBuyDate: item.firstBuyDate,
|
|
||||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
|
||||||
grossPerformancePercentage: !hasErrors
|
|
||||||
? grossPerformancePercentage ?? null
|
|
||||||
: null,
|
|
||||||
investment: item.investment,
|
|
||||||
marketPrice: marketValue?.toNumber() ?? null,
|
|
||||||
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
|
||||||
netPerformancePercentage: !hasErrors
|
|
||||||
? netPerformancePercentage ?? null
|
|
||||||
: null,
|
|
||||||
quantity: item.quantity,
|
|
||||||
symbol: item.symbol,
|
|
||||||
transactionCount: item.transactionCount
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasErrors) {
|
|
||||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...overall,
|
|
||||||
errors,
|
|
||||||
positions,
|
|
||||||
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getInvestments(): { date: string; investment: Big }[] {
|
|
||||||
if (this.transactionPoints.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.transactionPoints.map((transactionPoint) => {
|
|
||||||
return {
|
|
||||||
date: transactionPoint.date,
|
|
||||||
investment: transactionPoint.items.reduce(
|
|
||||||
(investment, transactionPointSymbol) =>
|
|
||||||
investment.plus(transactionPointSymbol.investment),
|
|
||||||
new Big(0)
|
|
||||||
)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async calculateTimeline(
|
|
||||||
timelineSpecification: TimelineSpecification[],
|
|
||||||
endDate: string
|
|
||||||
): Promise<TimelineInfoInterface> {
|
|
||||||
if (timelineSpecification.length === 0) {
|
|
||||||
return {
|
|
||||||
maxNetPerformance: new Big(0),
|
|
||||||
minNetPerformance: new Big(0),
|
|
||||||
timelinePeriods: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = timelineSpecification[0].start;
|
|
||||||
const start = parseDate(startDate);
|
|
||||||
const end = parseDate(endDate);
|
|
||||||
|
|
||||||
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
|
||||||
let i = 0;
|
|
||||||
let j = -1;
|
|
||||||
for (
|
|
||||||
let currentDate = start;
|
|
||||||
!isAfter(currentDate, end);
|
|
||||||
currentDate = this.addToDate(
|
|
||||||
currentDate,
|
|
||||||
timelineSpecification[i].accuracy
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
while (
|
|
||||||
j + 1 < this.transactionPoints.length &&
|
|
||||||
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
|
|
||||||
) {
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
|
|
||||||
let periodEndDate = currentDate;
|
|
||||||
if (timelineSpecification[i].accuracy === 'day') {
|
|
||||||
let nextEndDate = end;
|
|
||||||
if (j + 1 < this.transactionPoints.length) {
|
|
||||||
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
|
|
||||||
}
|
|
||||||
periodEndDate = min([
|
|
||||||
addMonths(currentDate, 3),
|
|
||||||
max([currentDate, nextEndDate])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
const timePeriodForDates = this.getTimePeriodForDate(
|
|
||||||
j,
|
|
||||||
currentDate,
|
|
||||||
endOfDay(periodEndDate)
|
|
||||||
);
|
|
||||||
currentDate = periodEndDate;
|
|
||||||
if (timePeriodForDates != null) {
|
|
||||||
timelinePeriodPromises.push(timePeriodForDates);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
|
||||||
timelinePeriodPromises
|
|
||||||
);
|
|
||||||
const minNetPerformance = timelineInfoInterfaces
|
|
||||||
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
|
||||||
.filter((performance) => performance !== null)
|
|
||||||
.reduce((minPerformance, current) => {
|
|
||||||
if (minPerformance.lt(current)) {
|
|
||||||
return minPerformance;
|
|
||||||
} else {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxNetPerformance = timelineInfoInterfaces
|
|
||||||
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
|
||||||
.filter((performance) => performance !== null)
|
|
||||||
.reduce((maxPerformance, current) => {
|
|
||||||
if (maxPerformance.gt(current)) {
|
|
||||||
return maxPerformance;
|
|
||||||
} else {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const timelinePeriods = timelineInfoInterfaces.map(
|
|
||||||
(timelineInfo) => timelineInfo.timelinePeriods
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
maxNetPerformance,
|
|
||||||
minNetPerformance,
|
|
||||||
timelinePeriods: flatten(timelinePeriods)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateOverallPerformance(
|
|
||||||
positions: TimelinePosition[],
|
|
||||||
initialValues: { [symbol: string]: Big }
|
|
||||||
) {
|
|
||||||
let currentValue = new Big(0);
|
|
||||||
let grossPerformance = new Big(0);
|
|
||||||
let grossPerformancePercentage = new Big(0);
|
|
||||||
let hasErrors = false;
|
|
||||||
let netPerformance = new Big(0);
|
|
||||||
let netPerformancePercentage = new Big(0);
|
|
||||||
let sumOfWeights = new Big(0);
|
|
||||||
let totalInvestment = new Big(0);
|
|
||||||
|
|
||||||
for (const currentPosition of positions) {
|
|
||||||
if (currentPosition.marketPrice) {
|
|
||||||
currentValue = currentValue.plus(
|
|
||||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
hasErrors = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
|
||||||
|
|
||||||
if (currentPosition.grossPerformance) {
|
|
||||||
grossPerformance = grossPerformance.plus(
|
|
||||||
currentPosition.grossPerformance
|
|
||||||
);
|
|
||||||
|
|
||||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
|
||||||
hasErrors = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPosition.grossPerformancePercentage) {
|
|
||||||
// Use the average from the initial value and the current investment as
|
|
||||||
// a weight
|
|
||||||
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
|
|
||||||
.plus(currentPosition.investment)
|
|
||||||
.div(2);
|
|
||||||
|
|
||||||
sumOfWeights = sumOfWeights.plus(weight);
|
|
||||||
|
|
||||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
|
||||||
currentPosition.grossPerformancePercentage.mul(weight)
|
|
||||||
);
|
|
||||||
|
|
||||||
netPerformancePercentage = netPerformancePercentage.plus(
|
|
||||||
currentPosition.netPerformancePercentage.mul(weight)
|
|
||||||
);
|
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
|
||||||
Logger.warn(
|
|
||||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
|
||||||
'PortfolioCalculatorNew'
|
|
||||||
);
|
|
||||||
hasErrors = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sumOfWeights.gt(0)) {
|
|
||||||
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
|
|
||||||
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
|
|
||||||
} else {
|
|
||||||
grossPerformancePercentage = new Big(0);
|
|
||||||
netPerformancePercentage = new Big(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentValue,
|
|
||||||
grossPerformance,
|
|
||||||
grossPerformancePercentage,
|
|
||||||
hasErrors,
|
|
||||||
netPerformance,
|
|
||||||
netPerformancePercentage,
|
|
||||||
totalInvestment
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getTimePeriodForDate(
|
|
||||||
j: number,
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date
|
|
||||||
): Promise<TimelineInfoInterface> {
|
|
||||||
let investment: Big = new Big(0);
|
|
||||||
let fees: Big = new Big(0);
|
|
||||||
|
|
||||||
const marketSymbolMap: {
|
|
||||||
[date: string]: { [symbol: string]: Big };
|
|
||||||
} = {};
|
|
||||||
if (j >= 0) {
|
|
||||||
const currencies: { [name: string]: string } = {};
|
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
|
||||||
|
|
||||||
for (const item of this.transactionPoints[j].items) {
|
|
||||||
currencies[item.symbol] = item.currency;
|
|
||||||
dataGatheringItems.push({
|
|
||||||
dataSource: item.dataSource,
|
|
||||||
symbol: item.symbol
|
|
||||||
});
|
|
||||||
investment = investment.plus(item.investment);
|
|
||||||
fees = fees.plus(item.fee);
|
|
||||||
}
|
|
||||||
|
|
||||||
let marketSymbols: GetValueObject[] = [];
|
|
||||||
if (dataGatheringItems.length > 0) {
|
|
||||||
try {
|
|
||||||
marketSymbols = await this.currentRateService.getValues({
|
|
||||||
currencies,
|
|
||||||
dataGatheringItems,
|
|
||||||
dateQuery: {
|
|
||||||
gte: startDate,
|
|
||||||
lt: endOfDay(endDate)
|
|
||||||
},
|
|
||||||
userCurrency: this.currency
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(
|
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
|
||||||
error,
|
|
||||||
'PortfolioCalculatorNew'
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const marketSymbol of marketSymbols) {
|
|
||||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
|
||||||
if (!marketSymbolMap[date]) {
|
|
||||||
marketSymbolMap[date] = {};
|
|
||||||
}
|
|
||||||
if (marketSymbol.marketPrice) {
|
|
||||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
|
||||||
marketSymbol.marketPrice
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: TimelinePeriod[] = [];
|
|
||||||
let maxNetPerformance: Big = null;
|
|
||||||
let minNetPerformance: Big = null;
|
|
||||||
for (
|
|
||||||
let currentDate = startDate;
|
|
||||||
isBefore(currentDate, endDate);
|
|
||||||
currentDate = addDays(currentDate, 1)
|
|
||||||
) {
|
|
||||||
let value = new Big(0);
|
|
||||||
const currentDateAsString = format(currentDate, DATE_FORMAT);
|
|
||||||
let invalid = false;
|
|
||||||
if (j >= 0) {
|
|
||||||
for (const item of this.transactionPoints[j].items) {
|
|
||||||
if (
|
|
||||||
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
|
|
||||||
) {
|
|
||||||
invalid = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
value = value.plus(
|
|
||||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!invalid) {
|
|
||||||
const grossPerformance = value.minus(investment);
|
|
||||||
const netPerformance = grossPerformance.minus(fees);
|
|
||||||
if (
|
|
||||||
minNetPerformance === null ||
|
|
||||||
minNetPerformance.gt(netPerformance)
|
|
||||||
) {
|
|
||||||
minNetPerformance = netPerformance;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
maxNetPerformance === null ||
|
|
||||||
maxNetPerformance.lt(netPerformance)
|
|
||||||
) {
|
|
||||||
maxNetPerformance = netPerformance;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
grossPerformance,
|
|
||||||
investment,
|
|
||||||
netPerformance,
|
|
||||||
value,
|
|
||||||
date: currentDateAsString
|
|
||||||
};
|
|
||||||
results.push(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
maxNetPerformance,
|
|
||||||
minNetPerformance,
|
|
||||||
timelinePeriods: results
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFactor(type: TypeOfOrder) {
|
|
||||||
let factor: number;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'BUY':
|
|
||||||
factor = 1;
|
|
||||||
break;
|
|
||||||
case 'SELL':
|
|
||||||
factor = -1;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
factor = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private addToDate(date: Date, accuracy: Accuracy): Date {
|
|
||||||
switch (accuracy) {
|
|
||||||
case 'day':
|
|
||||||
return addDays(date, 1);
|
|
||||||
case 'month':
|
|
||||||
return addMonths(date, 1);
|
|
||||||
case 'year':
|
|
||||||
return addYears(date, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSymbolMetrics({
|
|
||||||
marketSymbolMap,
|
|
||||||
start,
|
|
||||||
symbol
|
|
||||||
}: {
|
|
||||||
marketSymbolMap: {
|
|
||||||
[date: string]: { [symbol: string]: Big };
|
|
||||||
};
|
|
||||||
start: Date;
|
|
||||||
symbol: string;
|
|
||||||
}) {
|
|
||||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
|
||||||
return order.symbol === symbol;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
|
||||||
return {
|
|
||||||
hasErrors: false,
|
|
||||||
initialValue: new Big(0),
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateOfFirstTransaction = new Date(first(orders).date);
|
|
||||||
const endDate = new Date(Date.now());
|
|
||||||
|
|
||||||
const unitPriceAtStartDate =
|
|
||||||
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
|
||||||
|
|
||||||
const unitPriceAtEndDate =
|
|
||||||
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!unitPriceAtEndDate ||
|
|
||||||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
hasErrors: true,
|
|
||||||
initialValue: new Big(0),
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let averagePriceAtEndDate = new Big(0);
|
|
||||||
let averagePriceAtStartDate = new Big(0);
|
|
||||||
let feesAtStartDate = new Big(0);
|
|
||||||
let fees = new Big(0);
|
|
||||||
let grossPerformance = new Big(0);
|
|
||||||
let grossPerformanceAtStartDate = new Big(0);
|
|
||||||
let grossPerformanceFromSells = new Big(0);
|
|
||||||
let initialValue: Big;
|
|
||||||
let investmentAtStartDate: Big;
|
|
||||||
let lastAveragePrice = new Big(0);
|
|
||||||
let lastTransactionInvestment = new Big(0);
|
|
||||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
|
||||||
let maxTotalInvestment = new Big(0);
|
|
||||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
|
||||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
|
||||||
let totalInvestment = new Big(0);
|
|
||||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
|
||||||
let totalUnits = new Big(0);
|
|
||||||
let valueAtStartDate: Big;
|
|
||||||
|
|
||||||
// Add a synthetic order at the start and the end date
|
|
||||||
orders.push({
|
|
||||||
symbol,
|
|
||||||
currency: null,
|
|
||||||
date: format(start, DATE_FORMAT),
|
|
||||||
dataSource: null,
|
|
||||||
fee: new Big(0),
|
|
||||||
itemType: 'start',
|
|
||||||
name: '',
|
|
||||||
quantity: new Big(0),
|
|
||||||
type: TypeOfOrder.BUY,
|
|
||||||
unitPrice: unitPriceAtStartDate
|
|
||||||
});
|
|
||||||
|
|
||||||
orders.push({
|
|
||||||
symbol,
|
|
||||||
currency: null,
|
|
||||||
date: format(endDate, DATE_FORMAT),
|
|
||||||
dataSource: null,
|
|
||||||
fee: new Big(0),
|
|
||||||
itemType: 'end',
|
|
||||||
name: '',
|
|
||||||
quantity: new Big(0),
|
|
||||||
type: TypeOfOrder.BUY,
|
|
||||||
unitPrice: unitPriceAtEndDate
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort orders so that the start and end placeholder order are at the right
|
|
||||||
// position
|
|
||||||
orders = sortBy(orders, (order) => {
|
|
||||||
let sortIndex = new Date(order.date);
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
|
||||||
sortIndex = addMilliseconds(sortIndex, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.itemType === 'end') {
|
|
||||||
sortIndex = addMilliseconds(sortIndex, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortIndex.getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
const indexOfStartOrder = orders.findIndex((order) => {
|
|
||||||
return order.itemType === 'start';
|
|
||||||
});
|
|
||||||
|
|
||||||
const indexOfEndOrder = orders.findIndex((order) => {
|
|
||||||
return order.itemType === 'end';
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < orders.length; i += 1) {
|
|
||||||
const order = orders[i];
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
|
||||||
// Take the unit price of the order as the market price if there are no
|
|
||||||
// orders of this symbol before the start date
|
|
||||||
order.unitPrice =
|
|
||||||
indexOfStartOrder === 0
|
|
||||||
? orders[i + 1]?.unitPrice
|
|
||||||
: unitPriceAtStartDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the average start price as soon as any units are held
|
|
||||||
if (
|
|
||||||
averagePriceAtStartDate.eq(0) &&
|
|
||||||
i >= indexOfStartOrder &&
|
|
||||||
totalUnits.gt(0)
|
|
||||||
) {
|
|
||||||
averagePriceAtStartDate = totalInvestment.div(totalUnits);
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
|
|
||||||
order.unitPrice
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!investmentAtStartDate && i >= indexOfStartOrder) {
|
|
||||||
investmentAtStartDate = totalInvestment ?? new Big(0);
|
|
||||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transactionInvestment = order.quantity
|
|
||||||
.mul(order.unitPrice)
|
|
||||||
.mul(this.getFactor(order.type));
|
|
||||||
|
|
||||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
|
||||||
|
|
||||||
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
|
|
||||||
maxTotalInvestment = totalInvestment;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i === indexOfEndOrder && totalUnits.gt(0)) {
|
|
||||||
averagePriceAtEndDate = totalInvestment.div(totalUnits);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i >= indexOfStartOrder && !initialValue) {
|
|
||||||
if (
|
|
||||||
i === indexOfStartOrder &&
|
|
||||||
!valueOfInvestmentBeforeTransaction.eq(0)
|
|
||||||
) {
|
|
||||||
initialValue = valueOfInvestmentBeforeTransaction;
|
|
||||||
} else if (transactionInvestment.gt(0)) {
|
|
||||||
initialValue = transactionInvestment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fees = fees.plus(order.fee);
|
|
||||||
|
|
||||||
totalUnits = totalUnits.plus(
|
|
||||||
order.quantity.mul(this.getFactor(order.type))
|
|
||||||
);
|
|
||||||
|
|
||||||
const valueOfInvestment = totalUnits.mul(order.unitPrice);
|
|
||||||
|
|
||||||
const grossPerformanceFromSell =
|
|
||||||
order.type === TypeOfOrder.SELL
|
|
||||||
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
|
|
||||||
: new Big(0);
|
|
||||||
|
|
||||||
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
|
||||||
grossPerformanceFromSell
|
|
||||||
);
|
|
||||||
|
|
||||||
totalInvestmentWithGrossPerformanceFromSell =
|
|
||||||
totalInvestmentWithGrossPerformanceFromSell
|
|
||||||
.plus(transactionInvestment)
|
|
||||||
.plus(grossPerformanceFromSell);
|
|
||||||
|
|
||||||
lastAveragePrice = totalUnits.eq(0)
|
|
||||||
? new Big(0)
|
|
||||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
|
||||||
|
|
||||||
const newGrossPerformance = valueOfInvestment
|
|
||||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
|
||||||
.plus(grossPerformanceFromSells);
|
|
||||||
|
|
||||||
if (
|
|
||||||
i > indexOfStartOrder &&
|
|
||||||
!lastValueOfInvestmentBeforeTransaction
|
|
||||||
.plus(lastTransactionInvestment)
|
|
||||||
.eq(0)
|
|
||||||
) {
|
|
||||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
|
||||||
.minus(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.div(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
timeWeightedGrossPerformancePercentage =
|
|
||||||
timeWeightedGrossPerformancePercentage.mul(
|
|
||||||
new Big(1).plus(grossHoldingPeriodReturn)
|
|
||||||
);
|
|
||||||
|
|
||||||
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
|
||||||
.minus(fees.minus(feesAtStartDate))
|
|
||||||
.minus(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.div(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
timeWeightedNetPerformancePercentage =
|
|
||||||
timeWeightedNetPerformancePercentage.mul(
|
|
||||||
new Big(1).plus(netHoldingPeriodReturn)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
grossPerformance = newGrossPerformance;
|
|
||||||
|
|
||||||
lastTransactionInvestment = transactionInvestment;
|
|
||||||
|
|
||||||
lastValueOfInvestmentBeforeTransaction =
|
|
||||||
valueOfInvestmentBeforeTransaction;
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
|
||||||
feesAtStartDate = fees;
|
|
||||||
grossPerformanceAtStartDate = grossPerformance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
timeWeightedGrossPerformancePercentage =
|
|
||||||
timeWeightedGrossPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
timeWeightedNetPerformancePercentage =
|
|
||||||
timeWeightedNetPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
const totalGrossPerformance = grossPerformance.minus(
|
|
||||||
grossPerformanceAtStartDate
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalNetPerformance = grossPerformance
|
|
||||||
.minus(grossPerformanceAtStartDate)
|
|
||||||
.minus(fees.minus(feesAtStartDate));
|
|
||||||
|
|
||||||
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
|
|
||||||
maxTotalInvestment.minus(investmentAtStartDate)
|
|
||||||
);
|
|
||||||
|
|
||||||
const grossPerformancePercentage =
|
|
||||||
PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
|
||||||
averagePriceAtStartDate.eq(0) ||
|
|
||||||
averagePriceAtEndDate.eq(0) ||
|
|
||||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
|
||||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
|
||||||
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
|
||||||
: new Big(0)
|
|
||||||
: // This formula has the issue that buying more units with a price
|
|
||||||
// lower than the average buying price results in a positive
|
|
||||||
// performance even if the market price stays constant
|
|
||||||
unitPriceAtEndDate
|
|
||||||
.div(averagePriceAtEndDate)
|
|
||||||
.div(
|
|
||||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
|
||||||
)
|
|
||||||
.minus(1);
|
|
||||||
|
|
||||||
const feesPerUnit = totalUnits.gt(0)
|
|
||||||
? fees.minus(feesAtStartDate).div(totalUnits)
|
|
||||||
: new Big(0);
|
|
||||||
|
|
||||||
const netPerformancePercentage =
|
|
||||||
PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
|
||||||
averagePriceAtStartDate.eq(0) ||
|
|
||||||
averagePriceAtEndDate.eq(0) ||
|
|
||||||
orders[indexOfStartOrder].unitPrice.eq(0)
|
|
||||||
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
|
||||||
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
|
||||||
: new Big(0)
|
|
||||||
: // This formula has the issue that buying more units with a price
|
|
||||||
// lower than the average buying price results in a positive
|
|
||||||
// performance even if the market price stays constant
|
|
||||||
unitPriceAtEndDate
|
|
||||||
.minus(feesPerUnit)
|
|
||||||
.div(averagePriceAtEndDate)
|
|
||||||
.div(
|
|
||||||
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
|
||||||
)
|
|
||||||
.minus(1);
|
|
||||||
|
|
||||||
if (PortfolioCalculatorNew.ENABLE_LOGGING) {
|
|
||||||
console.log(
|
|
||||||
`
|
|
||||||
${symbol}
|
|
||||||
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
|
|
||||||
2
|
|
||||||
)} -> ${unitPriceAtEndDate.toFixed(2)}
|
|
||||||
Average price: ${averagePriceAtStartDate.toFixed(
|
|
||||||
2
|
|
||||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
|
||||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
|
||||||
Gross performance: ${totalGrossPerformance.toFixed(
|
|
||||||
2
|
|
||||||
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
|
|
||||||
Fees per unit: ${feesPerUnit.toFixed(2)}
|
|
||||||
Net performance: ${totalNetPerformance.toFixed(
|
|
||||||
2
|
|
||||||
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialValue,
|
|
||||||
grossPerformancePercentage,
|
|
||||||
netPerformancePercentage,
|
|
||||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
|
||||||
netPerformance: totalNetPerformance,
|
|
||||||
grossPerformance: totalGrossPerformance
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private isNextItemActive(
|
|
||||||
timelineSpecification: TimelineSpecification[],
|
|
||||||
currentDate: Date,
|
|
||||||
i: number
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
i + 1 < timelineSpecification.length &&
|
|
||||||
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
|
|||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PortfolioCalculatorNew', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -23,19 +23,19 @@ describe('PortfolioCalculatorNew', () => {
|
|||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it('with no orders', async () => {
|
it('with no orders', async () => {
|
||||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: []
|
orders: []
|
||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculatorNew.computeTransactionPoints();
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(Date, 'now')
|
.spyOn(Date, 'now')
|
||||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,15 @@
|
|||||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Type as TypeOfOrder } from '@prisma/client';
|
import { Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
|
addMilliseconds,
|
||||||
addMonths,
|
addMonths,
|
||||||
addYears,
|
addYears,
|
||||||
differenceInDays,
|
|
||||||
endOfDay,
|
endOfDay,
|
||||||
format,
|
format,
|
||||||
isAfter,
|
isAfter,
|
||||||
@ -17,11 +17,12 @@ import {
|
|||||||
max,
|
max,
|
||||||
min
|
min
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { flatten, isNumber } from 'lodash';
|
import { first, flatten, isNumber, sortBy } from 'lodash';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
|
||||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||||
import {
|
import {
|
||||||
@ -32,22 +33,39 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
|
|||||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||||
|
|
||||||
export class PortfolioCalculator {
|
export class PortfolioCalculator {
|
||||||
|
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
|
||||||
|
true;
|
||||||
|
|
||||||
|
private static readonly ENABLE_LOGGING = false;
|
||||||
|
|
||||||
|
private currency: string;
|
||||||
|
private currentRateService: CurrentRateService;
|
||||||
|
private orders: PortfolioOrder[];
|
||||||
private transactionPoints: TransactionPoint[];
|
private transactionPoints: TransactionPoint[];
|
||||||
|
|
||||||
public constructor(
|
public constructor({
|
||||||
private currentRateService: CurrentRateService,
|
currency,
|
||||||
private currency: string
|
currentRateService,
|
||||||
) {}
|
orders
|
||||||
|
}: {
|
||||||
|
currency: string;
|
||||||
|
currentRateService: CurrentRateService;
|
||||||
|
orders: PortfolioOrder[];
|
||||||
|
}) {
|
||||||
|
this.currency = currency;
|
||||||
|
this.currentRateService = currentRateService;
|
||||||
|
this.orders = orders;
|
||||||
|
|
||||||
public computeTransactionPoints(orders: PortfolioOrder[]) {
|
this.orders.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
orders.sort((a, b) => a.date.localeCompare(b.date));
|
}
|
||||||
|
|
||||||
|
public computeTransactionPoints() {
|
||||||
this.transactionPoints = [];
|
this.transactionPoints = [];
|
||||||
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||||
|
|
||||||
let lastDate: string = null;
|
let lastDate: string = null;
|
||||||
let lastTransactionPoint: TransactionPoint = null;
|
let lastTransactionPoint: TransactionPoint = null;
|
||||||
for (const order of orders) {
|
for (const order of this.orders) {
|
||||||
const currentDate = order.date;
|
const currentDate = order.date;
|
||||||
|
|
||||||
let currentTransactionPointItem: TransactionPointSymbol;
|
let currentTransactionPointItem: TransactionPointSymbol;
|
||||||
@ -140,7 +158,6 @@ export class PortfolioCalculator {
|
|||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
grossPerformance: new Big(0),
|
grossPerformance: new Big(0),
|
||||||
grossPerformancePercentage: new Big(0),
|
grossPerformancePercentage: new Big(0),
|
||||||
netAnnualizedPerformance: new Big(0),
|
|
||||||
netPerformance: new Big(0),
|
netPerformance: new Big(0),
|
||||||
netPerformancePercentage: new Big(0),
|
netPerformancePercentage: new Big(0),
|
||||||
positions: [],
|
positions: [],
|
||||||
@ -195,6 +212,7 @@ export class PortfolioCalculator {
|
|||||||
const marketSymbolMap: {
|
const marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
for (const marketSymbol of marketSymbols) {
|
for (const marketSymbol of marketSymbols) {
|
||||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||||
if (!marketSymbolMap[date]) {
|
if (!marketSymbolMap[date]) {
|
||||||
@ -207,112 +225,37 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasErrors = false;
|
|
||||||
const startString = format(start, DATE_FORMAT);
|
|
||||||
|
|
||||||
const holdingPeriodReturns: { [symbol: string]: Big } = {};
|
|
||||||
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
|
|
||||||
const grossPerformance: { [symbol: string]: Big } = {};
|
|
||||||
const netPerformance: { [symbol: string]: Big } = {};
|
|
||||||
const todayString = format(today, DATE_FORMAT);
|
const todayString = format(today, DATE_FORMAT);
|
||||||
|
|
||||||
if (firstIndex > 0) {
|
if (firstIndex > 0) {
|
||||||
firstIndex--;
|
firstIndex--;
|
||||||
}
|
}
|
||||||
const invalidSymbols = [];
|
|
||||||
const lastInvestments: { [symbol: string]: Big } = {};
|
|
||||||
const lastQuantities: { [symbol: string]: Big } = {};
|
|
||||||
const lastFees: { [symbol: string]: Big } = {};
|
|
||||||
const initialValues: { [symbol: string]: Big } = {};
|
const initialValues: { [symbol: string]: Big } = {};
|
||||||
|
|
||||||
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) {
|
|
||||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
|
||||||
invalidSymbols.push(item.symbol);
|
|
||||||
hasErrors = true;
|
|
||||||
Logger.warn(
|
|
||||||
`Missing value for symbol ${item.symbol} at ${nextDate}`,
|
|
||||||
'PortfolioCalculator'
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let lastInvestment: Big = new Big(0);
|
|
||||||
let lastQuantity: Big = item.quantity;
|
|
||||||
if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) {
|
|
||||||
lastInvestment = item.investment.minus(lastInvestments[item.symbol]);
|
|
||||||
lastQuantity = lastQuantities[item.symbol];
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
|
|
||||||
let initialValue = itemValue?.mul(lastQuantity);
|
|
||||||
let investedValue = itemValue?.mul(item.quantity);
|
|
||||||
const isFirstOrderAndIsStartBeforeCurrentDate =
|
|
||||||
i === firstIndex &&
|
|
||||||
isBefore(parseDate(this.transactionPoints[i].date), start);
|
|
||||||
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
|
|
||||||
const fee = isFirstOrderAndIsStartBeforeCurrentDate
|
|
||||||
? new Big(0)
|
|
||||||
: item.fee.minus(lastFee);
|
|
||||||
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
|
|
||||||
initialValue = item.investment;
|
|
||||||
investedValue = item.investment;
|
|
||||||
}
|
|
||||||
if (i === firstIndex || !initialValues[item.symbol]) {
|
|
||||||
initialValues[item.symbol] = initialValue;
|
|
||||||
}
|
|
||||||
if (!item.quantity.eq(0)) {
|
|
||||||
if (!initialValue) {
|
|
||||||
invalidSymbols.push(item.symbol);
|
|
||||||
hasErrors = true;
|
|
||||||
Logger.warn(
|
|
||||||
`Missing value for symbol ${item.symbol} at ${currentDate}`,
|
|
||||||
'PortfolioCalculator'
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cashFlow = lastInvestment;
|
|
||||||
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
|
|
||||||
item.quantity
|
|
||||||
);
|
|
||||||
|
|
||||||
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
|
|
||||||
holdingPeriodReturns[item.symbol] = (
|
|
||||||
holdingPeriodReturns[item.symbol] ?? new Big(1)
|
|
||||||
).mul(holdingPeriodReturn);
|
|
||||||
grossPerformance[item.symbol] = (
|
|
||||||
grossPerformance[item.symbol] ?? new Big(0)
|
|
||||||
).plus(endValue.minus(investedValue));
|
|
||||||
|
|
||||||
const netHoldingPeriodReturn = endValue.div(
|
|
||||||
initialValue.plus(cashFlow).plus(fee)
|
|
||||||
);
|
|
||||||
netHoldingPeriodReturns[item.symbol] = (
|
|
||||||
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
|
|
||||||
).mul(netHoldingPeriodReturn);
|
|
||||||
netPerformance[item.symbol] = (
|
|
||||||
netPerformance[item.symbol] ?? new Big(0)
|
|
||||||
).plus(endValue.minus(investedValue).minus(fee));
|
|
||||||
}
|
|
||||||
lastInvestments[item.symbol] = item.investment;
|
|
||||||
lastQuantities[item.symbol] = item.quantity;
|
|
||||||
lastFees[item.symbol] = item.fee;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const positions: TimelinePosition[] = [];
|
const positions: TimelinePosition[] = [];
|
||||||
|
let hasAnySymbolMetricsErrors = false;
|
||||||
|
|
||||||
|
const errors: ResponseError['errors'] = [];
|
||||||
|
|
||||||
for (const item of lastTransactionPoint.items) {
|
for (const item of lastTransactionPoint.items) {
|
||||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||||
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
|
|
||||||
|
const {
|
||||||
|
grossPerformance,
|
||||||
|
grossPerformancePercentage,
|
||||||
|
hasErrors,
|
||||||
|
initialValue,
|
||||||
|
netPerformance,
|
||||||
|
netPerformancePercentage
|
||||||
|
} = this.getSymbolMetrics({
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
symbol: item.symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||||
|
initialValues[item.symbol] = initialValue;
|
||||||
|
|
||||||
positions.push({
|
positions.push({
|
||||||
averagePrice: item.quantity.eq(0)
|
averagePrice: item.quantity.eq(0)
|
||||||
? new Big(0)
|
? new Big(0)
|
||||||
@ -320,31 +263,33 @@ export class PortfolioCalculator {
|
|||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
dataSource: item.dataSource,
|
dataSource: item.dataSource,
|
||||||
firstBuyDate: item.firstBuyDate,
|
firstBuyDate: item.firstBuyDate,
|
||||||
grossPerformance: isValid
|
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||||
? grossPerformance[item.symbol] ?? null
|
grossPerformancePercentage: !hasErrors
|
||||||
|
? grossPerformancePercentage ?? null
|
||||||
: null,
|
: null,
|
||||||
grossPerformancePercentage:
|
|
||||||
isValid && holdingPeriodReturns[item.symbol]
|
|
||||||
? holdingPeriodReturns[item.symbol].minus(1)
|
|
||||||
: null,
|
|
||||||
investment: item.investment,
|
investment: item.investment,
|
||||||
marketPrice: marketValue?.toNumber() ?? null,
|
marketPrice: marketValue?.toNumber() ?? null,
|
||||||
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
|
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
||||||
netPerformancePercentage:
|
netPerformancePercentage: !hasErrors
|
||||||
isValid && netHoldingPeriodReturns[item.symbol]
|
? netPerformancePercentage ?? null
|
||||||
? netHoldingPeriodReturns[item.symbol].minus(1)
|
: null,
|
||||||
: null,
|
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
transactionCount: item.transactionCount
|
transactionCount: item.transactionCount
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
const overall = this.calculateOverallPerformance(positions, initialValues);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...overall,
|
...overall,
|
||||||
|
errors,
|
||||||
positions,
|
positions,
|
||||||
hasErrors: hasErrors || overall.hasErrors
|
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,20 +407,16 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
private calculateOverallPerformance(
|
private calculateOverallPerformance(
|
||||||
positions: TimelinePosition[],
|
positions: TimelinePosition[],
|
||||||
initialValues: { [p: string]: Big }
|
initialValues: { [symbol: string]: Big }
|
||||||
) {
|
) {
|
||||||
let hasErrors = false;
|
|
||||||
let currentValue = new Big(0);
|
let currentValue = new Big(0);
|
||||||
let totalInvestment = new Big(0);
|
|
||||||
let grossPerformance = new Big(0);
|
let grossPerformance = new Big(0);
|
||||||
let grossPerformancePercentage = new Big(0);
|
let grossPerformancePercentage = new Big(0);
|
||||||
|
let hasErrors = false;
|
||||||
let netPerformance = new Big(0);
|
let netPerformance = new Big(0);
|
||||||
let netPerformancePercentage = new Big(0);
|
let netPerformancePercentage = new Big(0);
|
||||||
let completeInitialValue = new Big(0);
|
let sumOfWeights = new Big(0);
|
||||||
let netAnnualizedPerformance = new Big(0);
|
let totalInvestment = new Big(0);
|
||||||
|
|
||||||
// use Date.now() to use the mock for today
|
|
||||||
const today = new Date(Date.now());
|
|
||||||
|
|
||||||
for (const currentPosition of positions) {
|
for (const currentPosition of positions) {
|
||||||
if (currentPosition.marketPrice) {
|
if (currentPosition.marketPrice) {
|
||||||
@ -485,36 +426,34 @@ export class PortfolioCalculator {
|
|||||||
} else {
|
} else {
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
||||||
|
|
||||||
if (currentPosition.grossPerformance) {
|
if (currentPosition.grossPerformance) {
|
||||||
grossPerformance = grossPerformance.plus(
|
grossPerformance = grossPerformance.plus(
|
||||||
currentPosition.grossPerformance
|
currentPosition.grossPerformance
|
||||||
);
|
);
|
||||||
|
|
||||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (currentPosition.grossPerformancePercentage) {
|
||||||
currentPosition.grossPerformancePercentage &&
|
// Use the average from the initial value and the current investment as
|
||||||
initialValues[currentPosition.symbol]
|
// a weight
|
||||||
) {
|
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
|
||||||
const currentInitialValue = initialValues[currentPosition.symbol];
|
.plus(currentPosition.investment)
|
||||||
completeInitialValue = completeInitialValue.plus(currentInitialValue);
|
.div(2);
|
||||||
|
|
||||||
|
sumOfWeights = sumOfWeights.plus(weight);
|
||||||
|
|
||||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||||
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
currentPosition.grossPerformancePercentage.mul(weight)
|
||||||
);
|
|
||||||
netAnnualizedPerformance = netAnnualizedPerformance.plus(
|
|
||||||
this.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: differenceInDays(
|
|
||||||
today,
|
|
||||||
parseDate(currentPosition.firstBuyDate)
|
|
||||||
),
|
|
||||||
netPerformancePercent: currentPosition.netPerformancePercentage
|
|
||||||
}).mul(currentInitialValue)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
netPerformancePercentage = netPerformancePercentage.plus(
|
netPerformancePercentage = netPerformancePercentage.plus(
|
||||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
currentPosition.netPerformancePercentage.mul(weight)
|
||||||
);
|
);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
Logger.warn(
|
Logger.warn(
|
||||||
@ -525,13 +464,12 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!completeInitialValue.eq(0)) {
|
if (sumOfWeights.gt(0)) {
|
||||||
grossPerformancePercentage =
|
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
|
||||||
grossPerformancePercentage.div(completeInitialValue);
|
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
|
||||||
netPerformancePercentage =
|
} else {
|
||||||
netPerformancePercentage.div(completeInitialValue);
|
grossPerformancePercentage = new Big(0);
|
||||||
netAnnualizedPerformance =
|
netPerformancePercentage = new Big(0);
|
||||||
netAnnualizedPerformance.div(completeInitialValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -539,7 +477,6 @@ export class PortfolioCalculator {
|
|||||||
grossPerformance,
|
grossPerformance,
|
||||||
grossPerformancePercentage,
|
grossPerformancePercentage,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
netAnnualizedPerformance,
|
|
||||||
netPerformance,
|
netPerformance,
|
||||||
netPerformancePercentage,
|
netPerformancePercentage,
|
||||||
totalInvestment
|
totalInvestment
|
||||||
@ -693,6 +630,356 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSymbolMetrics({
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
};
|
||||||
|
start: Date;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
||||||
|
return order.symbol === symbol;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (orders.length <= 0) {
|
||||||
|
return {
|
||||||
|
hasErrors: false,
|
||||||
|
initialValue: new Big(0),
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOfFirstTransaction = new Date(first(orders).date);
|
||||||
|
const endDate = new Date(Date.now());
|
||||||
|
|
||||||
|
const unitPriceAtStartDate =
|
||||||
|
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
||||||
|
|
||||||
|
const unitPriceAtEndDate =
|
||||||
|
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!unitPriceAtEndDate ||
|
||||||
|
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
hasErrors: true,
|
||||||
|
initialValue: new Big(0),
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let averagePriceAtEndDate = new Big(0);
|
||||||
|
let averagePriceAtStartDate = new Big(0);
|
||||||
|
let feesAtStartDate = new Big(0);
|
||||||
|
let fees = new Big(0);
|
||||||
|
let grossPerformance = new Big(0);
|
||||||
|
let grossPerformanceAtStartDate = new Big(0);
|
||||||
|
let grossPerformanceFromSells = new Big(0);
|
||||||
|
let initialValue: Big;
|
||||||
|
let investmentAtStartDate: Big;
|
||||||
|
let lastAveragePrice = new Big(0);
|
||||||
|
let lastTransactionInvestment = new Big(0);
|
||||||
|
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
||||||
|
let maxTotalInvestment = new Big(0);
|
||||||
|
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||||
|
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||||
|
let totalInvestment = new Big(0);
|
||||||
|
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||||
|
let totalUnits = new Big(0);
|
||||||
|
let valueAtStartDate: Big;
|
||||||
|
|
||||||
|
// Add a synthetic order at the start and the end date
|
||||||
|
orders.push({
|
||||||
|
symbol,
|
||||||
|
currency: null,
|
||||||
|
date: format(start, DATE_FORMAT),
|
||||||
|
dataSource: null,
|
||||||
|
fee: new Big(0),
|
||||||
|
itemType: 'start',
|
||||||
|
name: '',
|
||||||
|
quantity: new Big(0),
|
||||||
|
type: TypeOfOrder.BUY,
|
||||||
|
unitPrice: unitPriceAtStartDate
|
||||||
|
});
|
||||||
|
|
||||||
|
orders.push({
|
||||||
|
symbol,
|
||||||
|
currency: null,
|
||||||
|
date: format(endDate, DATE_FORMAT),
|
||||||
|
dataSource: null,
|
||||||
|
fee: new Big(0),
|
||||||
|
itemType: 'end',
|
||||||
|
name: '',
|
||||||
|
quantity: new Big(0),
|
||||||
|
type: TypeOfOrder.BUY,
|
||||||
|
unitPrice: unitPriceAtEndDate
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort orders so that the start and end placeholder order are at the right
|
||||||
|
// position
|
||||||
|
orders = sortBy(orders, (order) => {
|
||||||
|
let sortIndex = new Date(order.date);
|
||||||
|
|
||||||
|
if (order.itemType === 'start') {
|
||||||
|
sortIndex = addMilliseconds(sortIndex, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.itemType === 'end') {
|
||||||
|
sortIndex = addMilliseconds(sortIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortIndex.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexOfStartOrder = orders.findIndex((order) => {
|
||||||
|
return order.itemType === 'start';
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexOfEndOrder = orders.findIndex((order) => {
|
||||||
|
return order.itemType === 'end';
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < orders.length; i += 1) {
|
||||||
|
const order = orders[i];
|
||||||
|
|
||||||
|
if (order.itemType === 'start') {
|
||||||
|
// Take the unit price of the order as the market price if there are no
|
||||||
|
// orders of this symbol before the start date
|
||||||
|
order.unitPrice =
|
||||||
|
indexOfStartOrder === 0
|
||||||
|
? orders[i + 1]?.unitPrice
|
||||||
|
: unitPriceAtStartDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the average start price as soon as any units are held
|
||||||
|
if (
|
||||||
|
averagePriceAtStartDate.eq(0) &&
|
||||||
|
i >= indexOfStartOrder &&
|
||||||
|
totalUnits.gt(0)
|
||||||
|
) {
|
||||||
|
averagePriceAtStartDate = totalInvestment.div(totalUnits);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
|
||||||
|
order.unitPrice
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!investmentAtStartDate && i >= indexOfStartOrder) {
|
||||||
|
investmentAtStartDate = totalInvestment ?? new Big(0);
|
||||||
|
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionInvestment = order.quantity
|
||||||
|
.mul(order.unitPrice)
|
||||||
|
.mul(this.getFactor(order.type));
|
||||||
|
|
||||||
|
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||||
|
|
||||||
|
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
|
||||||
|
maxTotalInvestment = totalInvestment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i === indexOfEndOrder && totalUnits.gt(0)) {
|
||||||
|
averagePriceAtEndDate = totalInvestment.div(totalUnits);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i >= indexOfStartOrder && !initialValue) {
|
||||||
|
if (
|
||||||
|
i === indexOfStartOrder &&
|
||||||
|
!valueOfInvestmentBeforeTransaction.eq(0)
|
||||||
|
) {
|
||||||
|
initialValue = valueOfInvestmentBeforeTransaction;
|
||||||
|
} else if (transactionInvestment.gt(0)) {
|
||||||
|
initialValue = transactionInvestment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fees = fees.plus(order.fee);
|
||||||
|
|
||||||
|
totalUnits = totalUnits.plus(
|
||||||
|
order.quantity.mul(this.getFactor(order.type))
|
||||||
|
);
|
||||||
|
|
||||||
|
const valueOfInvestment = totalUnits.mul(order.unitPrice);
|
||||||
|
|
||||||
|
const grossPerformanceFromSell =
|
||||||
|
order.type === TypeOfOrder.SELL
|
||||||
|
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
||||||
|
grossPerformanceFromSell
|
||||||
|
);
|
||||||
|
|
||||||
|
totalInvestmentWithGrossPerformanceFromSell =
|
||||||
|
totalInvestmentWithGrossPerformanceFromSell
|
||||||
|
.plus(transactionInvestment)
|
||||||
|
.plus(grossPerformanceFromSell);
|
||||||
|
|
||||||
|
lastAveragePrice = totalUnits.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||||
|
|
||||||
|
const newGrossPerformance = valueOfInvestment
|
||||||
|
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
||||||
|
.plus(grossPerformanceFromSells);
|
||||||
|
|
||||||
|
if (
|
||||||
|
i > indexOfStartOrder &&
|
||||||
|
!lastValueOfInvestmentBeforeTransaction
|
||||||
|
.plus(lastTransactionInvestment)
|
||||||
|
.eq(0)
|
||||||
|
) {
|
||||||
|
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||||
|
.minus(
|
||||||
|
lastValueOfInvestmentBeforeTransaction.plus(
|
||||||
|
lastTransactionInvestment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.div(
|
||||||
|
lastValueOfInvestmentBeforeTransaction.plus(
|
||||||
|
lastTransactionInvestment
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
timeWeightedGrossPerformancePercentage =
|
||||||
|
timeWeightedGrossPerformancePercentage.mul(
|
||||||
|
new Big(1).plus(grossHoldingPeriodReturn)
|
||||||
|
);
|
||||||
|
|
||||||
|
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||||
|
.minus(fees.minus(feesAtStartDate))
|
||||||
|
.minus(
|
||||||
|
lastValueOfInvestmentBeforeTransaction.plus(
|
||||||
|
lastTransactionInvestment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.div(
|
||||||
|
lastValueOfInvestmentBeforeTransaction.plus(
|
||||||
|
lastTransactionInvestment
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
timeWeightedNetPerformancePercentage =
|
||||||
|
timeWeightedNetPerformancePercentage.mul(
|
||||||
|
new Big(1).plus(netHoldingPeriodReturn)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
grossPerformance = newGrossPerformance;
|
||||||
|
|
||||||
|
lastTransactionInvestment = transactionInvestment;
|
||||||
|
|
||||||
|
lastValueOfInvestmentBeforeTransaction =
|
||||||
|
valueOfInvestmentBeforeTransaction;
|
||||||
|
|
||||||
|
if (order.itemType === 'start') {
|
||||||
|
feesAtStartDate = fees;
|
||||||
|
grossPerformanceAtStartDate = grossPerformance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeWeightedGrossPerformancePercentage =
|
||||||
|
timeWeightedGrossPerformancePercentage.minus(1);
|
||||||
|
|
||||||
|
timeWeightedNetPerformancePercentage =
|
||||||
|
timeWeightedNetPerformancePercentage.minus(1);
|
||||||
|
|
||||||
|
const totalGrossPerformance = grossPerformance.minus(
|
||||||
|
grossPerformanceAtStartDate
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalNetPerformance = grossPerformance
|
||||||
|
.minus(grossPerformanceAtStartDate)
|
||||||
|
.minus(fees.minus(feesAtStartDate));
|
||||||
|
|
||||||
|
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
|
||||||
|
maxTotalInvestment.minus(investmentAtStartDate)
|
||||||
|
);
|
||||||
|
|
||||||
|
const grossPerformancePercentage =
|
||||||
|
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
||||||
|
averagePriceAtStartDate.eq(0) ||
|
||||||
|
averagePriceAtEndDate.eq(0) ||
|
||||||
|
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||||
|
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||||
|
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||||
|
: new Big(0)
|
||||||
|
: // This formula has the issue that buying more units with a price
|
||||||
|
// lower than the average buying price results in a positive
|
||||||
|
// performance even if the market price stays constant
|
||||||
|
unitPriceAtEndDate
|
||||||
|
.div(averagePriceAtEndDate)
|
||||||
|
.div(
|
||||||
|
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||||
|
)
|
||||||
|
.minus(1);
|
||||||
|
|
||||||
|
const feesPerUnit = totalUnits.gt(0)
|
||||||
|
? fees.minus(feesAtStartDate).div(totalUnits)
|
||||||
|
: new Big(0);
|
||||||
|
|
||||||
|
const netPerformancePercentage =
|
||||||
|
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
|
||||||
|
averagePriceAtStartDate.eq(0) ||
|
||||||
|
averagePriceAtEndDate.eq(0) ||
|
||||||
|
orders[indexOfStartOrder].unitPrice.eq(0)
|
||||||
|
? maxInvestmentBetweenStartAndEndDate.gt(0)
|
||||||
|
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
|
||||||
|
: new Big(0)
|
||||||
|
: // This formula has the issue that buying more units with a price
|
||||||
|
// lower than the average buying price results in a positive
|
||||||
|
// performance even if the market price stays constant
|
||||||
|
unitPriceAtEndDate
|
||||||
|
.minus(feesPerUnit)
|
||||||
|
.div(averagePriceAtEndDate)
|
||||||
|
.div(
|
||||||
|
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
|
||||||
|
)
|
||||||
|
.minus(1);
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log(
|
||||||
|
`
|
||||||
|
${symbol}
|
||||||
|
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
|
||||||
|
2
|
||||||
|
)} -> ${unitPriceAtEndDate.toFixed(2)}
|
||||||
|
Average price: ${averagePriceAtStartDate.toFixed(
|
||||||
|
2
|
||||||
|
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||||
|
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||||
|
Gross performance: ${totalGrossPerformance.toFixed(
|
||||||
|
2
|
||||||
|
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
|
||||||
|
Fees per unit: ${feesPerUnit.toFixed(2)}
|
||||||
|
Net performance: ${totalNetPerformance.toFixed(
|
||||||
|
2
|
||||||
|
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialValue,
|
||||||
|
grossPerformancePercentage,
|
||||||
|
netPerformancePercentage,
|
||||||
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||||
|
netPerformance: totalNetPerformance,
|
||||||
|
grossPerformance: totalGrossPerformance
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private isNextItemActive(
|
private isNextItemActive(
|
||||||
timelineSpecification: TimelineSpecification[],
|
timelineSpecification: TimelineSpecification[],
|
||||||
currentDate: Date,
|
currentDate: Date,
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
|
||||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PortfolioServiceStrategy {
|
|
||||||
public constructor(
|
|
||||||
private readonly portfolioService: PortfolioService,
|
|
||||||
private readonly portfolioServiceNew: PortfolioServiceNew,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public get(newCalculationEngine?: boolean) {
|
|
||||||
if (
|
|
||||||
newCalculationEngine ||
|
|
||||||
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
|
|
||||||
) {
|
|
||||||
return this.portfolioServiceNew;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.portfolioService;
|
|
||||||
}
|
|
||||||
}
|
|
@ -38,7 +38,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
@ -46,7 +46,7 @@ export class PortfolioController {
|
|||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
@ -57,9 +57,10 @@ export class PortfolioController {
|
|||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('range') range
|
||||||
): Promise<PortfolioChart> {
|
): Promise<PortfolioChart> {
|
||||||
const historicalDataContainer = await this.portfolioServiceStrategy
|
const historicalDataContainer = await this.portfolioService.getChart(
|
||||||
.get()
|
impersonationId,
|
||||||
.getChart(impersonationId, range);
|
range
|
||||||
|
);
|
||||||
|
|
||||||
let chartData = historicalDataContainer.items;
|
let chartData = historicalDataContainer.items;
|
||||||
|
|
||||||
@ -109,9 +110,11 @@ export class PortfolioController {
|
|||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioServiceStrategy
|
await this.portfolioService.getDetails(
|
||||||
.get(true)
|
impersonationId,
|
||||||
.getDetails(impersonationId, this.request.user.id, range);
|
this.request.user.id,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
@ -174,9 +177,9 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let investments = await this.portfolioServiceStrategy
|
let investments = await this.portfolioService.getInvestments(
|
||||||
.get()
|
impersonationId
|
||||||
.getInvestments(impersonationId);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -203,9 +206,10 @@ export class PortfolioController {
|
|||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('range') range
|
||||||
): Promise<PortfolioPerformanceResponse> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
const performanceInformation = await this.portfolioServiceStrategy
|
const performanceInformation = await this.portfolioService.getPerformance(
|
||||||
.get()
|
impersonationId,
|
||||||
.getPerformance(impersonationId, range);
|
range
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -228,9 +232,10 @@ export class PortfolioController {
|
|||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('range') range
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const result = await this.portfolioServiceStrategy
|
const result = await this.portfolioService.getPositions(
|
||||||
.get()
|
impersonationId,
|
||||||
.getPositions(impersonationId, range);
|
range
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -270,9 +275,10 @@ export class PortfolioController {
|
|||||||
hasDetails = user.subscription.type === 'Premium';
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { holdings } = await this.portfolioServiceStrategy
|
const { holdings } = await this.portfolioService.getDetails(
|
||||||
.get(true)
|
access.userId,
|
||||||
.getDetails(access.userId, access.userId);
|
access.userId
|
||||||
|
);
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
hasDetails,
|
hasDetails,
|
||||||
@ -324,9 +330,7 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let summary = await this.portfolioServiceStrategy
|
let summary = await this.portfolioService.getSummary(impersonationId);
|
||||||
.get()
|
|
||||||
.getSummary(impersonationId);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -360,9 +364,11 @@ export class PortfolioController {
|
|||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioServiceStrategy
|
let position = await this.portfolioService.getPosition(
|
||||||
.get()
|
dataSource,
|
||||||
.getPosition(dataSource, impersonationId, symbol);
|
impersonationId,
|
||||||
|
symbol
|
||||||
|
);
|
||||||
|
|
||||||
if (position) {
|
if (position) {
|
||||||
if (
|
if (
|
||||||
@ -403,6 +409,6 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
|
return await this.portfolioService.getReport(impersonationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,15 +13,13 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
|
||||||
import { RulesService } from './rules.service';
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
exports: [PortfolioServiceStrategy],
|
exports: [PortfolioService],
|
||||||
imports: [
|
imports: [
|
||||||
AccessModule,
|
AccessModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
@ -39,8 +37,6 @@ import { RulesService } from './rules.service';
|
|||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
PortfolioServiceNew,
|
|
||||||
PortfolioServiceStrategy,
|
|
||||||
RulesService
|
RulesService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,6 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
|
|||||||
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||||
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
||||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
|
|
||||||
import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface';
|
import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||||
@ -41,6 +40,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
|
|||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
DateRange,
|
DateRange,
|
||||||
|
Market,
|
||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
@ -49,6 +49,7 @@ import { REQUEST } from '@nestjs/core';
|
|||||||
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
|
differenceInDays,
|
||||||
endOfToday,
|
endOfToday,
|
||||||
format,
|
format,
|
||||||
isAfter,
|
isAfter,
|
||||||
@ -68,8 +69,12 @@ import {
|
|||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
PortfolioPositionDetail
|
PortfolioPositionDetail
|
||||||
} from './interfaces/portfolio-position-detail.interface';
|
} from './interfaces/portfolio-position-detail.interface';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
import { RulesService } from './rules.service';
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
|
const developedMarkets = require('../../assets/countries/developed-markets.json');
|
||||||
|
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -159,15 +164,18 @@ export class PortfolioService {
|
|||||||
): Promise<InvestmentItem[]> {
|
): Promise<InvestmentItem[]> {
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const { portfolioOrders, transactionPoints } =
|
||||||
this.currentRateService,
|
await this.getTransactionPoints({
|
||||||
this.request.user.Settings.currency
|
userId,
|
||||||
);
|
includeDrafts: true
|
||||||
|
});
|
||||||
|
|
||||||
const { transactionPoints } = await this.getTransactionPoints({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
userId,
|
currency: this.request.user.Settings.currency,
|
||||||
includeDrafts: true
|
currentRateService: this.currentRateService,
|
||||||
|
orders: portfolioOrders
|
||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
if (transactionPoints.length === 0) {
|
if (transactionPoints.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@ -208,12 +216,17 @@ export class PortfolioService {
|
|||||||
): Promise<HistoricalDataContainer> {
|
): Promise<HistoricalDataContainer> {
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const { portfolioOrders, transactionPoints } =
|
||||||
this.currentRateService,
|
await this.getTransactionPoints({
|
||||||
this.request.user.Settings.currency
|
userId
|
||||||
);
|
});
|
||||||
|
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currency: this.request.user.Settings.currency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
orders: portfolioOrders
|
||||||
|
});
|
||||||
|
|
||||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
if (transactionPoints.length === 0) {
|
if (transactionPoints.length === 0) {
|
||||||
return {
|
return {
|
||||||
@ -302,13 +315,16 @@ export class PortfolioService {
|
|||||||
this.request.user?.Settings?.currency ??
|
this.request.user?.Settings?.currency ??
|
||||||
user.Settings?.currency ??
|
user.Settings?.currency ??
|
||||||
baseCurrency;
|
baseCurrency;
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
|
||||||
this.currentRateService,
|
|
||||||
userCurrency
|
|
||||||
);
|
|
||||||
|
|
||||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
const { orders, portfolioOrders, transactionPoints } =
|
||||||
userId
|
await this.getTransactionPoints({
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currency: userCurrency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
orders: portfolioOrders
|
||||||
});
|
});
|
||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
@ -368,7 +384,31 @@ export class PortfolioService {
|
|||||||
const value = item.quantity.mul(item.marketPrice);
|
const value = item.quantity.mul(item.marketPrice);
|
||||||
const symbolProfile = symbolProfileMap[item.symbol];
|
const symbolProfile = symbolProfileMap[item.symbol];
|
||||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||||
|
|
||||||
|
const markets: { [key in Market]: number } = {
|
||||||
|
developedMarkets: 0,
|
||||||
|
emergingMarkets: 0,
|
||||||
|
otherMarkets: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const country of symbolProfile.countries) {
|
||||||
|
if (developedMarkets.includes(country.code)) {
|
||||||
|
markets.developedMarkets = new Big(markets.developedMarkets)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else if (emergingMarkets.includes(country.code)) {
|
||||||
|
markets.emergingMarkets = new Big(markets.emergingMarkets)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else {
|
||||||
|
markets.otherMarkets = new Big(markets.otherMarkets)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
holdings[item.symbol] = {
|
holdings[item.symbol] = {
|
||||||
|
markets,
|
||||||
allocationCurrent: value.div(totalValue).toNumber(),
|
allocationCurrent: value.div(totalValue).toNumber(),
|
||||||
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
||||||
assetClass: symbolProfile.assetClass,
|
assetClass: symbolProfile.assetClass,
|
||||||
@ -474,11 +514,13 @@ export class PortfolioService {
|
|||||||
unitPrice: new Big(order.unitPrice)
|
unitPrice: new Big(order.unitPrice)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
this.currentRateService,
|
currency: positionCurrency,
|
||||||
positionCurrency
|
currentRateService: this.currentRateService,
|
||||||
);
|
orders: portfolioOrders
|
||||||
portfolioCalculator.computeTransactionPoints(portfolioOrders);
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.computeTransactionPoints();
|
||||||
const transactionPoints = portfolioCalculator.getTransactionPoints();
|
const transactionPoints = portfolioCalculator.getTransactionPoints();
|
||||||
|
|
||||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
@ -657,12 +699,16 @@ export class PortfolioService {
|
|||||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const { portfolioOrders, transactionPoints } =
|
||||||
this.currentRateService,
|
await this.getTransactionPoints({
|
||||||
this.request.user.Settings.currency
|
userId
|
||||||
);
|
});
|
||||||
|
|
||||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currency: this.request.user.Settings.currency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
orders: portfolioOrders
|
||||||
|
});
|
||||||
|
|
||||||
if (transactionPoints?.length <= 0) {
|
if (transactionPoints?.length <= 0) {
|
||||||
return {
|
return {
|
||||||
@ -730,18 +776,21 @@ export class PortfolioService {
|
|||||||
): Promise<PortfolioPerformanceResponse> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const { portfolioOrders, transactionPoints } =
|
||||||
this.currentRateService,
|
await this.getTransactionPoints({
|
||||||
this.request.user.Settings.currency
|
userId
|
||||||
);
|
});
|
||||||
|
|
||||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currency: this.request.user.Settings.currency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
orders: portfolioOrders
|
||||||
|
});
|
||||||
|
|
||||||
if (transactionPoints?.length <= 0) {
|
if (transactionPoints?.length <= 0) {
|
||||||
return {
|
return {
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
performance: {
|
performance: {
|
||||||
annualizedPerformancePercent: 0,
|
|
||||||
currentGrossPerformance: 0,
|
currentGrossPerformance: 0,
|
||||||
currentGrossPerformancePercent: 0,
|
currentGrossPerformancePercent: 0,
|
||||||
currentNetPerformance: 0,
|
currentNetPerformance: 0,
|
||||||
@ -760,26 +809,34 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasErrors = currentPositions.hasErrors;
|
const hasErrors = currentPositions.hasErrors;
|
||||||
const annualizedPerformancePercent =
|
|
||||||
currentPositions.netAnnualizedPerformance.toNumber();
|
|
||||||
const currentValue = currentPositions.currentValue.toNumber();
|
const currentValue = currentPositions.currentValue.toNumber();
|
||||||
const currentGrossPerformance =
|
const currentGrossPerformance = currentPositions.grossPerformance;
|
||||||
currentPositions.grossPerformance.toNumber();
|
let currentGrossPerformancePercent =
|
||||||
const currentGrossPerformancePercent =
|
currentPositions.grossPerformancePercentage;
|
||||||
currentPositions.grossPerformancePercentage.toNumber();
|
const currentNetPerformance = currentPositions.netPerformance;
|
||||||
const currentNetPerformance = currentPositions.netPerformance.toNumber();
|
let currentNetPerformancePercent =
|
||||||
const currentNetPerformancePercent =
|
currentPositions.netPerformancePercentage;
|
||||||
currentPositions.netPerformancePercentage.toNumber();
|
|
||||||
|
if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
|
||||||
|
// If algebraic sign is different, harmonize it
|
||||||
|
currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
|
||||||
|
// If algebraic sign is different, harmonize it
|
||||||
|
currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
errors: currentPositions.errors,
|
||||||
hasErrors: currentPositions.hasErrors || hasErrors,
|
hasErrors: currentPositions.hasErrors || hasErrors,
|
||||||
performance: {
|
performance: {
|
||||||
annualizedPerformancePercent,
|
currentValue,
|
||||||
currentGrossPerformance,
|
currentGrossPerformance: currentGrossPerformance.toNumber(),
|
||||||
currentGrossPerformancePercent,
|
currentGrossPerformancePercent:
|
||||||
currentNetPerformance,
|
currentGrossPerformancePercent.toNumber(),
|
||||||
currentNetPerformancePercent,
|
currentNetPerformance: currentNetPerformance.toNumber(),
|
||||||
currentValue
|
currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -788,9 +845,10 @@ export class PortfolioService {
|
|||||||
const currency = this.request.user.Settings.currency;
|
const currency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
const { orders, portfolioOrders, transactionPoints } =
|
||||||
userId
|
await this.getTransactionPoints({
|
||||||
});
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
if (isEmpty(orders)) {
|
if (isEmpty(orders)) {
|
||||||
return {
|
return {
|
||||||
@ -798,10 +856,12 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
this.currentRateService,
|
currency,
|
||||||
currency
|
currentRateService: this.currentRateService,
|
||||||
);
|
orders: portfolioOrders
|
||||||
|
});
|
||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||||
@ -907,8 +967,24 @@ export class PortfolioService {
|
|||||||
.plus(items)
|
.plus(items)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
|
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||||
|
|
||||||
|
const annualizedPerformancePercent = new PortfolioCalculator({
|
||||||
|
currency: userCurrency,
|
||||||
|
currentRateService: this.currentRateService,
|
||||||
|
orders: []
|
||||||
|
})
|
||||||
|
.getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket,
|
||||||
|
netPerformancePercent: new Big(
|
||||||
|
performanceInformation.performance.currentNetPerformancePercent
|
||||||
|
)
|
||||||
|
})
|
||||||
|
?.toNumber();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...performanceInformation.performance,
|
...performanceInformation.performance,
|
||||||
|
annualizedPerformancePercent,
|
||||||
cash,
|
cash,
|
||||||
dividend,
|
dividend,
|
||||||
fees,
|
fees,
|
||||||
@ -917,8 +993,6 @@ export class PortfolioService {
|
|||||||
netWorth,
|
netWorth,
|
||||||
totalBuy,
|
totalBuy,
|
||||||
totalSell,
|
totalSell,
|
||||||
annualizedPerformancePercent:
|
|
||||||
performanceInformation.performance.annualizedPerformancePercent,
|
|
||||||
committedFunds: committedFunds.toNumber(),
|
committedFunds: committedFunds.toNumber(),
|
||||||
emergencyFund: emergencyFund.toNumber(),
|
emergencyFund: emergencyFund.toNumber(),
|
||||||
ordersCount: orders.filter((order) => {
|
ordersCount: orders.filter((order) => {
|
||||||
@ -937,8 +1011,8 @@ export class PortfolioService {
|
|||||||
cashDetails: CashDetails;
|
cashDetails: CashDetails;
|
||||||
emergencyFund: Big;
|
emergencyFund: Big;
|
||||||
investment: Big;
|
investment: Big;
|
||||||
userCurrency: string;
|
|
||||||
value: Big;
|
value: Big;
|
||||||
|
userCurrency: string;
|
||||||
}) {
|
}) {
|
||||||
const cashPositions: PortfolioDetails['holdings'] = {};
|
const cashPositions: PortfolioDetails['holdings'] = {};
|
||||||
|
|
||||||
@ -1111,6 +1185,7 @@ export class PortfolioService {
|
|||||||
}): Promise<{
|
}): Promise<{
|
||||||
transactionPoints: TransactionPoint[];
|
transactionPoints: TransactionPoint[];
|
||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
|
portfolioOrders: PortfolioOrder[];
|
||||||
}> {
|
}> {
|
||||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
|
|
||||||
@ -1122,7 +1197,7 @@ export class PortfolioService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return { transactionPoints: [], orders: [] };
|
return { transactionPoints: [], orders: [], portfolioOrders: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||||
@ -1149,14 +1224,18 @@ export class PortfolioService {
|
|||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
this.currentRateService,
|
currency: userCurrency,
|
||||||
userCurrency
|
currentRateService: this.currentRateService,
|
||||||
);
|
orders: portfolioOrders
|
||||||
portfolioCalculator.computeTransactionPoints(portfolioOrders);
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transactionPoints: portfolioCalculator.getTransactionPoints(),
|
transactionPoints: portfolioCalculator.getTransactionPoints(),
|
||||||
orders
|
orders,
|
||||||
|
portfolioOrders
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
export interface UserSettings {
|
export interface UserSettings {
|
||||||
emergencyFund?: number;
|
emergencyFund?: number;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
isNewCalculationEngine?: boolean;
|
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -5,10 +5,6 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
emergencyFund?: number;
|
emergencyFund?: number;
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
@IsOptional()
|
|
||||||
isNewCalculationEngine?: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
|
@ -222,24 +222,6 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onNewCalculationChange(aEvent: MatSlideToggleChange) {
|
|
||||||
this.dataService
|
|
||||||
.putUserSetting({ isNewCalculationEngine: aEvent.checked })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.userService.remove();
|
|
||||||
|
|
||||||
this.userService
|
|
||||||
.get()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((user) => {
|
|
||||||
this.user = user;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onRedeemCoupon() {
|
public onRedeemCoupon() {
|
||||||
let couponCode = prompt('Please enter your coupon code:');
|
let couponCode = prompt('Please enter your coupon code:');
|
||||||
couponCode = couponCode?.trim();
|
couponCode = couponCode?.trim();
|
||||||
|
@ -169,23 +169,6 @@
|
|||||||
></mat-slide-toggle>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
*ngIf="user?.subscription"
|
|
||||||
class="align-items-center d-flex mt-4 py-1"
|
|
||||||
>
|
|
||||||
<div class="pr-1 w-50">
|
|
||||||
<div i18n>New Calculation Engine</div>
|
|
||||||
<div class="hint-text text-muted" i18n>Experimental</div>
|
|
||||||
</div>
|
|
||||||
<div class="pl-1 w-50">
|
|
||||||
<mat-slide-toggle
|
|
||||||
color="primary"
|
|
||||||
[checked]="user.settings.isNewCalculationEngine"
|
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
|
||||||
(change)="onNewCalculationChange($event)"
|
|
||||||
></mat-slide-toggle>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user