Browse Source

Implement ROI Calculator

pull/5027/head
Dan 2 months ago
parent
commit
a3a3f411a3
  1. 208
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts
  2. 39
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts
  3. 198
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts
  4. 202
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  5. 253
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts
  6. 861
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts
  7. 252
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  8. 13
      libs/common/src/lib/types/date-range.type.ts

208
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts

@ -0,0 +1,208 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with GOOGL buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Alphabet Inc.',
symbol: 'GOOGL'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 89.12
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('103.10483'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('89.12'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33').mul(0.8854),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109'
),
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
investment: new Big('89.12').mul(0.8854),
investmentWithCurrencyEffect: new Big('82.329056'),
netPerformance: new Big('26.33').mul(0.8854),
netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.24112962014285697628')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') },
marketPrice: 116.45,
marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'),
symbol: 'GOOGL',
tags: [],
timeWeightedInvestment: new Big('89.12').mul(0.8854),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1,
valueInBaseCurrency: new Big('103.10483')
}
],
totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12').mul(0.8854),
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: new Big('26.33').mul(0.8854).toNumber(),
netPerformanceInPercentage: 0.29544434470377019749,
netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628,
netPerformanceWithCurrencyEffect: 19.851974,
totalInvestmentValueWithCurrencyEffect: 82.329056
})
);
expect(investments).toEqual([
{ date: '2023-01-03', investment: new Big('89.12') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2023-01-01', investment: 82.329056 },
{ date: '2023-02-01', investment: 0 },
{ date: '2023-03-01', investment: 0 },
{ date: '2023-04-01', investment: 0 },
{ date: '2023-05-01', investment: 0 },
{ date: '2023-06-01', investment: 0 },
{ date: '2023-07-01', investment: 0 }
]);
});
});
});

39
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts

@ -0,0 +1,39 @@
import { SymbolMetrics } from '@ghostfolio/common/interfaces';
import { Big } from 'big.js';
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface';
export class PortfolioCalculatorSymbolMetricsHelperObject {
currentExchangeRate: number;
endDateString: string;
exchangeRateAtOrderDate: number;
fees: Big = new Big(0);
feesWithCurrencyEffect: Big = new Big(0);
feesAtStartDate: Big = new Big(0);
feesAtStartDateWithCurrencyEffect: Big = new Big(0);
grossPerformanceAtStartDate: Big = new Big(0);
grossPerformanceAtStartDateWithCurrencyEffect: Big = new Big(0);
indexOfEndOrder: number;
indexOfStartOrder: number;
initialValue: Big;
initialValueWithCurrencyEffect: Big;
investmentAtStartDate: Big;
investmentAtStartDateWithCurrencyEffect: Big;
investmentValueBeforeTransaction: Big = new Big(0);
investmentValueBeforeTransactionWithCurrencyEffect: Big = new Big(0);
ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
startDateString: string;
symbolMetrics: SymbolMetrics;
totalUnits: Big = new Big(0);
totalInvestmentFromBuyTransactions: Big = new Big(0);
totalInvestmentFromBuyTransactionsWithCurrencyEffect: Big = new Big(0);
totalQuantityFromBuyTransactions: Big = new Big(0);
totalValueOfPositionsSold: Big = new Big(0);
totalValueOfPositionsSoldWithCurrencyEffect: Big = new Big(0);
unitPrice: Big;
unitPriceAtEndDate: Big = new Big(0);
unitPriceAtStartDate: Big = new Big(0);
valueAtStartDate: Big = new Big(0);
valueAtStartDateWithCurrencyEffect: Big = new Big(0);
}

198
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -0,0 +1,198 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with MSFT buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPriceInAssetProfileCurrency: 0.62
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('298.58'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
grossPerformance: new Big('33.87'),
grossPerformancePercentage: new Big('0.11343693482483756447'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.11343693482483756447'
),
grossPerformanceWithCurrencyEffect: new Big('33.87'),
investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83,
netPerformance: new Big('14.87'),
netPerformancePercentage: new Big('0.04980239801728180052'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.04980239801728180052')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big('-5.39'),
'5y': new Big('14.87'),
max: new Big('14.87'),
wtd: new Big('-5.39')
},
quantity: new Big('1'),
symbol: 'MSFT',
tags: [],
transactionCount: 2
}
],
totalFeesWithCurrencyEffect: new Big('19'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
totalInvestmentValueWithCurrencyEffect: 298.58
})
);
});
});
});

202
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -0,0 +1,202 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell partially', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('87.8'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.14465699208443271768'
),
grossPerformanceWithCurrencyEffect: new Big('21.93'),
investment: new Big('75.80'),
investmentWithCurrencyEffect: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.11662269129287598945')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') },
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('87.8')
}
],
totalFeesWithCurrencyEffect: new Big('4.25'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('75.80'),
totalInvestmentWithCurrencyEffect: new Big('75.80'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 17.68,
netPerformanceInPercentage: 0.11662269129287598945,
netPerformanceInPercentageWithCurrencyEffect: 0.11662269129287598945,
netPerformanceWithCurrencyEffect: 17.68,
totalInvestmentValueWithCurrencyEffect: 75.8
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -75.8 }
]);
});
});
});

253
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -0,0 +1,253 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 151.6,
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6,
valueWithCurrencyEffect: 151.6
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
});
});
});

861
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts

@ -0,0 +1,861 @@
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { SymbolMetrics } from '@ghostfolio/common/interfaces';
import { DateRangeTypes } from '@ghostfolio/common/types/date-range.type';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { isBefore, addMilliseconds, format } from 'date-fns';
import { sortBy } from 'lodash';
import { getFactor } from '../../../../helper/portfolio.helper';
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface';
import { PortfolioCalculatorSymbolMetricsHelperObject } from './portfolio-calculator-helper-object';
export class RoiPortfolioCalculatorSymbolMetricsHelper {
private ENABLE_LOGGING: boolean;
private baseCurrencySuffix = 'InBaseCurrency';
private chartDates: string[];
private marketSymbolMap: { [date: string]: { [symbol: string]: Big } };
public constructor(
ENABLE_LOGGING: boolean,
marketSymbolMap: { [date: string]: { [symbol: string]: Big } },
chartDates: string[]
) {
this.ENABLE_LOGGING = ENABLE_LOGGING;
this.marketSymbolMap = marketSymbolMap;
this.chartDates = chartDates;
}
public calculateNetPerformanceByDateRange(
start: Date,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
for (const dateRange of DateRangeTypes) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[
dateRange
] =
symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[
rangeEndDateString
]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (symbolMetricsHelper.symbolMetrics
.netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
symbolMetricsHelper.symbolMetrics.netPerformancePercentageWithCurrencyEffectMap[
dateRange
] =
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValuesWithCurrencyEffect[
rangeEndDateString
]?.gt(0)
? symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[
dateRange
].div(
symbolMetricsHelper.symbolMetrics
.timeWeightedInvestmentValuesWithCurrencyEffect[
rangeEndDateString
]
)
: new Big(0);
}
}
public handleOverallPerformanceCalculation(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
symbolMetricsHelper.symbolMetrics.grossPerformance =
symbolMetricsHelper.symbolMetrics.grossPerformance.minus(
symbolMetricsHelper.grossPerformanceAtStartDate
);
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.minus(
symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect
);
symbolMetricsHelper.symbolMetrics.netPerformance =
symbolMetricsHelper.symbolMetrics.grossPerformance.minus(
symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate)
);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment = new Big(
symbolMetricsHelper.totalInvestmentFromBuyTransactions
);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentWithCurrencyEffect =
new Big(
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect
);
if (symbolMetricsHelper.symbolMetrics.timeWeightedInvestment.gt(0)) {
symbolMetricsHelper.symbolMetrics.netPerformancePercentage =
symbolMetricsHelper.symbolMetrics.netPerformance.div(
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment
);
symbolMetricsHelper.symbolMetrics.grossPerformancePercentage =
symbolMetricsHelper.symbolMetrics.grossPerformance.div(
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment
);
symbolMetricsHelper.symbolMetrics.grossPerformancePercentageWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.div(
symbolMetricsHelper.symbolMetrics
.timeWeightedInvestmentWithCurrencyEffect
);
}
}
public processOrderMetrics(
orders: PortfolioOrderItem[],
i: number,
exchangeRates: { [dateString: string]: number },
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const order = orders[i];
this.writeOrderToLogIfNecessary(i, order);
symbolMetricsHelper.exchangeRateAtOrderDate = exchangeRates[order.date];
const value = order.quantity.gt(0)
? order.quantity.mul(order.unitPrice)
: new Big(0);
this.handleNoneBuyAndSellOrders(order, value, symbolMetricsHelper);
this.handleStartOrder(
order,
i,
orders,
symbolMetricsHelper.unitPriceAtStartDate
);
this.handleOrderFee(order, symbolMetricsHelper);
symbolMetricsHelper.unitPrice = this.getUnitPriceAndFillCurrencyDeviations(
order,
symbolMetricsHelper
);
if (order.unitPriceInBaseCurrency) {
symbolMetricsHelper.investmentValueBeforeTransaction =
symbolMetricsHelper.totalUnits.mul(order.unitPriceInBaseCurrency);
symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect =
symbolMetricsHelper.totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
}
this.handleInitialInvestmentValues(symbolMetricsHelper, i, order);
const { transactionInvestment, transactionInvestmentWithCurrencyEffect } =
this.handleBuyAndSellTranscation(order, symbolMetricsHelper);
this.logTransactionValuesIfRequested(
order,
transactionInvestment,
transactionInvestmentWithCurrencyEffect
);
this.updateTotalInvestments(
symbolMetricsHelper,
transactionInvestment,
transactionInvestmentWithCurrencyEffect
);
this.setInitialValueIfNecessary(
symbolMetricsHelper,
transactionInvestment,
transactionInvestmentWithCurrencyEffect
);
this.accumulateFees(symbolMetricsHelper, order);
symbolMetricsHelper.totalUnits = symbolMetricsHelper.totalUnits.plus(
order.quantity.mul(getFactor(order.type))
);
this.fillOrderUnitPricesIfMissing(order, symbolMetricsHelper);
const valueOfInvestment = symbolMetricsHelper.totalUnits.mul(
order.unitPriceInBaseCurrency
);
const valueOfInvestmentWithCurrencyEffect =
symbolMetricsHelper.totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
const valueOfPositionsSold =
order.type === 'SELL'
? order.unitPriceInBaseCurrency.mul(order.quantity)
: new Big(0);
const valueOfPositionsSoldWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect.mul(order.quantity)
: new Big(0);
symbolMetricsHelper.totalValueOfPositionsSold =
symbolMetricsHelper.totalValueOfPositionsSold.plus(valueOfPositionsSold);
symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect =
symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect.plus(
valueOfPositionsSoldWithCurrencyEffect
);
this.handlePerformanceCalculation(
valueOfInvestment,
symbolMetricsHelper,
valueOfInvestmentWithCurrencyEffect,
order
);
symbolMetricsHelper.symbolMetrics.investmentValuesAccumulated[order.date] =
new Big(symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber());
symbolMetricsHelper.symbolMetrics.investmentValuesAccumulatedWithCurrencyEffect[
order.date
] = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber()
);
symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[
order.date
] = (
symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[
order.date
] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
}
public handlePerformanceCalculation(
valueOfInvestment: Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
valueOfInvestmentWithCurrencyEffect: Big,
order: PortfolioOrderItem
) {
this.calculateGrossPerformance(
valueOfInvestment,
symbolMetricsHelper,
valueOfInvestmentWithCurrencyEffect
);
this.calculateNetPerformance(
symbolMetricsHelper,
order,
valueOfInvestment,
valueOfInvestmentWithCurrencyEffect
);
}
public calculateNetPerformance(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
order: PortfolioOrderItem,
valueOfInvestment: Big,
valueOfInvestmentWithCurrencyEffect: Big
) {
symbolMetricsHelper.symbolMetrics.currentValues[order.date] = new Big(
valueOfInvestment
);
symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[
order.date
] = new Big(valueOfInvestmentWithCurrencyEffect);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValues[order.date] =
new Big(symbolMetricsHelper.totalInvestmentFromBuyTransactions);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValuesWithCurrencyEffect[
order.date
] = new Big(
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect
);
symbolMetricsHelper.symbolMetrics.netPerformanceValues[order.date] =
symbolMetricsHelper.symbolMetrics.grossPerformance
.minus(symbolMetricsHelper.grossPerformanceAtStartDate)
.minus(
symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate)
);
symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[
order.date
] = symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect
.minus(symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
symbolMetricsHelper.feesWithCurrencyEffect.minus(
symbolMetricsHelper.feesAtStartDateWithCurrencyEffect
)
);
}
public calculateGrossPerformance(
valueOfInvestment: Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
valueOfInvestmentWithCurrencyEffect: Big
) {
const newGrossPerformance = valueOfInvestment
.minus(symbolMetricsHelper.totalInvestmentFromBuyTransactions)
.plus(symbolMetricsHelper.totalValueOfPositionsSold)
.plus(
symbolMetricsHelper.symbolMetrics.totalDividend.mul(
symbolMetricsHelper.currentExchangeRate
)
)
.plus(
symbolMetricsHelper.symbolMetrics.totalInterest.mul(
symbolMetricsHelper.currentExchangeRate
)
);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect
)
.plus(symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect)
.plus(symbolMetricsHelper.symbolMetrics.totalDividendInBaseCurrency)
.plus(symbolMetricsHelper.symbolMetrics.totalInterestInBaseCurrency);
symbolMetricsHelper.symbolMetrics.grossPerformance = newGrossPerformance;
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
}
public accumulateFees(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
order: PortfolioOrderItem
) {
symbolMetricsHelper.fees = symbolMetricsHelper.fees.plus(
order.feeInBaseCurrency ?? 0
);
symbolMetricsHelper.feesWithCurrencyEffect =
symbolMetricsHelper.feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
}
public updateTotalInvestments(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
transactionInvestment: Big,
transactionInvestmentWithCurrencyEffect: Big
) {
symbolMetricsHelper.symbolMetrics.totalInvestment =
symbolMetricsHelper.symbolMetrics.totalInvestment.plus(
transactionInvestment
);
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
}
public setInitialValueIfNecessary(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
transactionInvestment: Big,
transactionInvestmentWithCurrencyEffect: Big
) {
if (!symbolMetricsHelper.initialValue && transactionInvestment.gt(0)) {
symbolMetricsHelper.initialValue = transactionInvestment;
symbolMetricsHelper.initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
public logTransactionValuesIfRequested(
order: PortfolioOrderItem,
transactionInvestment: Big,
transactionInvestmentWithCurrencyEffect: Big
) {
if (this.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
}
public handleBuyAndSellTranscation(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
switch (order.type) {
case 'BUY':
return this.handleBuyTransaction(order, symbolMetricsHelper);
case 'SELL':
return this.handleSellTransaction(symbolMetricsHelper, order);
default:
return {
transactionInvestment: new Big(0),
transactionInvestmentWithCurrencyEffect: new Big(0)
};
}
}
public handleSellTransaction(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
order: PortfolioOrderItem
) {
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (symbolMetricsHelper.totalUnits.gt(0)) {
transactionInvestment = symbolMetricsHelper.symbolMetrics.totalInvestment
.div(symbolMetricsHelper.totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect
.div(symbolMetricsHelper.totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
return { transactionInvestment, transactionInvestmentWithCurrencyEffect };
}
public handleBuyTransaction(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
const transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
symbolMetricsHelper.totalQuantityFromBuyTransactions =
symbolMetricsHelper.totalQuantityFromBuyTransactions.plus(order.quantity);
symbolMetricsHelper.totalInvestmentFromBuyTransactions =
symbolMetricsHelper.totalInvestmentFromBuyTransactions.plus(
transactionInvestment
);
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect =
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
return { transactionInvestment, transactionInvestmentWithCurrencyEffect };
}
public handleInitialInvestmentValues(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
i: number,
order: PortfolioOrderItem
) {
if (
!symbolMetricsHelper.investmentAtStartDate &&
i >= symbolMetricsHelper.indexOfStartOrder
) {
symbolMetricsHelper.investmentAtStartDate = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber()
);
symbolMetricsHelper.investmentAtStartDateWithCurrencyEffect = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber()
);
symbolMetricsHelper.valueAtStartDate = new Big(
symbolMetricsHelper.investmentValueBeforeTransaction.toNumber()
);
symbolMetricsHelper.valueAtStartDateWithCurrencyEffect = new Big(
symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect.toNumber()
);
}
if (order.itemType === 'start') {
symbolMetricsHelper.feesAtStartDate = symbolMetricsHelper.fees;
symbolMetricsHelper.feesAtStartDateWithCurrencyEffect =
symbolMetricsHelper.feesWithCurrencyEffect;
symbolMetricsHelper.grossPerformanceAtStartDate =
symbolMetricsHelper.symbolMetrics.grossPerformance;
symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect;
}
if (
i >= symbolMetricsHelper.indexOfStartOrder &&
!symbolMetricsHelper.initialValue
) {
if (
i === symbolMetricsHelper.indexOfStartOrder &&
!symbolMetricsHelper.symbolMetrics.totalInvestment.eq(0)
) {
symbolMetricsHelper.initialValue = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber()
);
symbolMetricsHelper.initialValueWithCurrencyEffect = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber()
);
}
}
}
public getSymbolMetricHelperObject(
exchangeRates: { [dateString: string]: number },
start: Date,
end: Date,
marketSymbolMap: { [date: string]: { [symbol: string]: Big } },
symbol: string
): PortfolioCalculatorSymbolMetricsHelperObject {
const symbolMetricsHelper =
new PortfolioCalculatorSymbolMetricsHelperObject();
symbolMetricsHelper.symbolMetrics = this.createEmptySymbolMetrics();
symbolMetricsHelper.currentExchangeRate =
exchangeRates[format(new Date(), DATE_FORMAT)];
symbolMetricsHelper.startDateString = format(start, DATE_FORMAT);
symbolMetricsHelper.endDateString = format(end, DATE_FORMAT);
symbolMetricsHelper.unitPriceAtStartDate =
marketSymbolMap[symbolMetricsHelper.startDateString]?.[symbol];
symbolMetricsHelper.unitPriceAtEndDate =
marketSymbolMap[symbolMetricsHelper.endDateString]?.[symbol];
symbolMetricsHelper.totalUnits = new Big(0);
return symbolMetricsHelper;
}
public getUnitPriceAndFillCurrencyDeviations(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const unitprice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitprice) {
order.unitPriceInBaseCurrency = unitprice.mul(
symbolMetricsHelper.currentExchangeRate ?? 1
);
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitprice.mul(
symbolMetricsHelper.exchangeRateAtOrderDate ?? 1
);
}
return unitprice;
}
public handleOrderFee(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(
symbolMetricsHelper.currentExchangeRate ?? 1
);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
symbolMetricsHelper.exchangeRateAtOrderDate ?? 1
);
}
}
public handleStartOrder(
order: PortfolioOrderItem,
i: number,
orders: PortfolioOrderItem[],
unitPriceAtStartDate: Big.Big
) {
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 =
i === 0 ? orders[i + 1]?.unitPrice : unitPriceAtStartDate;
}
}
public handleNoneBuyAndSellOrders(
order: PortfolioOrderItem,
value: Big.Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const symbolMetricsKey = this.getSymbolMetricsKeyFromOrderType(order.type);
if (symbolMetricsKey) {
this.calculateMetrics(value, symbolMetricsHelper, symbolMetricsKey);
}
}
public getSymbolMetricsKeyFromOrderType(
orderType: PortfolioOrderItem['type']
): keyof SymbolMetrics {
switch (orderType) {
case 'DIVIDEND':
return 'totalDividend';
case 'INTEREST':
return 'totalInterest';
case 'ITEM':
return 'totalValuables';
case 'LIABILITY':
return 'totalLiabilities';
default:
return undefined;
}
}
public calculateMetrics(
value: Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
key: keyof SymbolMetrics
) {
const stringKey = key.toString();
symbolMetricsHelper.symbolMetrics[stringKey] = (
symbolMetricsHelper.symbolMetrics[stringKey] as Big
).plus(value);
if (
Object.keys(symbolMetricsHelper.symbolMetrics).includes(
stringKey + this.baseCurrencySuffix
)
) {
symbolMetricsHelper.symbolMetrics[stringKey + this.baseCurrencySuffix] = (
symbolMetricsHelper.symbolMetrics[
stringKey + this.baseCurrencySuffix
] as Big
).plus(value.mul(symbolMetricsHelper.exchangeRateAtOrderDate ?? 1));
} else {
throw new Error(
`Key ${stringKey + this.baseCurrencySuffix} not found in symbolMetrics`
);
}
}
public writeOrderToLogIfNecessary(i: number, order: PortfolioOrderItem) {
if (this.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
}
public fillOrdersAndSortByTime(
orders: PortfolioOrderItem[],
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
chartDateMap: { [date: string]: boolean },
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } },
symbol: string,
dataSource: DataSource
) {
this.fillOrdersByDate(orders, symbolMetricsHelper.ordersByDate);
this.chartDates ??= Object.keys(chartDateMap).sort();
this.fillOrdersWithDatesFromChartDate(
symbolMetricsHelper,
marketSymbolMap,
symbol,
orders,
dataSource
);
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = this.sortOrdersByTime(orders);
return orders;
}
public sortOrdersByTime(orders: PortfolioOrderItem[]) {
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
return orders;
}
public fillOrdersWithDatesFromChartDate(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } },
symbol: string,
orders: PortfolioOrderItem[],
dataSource: DataSource
) {
let lastUnitPrice: Big;
for (const dateString of this.chartDates) {
if (dateString < symbolMetricsHelper.startDateString) {
continue;
} else if (dateString > symbolMetricsHelper.endDateString) {
break;
}
if (symbolMetricsHelper.ordersByDate[dateString]?.length > 0) {
for (const order of symbolMetricsHelper.ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else {
orders.push(
this.getFakeOrder(
dateString,
dataSource,
symbol,
marketSymbolMap,
lastUnitPrice
)
);
}
const lastOrder = orders.at(-1);
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
}
return lastUnitPrice;
}
public getFakeOrder(
dateString: string,
dataSource: DataSource,
symbol: string,
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } },
lastUnitPrice: Big.Big
): PortfolioOrderItem {
return {
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
};
}
public fillOrdersByDate(
orders: PortfolioOrderItem[],
ordersByDate: { [date: string]: PortfolioOrderItem[] }
) {
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
}
public addSyntheticStartAndEndOrder(
orders: PortfolioOrderItem[],
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
dataSource: DataSource,
symbol: string
) {
orders.push({
date: symbolMetricsHelper.startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: symbolMetricsHelper.unitPriceAtStartDate
});
orders.push({
date: symbolMetricsHelper.endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: symbolMetricsHelper.unitPriceAtEndDate
});
}
public hasNoUnitPriceAtEndOrStartDate(
unitPriceAtEndDate: Big.Big,
unitPriceAtStartDate: Big.Big,
orders: PortfolioOrderItem[],
start: Date
) {
return (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(new Date(orders[0].date), start))
);
}
public createEmptySymbolMetrics(): SymbolMetrics {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
unitPrices: {},
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
private fillOrderUnitPricesIfMissing(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
order.unitPriceInBaseCurrency ??= this.marketSymbolMap[order.date]?.[
order.SymbolProfile.symbol
].mul(symbolMetricsHelper.currentExchangeRate);
order.unitPriceInBaseCurrencyWithCurrencyEffect ??= this.marketSymbolMap[
order.date
]?.[order.SymbolProfile.symbol].mul(
symbolMetricsHelper.exchangeRateAtOrderDate
);
}
}

252
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts

@ -3,27 +3,267 @@ import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { cloneDeep } from 'lodash';
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface';
import { RoiPortfolioCalculatorSymbolMetricsHelper } from './portfolio-calculator-symbolmetrics-helper';
export class RoiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
private chartDates: string[];
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
({
totalFeesWithCurrencyEffect,
currentValueInBaseCurrency,
hasErrors,
totalInvestment,
totalInvestmentWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
netPerformance,
totalTimeWeightedInvestment,
totalTimeWeightedInvestmentWithCurrencyEffect
} = this.calculatePositionMetrics(
currentPosition,
totalFeesWithCurrencyEffect,
currentValueInBaseCurrency,
hasErrors,
totalInvestment,
totalInvestmentWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
netPerformance,
totalTimeWeightedInvestment,
totalTimeWeightedInvestmentWithCurrencyEffect
));
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL', 'STAKE'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROI;
}
protected getSymbolMetrics({}: {
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.');
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
const symbolMetricsHelperClass =
new RoiPortfolioCalculatorSymbolMetricsHelper(
PortfolioCalculator.ENABLE_LOGGING,
marketSymbolMap,
this.chartDates
);
const symbolMetricsHelper =
symbolMetricsHelperClass.getSymbolMetricHelperObject(
exchangeRates,
start,
end,
marketSymbolMap,
symbol
);
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
})
);
if (!orders.length) {
return symbolMetricsHelper.symbolMetrics;
}
if (
symbolMetricsHelperClass.hasNoUnitPriceAtEndOrStartDate(
symbolMetricsHelper.unitPriceAtEndDate,
symbolMetricsHelper.unitPriceAtStartDate,
orders,
start
)
) {
symbolMetricsHelper.symbolMetrics.hasErrors = true;
return symbolMetricsHelper.symbolMetrics;
}
symbolMetricsHelperClass.addSyntheticStartAndEndOrder(
orders,
symbolMetricsHelper,
dataSource,
symbol
);
orders = symbolMetricsHelperClass.fillOrdersAndSortByTime(
orders,
symbolMetricsHelper,
chartDateMap,
marketSymbolMap,
symbol,
dataSource
);
symbolMetricsHelper.indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
symbolMetricsHelper.indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
for (let i = 0; i < orders.length; i++) {
symbolMetricsHelperClass.processOrderMetrics(
orders,
i,
exchangeRates,
symbolMetricsHelper
);
if (i === symbolMetricsHelper.indexOfEndOrder) {
break;
}
}
symbolMetricsHelperClass.handleOverallPerformanceCalculation(
symbolMetricsHelper
);
symbolMetricsHelperClass.calculateNetPerformanceByDateRange(
start,
symbolMetricsHelper
);
return symbolMetricsHelper.symbolMetrics;
}
private calculatePositionMetrics(
currentPosition: TimelinePosition,
totalFeesWithCurrencyEffect: Big,
currentValueInBaseCurrency: Big,
hasErrors: boolean,
totalInvestment: Big,
totalInvestmentWithCurrencyEffect: Big,
grossPerformance: Big,
grossPerformanceWithCurrencyEffect: Big,
netPerformance: Big,
totalTimeWeightedInvestment: Big,
totalTimeWeightedInvestmentWithCurrencyEffect: Big
) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
return {
totalFeesWithCurrencyEffect,
currentValueInBaseCurrency,
hasErrors,
totalInvestment,
totalInvestmentWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
netPerformance,
totalTimeWeightedInvestment,
totalTimeWeightedInvestmentWithCurrencyEffect
};
}
}

13
libs/common/src/lib/types/date-range.type.ts

@ -10,3 +10,16 @@ export type DateRange =
| '5y'
| 'max'
| string; // '2024', '2023', '2022', etc.
export const DateRangeTypes: DateRange[] = [
'1d',
'wtd',
'1w',
'mtd',
'1m',
'3m',
'ytd',
'1y',
'5y',
'max'
];

Loading…
Cancel
Save