Skip to content

Commit

Permalink
FINERACT-1981: EMI Calculator performance optimization
Browse files Browse the repository at this point in the history
  • Loading branch information
janez89 authored and adamsaghy committed Oct 17, 2024
1 parent c8e6adb commit f94e293
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import lombok.ToString;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.util.Memo;

@ToString(exclude = { "previous" })
@EqualsAndHashCode(exclude = { "previous" })
Expand All @@ -49,6 +50,11 @@ public class RepaymentPeriod {
@Getter
private Money paidInterest;

private Memo<BigDecimal> rateFactorPlus1Calculation;
private Memo<Money> calculatedDueInterestCalculation;
private Memo<Money> dueInterestCalculation;
private Memo<Money> outstandingBalanceCalculation;

public RepaymentPeriod(RepaymentPeriod previous, LocalDate fromDate, LocalDate dueDate, Money emi) {
this.previous = previous;
this.fromDate = fromDate;
Expand Down Expand Up @@ -80,10 +86,25 @@ public Optional<RepaymentPeriod> getPrevious() {
}

public BigDecimal getRateFactorPlus1() {
if (rateFactorPlus1Calculation == null) {
rateFactorPlus1Calculation = Memo.of(this::calculateRateFactorPlus1, () -> this.interestPeriods);
}
return rateFactorPlus1Calculation.get();
}

private BigDecimal calculateRateFactorPlus1() {
return interestPeriods.stream().map(InterestPeriod::getRateFactor).reduce(BigDecimal.ONE, BigDecimal::add);
}

public Money getCalculatedDueInterest() {
if (calculatedDueInterestCalculation == null) {
calculatedDueInterestCalculation = Memo.of(this::calculateCalculatedDueInterest,
() -> new Object[] { this.previous, this.interestPeriods });
}
return calculatedDueInterestCalculation.get();
}

private Money calculateCalculatedDueInterest() {
Money calculatedDueInterest = getInterestPeriods().stream().map(InterestPeriod::getCalculatedDueInterest).reduce(getZero(),
Money::plus);
if (getPrevious().isPresent()) {
Expand All @@ -106,9 +127,13 @@ public boolean isFullyPaid() {
}

public Money getDueInterest() {
// Due interest might be the maximum paid if there is pay-off or early repayment
return MathUtil.max(getPaidPrincipal().isGreaterThan(getCalculatedDuePrincipal()) ? getPaidInterest() : getCalculatedDueInterest(),
getPaidInterest(), false);
if (dueInterestCalculation == null) {
// Due interest might be the maximum paid if there is pay-off or early repayment
dueInterestCalculation = Memo.of(() -> MathUtil.max(
getPaidPrincipal().isGreaterThan(getCalculatedDuePrincipal()) ? getPaidInterest() : getCalculatedDueInterest(),
getPaidInterest(), false), () -> new Object[] { paidPrincipal, paidInterest, interestPeriods });
}
return dueInterestCalculation.get();
}

public Money getDuePrincipal() {
Expand All @@ -121,13 +146,18 @@ public Money getUnrecognizedInterest() {
}

public Money getOutstandingLoanBalance() {
InterestPeriod lastInstallmentPeriod = getInterestPeriods().get(getInterestPeriods().size() - 1);
Money calculatedOutStandingLoanBalance = lastInstallmentPeriod.getOutstandingLoanBalance() //
.plus(lastInstallmentPeriod.getBalanceCorrectionAmount()) //
.plus(lastInstallmentPeriod.getDisbursementAmount()) //
.minus(getDuePrincipal())//
.plus(getPaidPrincipal());//
return MathUtil.negativeToZero(calculatedOutStandingLoanBalance);
if (outstandingBalanceCalculation == null) {
outstandingBalanceCalculation = Memo.of(() -> {
InterestPeriod lastInstallmentPeriod = getInterestPeriods().get(getInterestPeriods().size() - 1);
Money calculatedOutStandingLoanBalance = lastInstallmentPeriod.getOutstandingLoanBalance() //
.plus(lastInstallmentPeriod.getBalanceCorrectionAmount()) //
.plus(lastInstallmentPeriod.getDisbursementAmount()) //
.minus(getDuePrincipal())//
.plus(getPaidPrincipal());//
return MathUtil.negativeToZero(calculatedOutStandingLoanBalance);
}, () -> interestPeriods);
}
return outstandingBalanceCalculation.get();
}

public void addPaidPrincipalAmount(Money paidPrincipal) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.util;

import java.util.function.Supplier;

/**
* Memo (Object value cache) for calculations
*
*/
public final class Memo<T> {

private final Object lock = new Object();
private final Supplier<? extends T> supplier;
private final Supplier<Object> dependenciesGetter;
private final boolean useReferenceCheck;

private volatile T value;
private volatile int[] dependencyHashCodes = new int[0];

private Memo(Supplier<? extends T> supplier, Supplier<Object> dependenciesGetter, boolean useReferenceCheck) {
this.supplier = supplier;
this.dependenciesGetter = dependenciesGetter;
this.useReferenceCheck = useReferenceCheck;
}

public T get() {
Object actualDependencies = dependenciesGetter != null ? dependenciesGetter.get() : null;
if (actualDependencies == null && value != null) {
return value;
}
synchronized (lock) {
if (checkDependencyChangedAndUpdate(actualDependencies)) {
value = supplier.get();
}
}
return value;
}

private boolean checkDependencyChangedAndUpdate(Object actualDependencies) {
if (actualDependencies == null) {
return true;
}
if (actualDependencies instanceof Object[] actualDependencyList) {
boolean isSame = dependencyHashCodes.length == actualDependencyList.length;
int[] actualDependencyHashCodes = new int[actualDependencyList.length];
for (int i = 0; i < actualDependencyList.length; i++) {
actualDependencyHashCodes[i] = getHashCode(actualDependencyList[i]);
if (isSame) {
isSame = dependencyHashCodes[i] == actualDependencyHashCodes[i];
}
}
if (!isSame) {
dependencyHashCodes = actualDependencyHashCodes;
}
return !isSame;
} else {
final int[] actualDependencyHashCodes = { getHashCode(actualDependencies) };
final boolean isSame = dependencyHashCodes.length == actualDependencyHashCodes.length
&& dependencyHashCodes[0] == actualDependencyHashCodes[0];
if (!isSame) {
dependencyHashCodes = actualDependencyHashCodes;
return true;
}
}
return false;
}

private int getHashCode(Object dependency) {
if (dependency == null) {
return 0;
}
return useReferenceCheck ? System.identityHashCode(dependency) : dependency.hashCode();
}

public static <T> Memo<T> of(Supplier<? extends T> supplier) {
return new Memo<>(supplier, null, false);
}

public static <T> Memo<T> of(Supplier<? extends T> supplier, Supplier<Object> dependenciesFunction) {
return new Memo<>(supplier, dependenciesFunction, false);
}

public static <T> Memo<T> of(Supplier<? extends T> supplier, Supplier<Object> dependenciesFunction, boolean useReferenceCheck) {
return new Memo<>(supplier, dependenciesFunction, useReferenceCheck);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
Expand Down Expand Up @@ -180,6 +181,58 @@ public void testEMICalculator_generateInterestScheduleModel() {
Assertions.assertEquals(121, interestScheduleModel.getLoanTermInDays());
}

@Test
@Timeout(1) // seconds
public void testEMICalculation_performance() {

final List<LoanScheduleModelRepaymentPeriod> expectedRepaymentPeriods = new ArrayList<>();

expectedRepaymentPeriods.add(repayment(1, LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1)));
expectedRepaymentPeriods.add(repayment(2, LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)));
expectedRepaymentPeriods.add(repayment(3, LocalDate.of(2024, 3, 1), LocalDate.of(2024, 4, 1)));
expectedRepaymentPeriods.add(repayment(4, LocalDate.of(2024, 4, 1), LocalDate.of(2024, 5, 1)));
expectedRepaymentPeriods.add(repayment(5, LocalDate.of(2024, 5, 1), LocalDate.of(2024, 6, 1)));
expectedRepaymentPeriods.add(repayment(6, LocalDate.of(2024, 6, 1), LocalDate.of(2024, 7, 1)));
expectedRepaymentPeriods.add(repayment(7, LocalDate.of(2024, 7, 1), LocalDate.of(2024, 8, 1)));
expectedRepaymentPeriods.add(repayment(8, LocalDate.of(2024, 8, 1), LocalDate.of(2024, 9, 1)));
expectedRepaymentPeriods.add(repayment(9, LocalDate.of(2024, 9, 1), LocalDate.of(2024, 10, 1)));
expectedRepaymentPeriods.add(repayment(10, LocalDate.of(2024, 10, 1), LocalDate.of(2024, 11, 1)));
expectedRepaymentPeriods.add(repayment(11, LocalDate.of(2024, 11, 1), LocalDate.of(2024, 12, 1)));
expectedRepaymentPeriods.add(repayment(12, LocalDate.of(2024, 12, 1), LocalDate.of(2025, 1, 1)));

final BigDecimal interestRate = BigDecimal.valueOf(7.0);
final Integer installmentAmountInMultiplesOf = null;

Mockito.when(loanProductRelatedDetail.getAnnualNominalInterestRate()).thenReturn(interestRate);
Mockito.when(loanProductRelatedDetail.getDaysInYearType()).thenReturn(DaysInYearType.DAYS_360.getValue());
Mockito.when(loanProductRelatedDetail.getDaysInMonthType()).thenReturn(DaysInMonthType.DAYS_30.getValue());
Mockito.when(loanProductRelatedDetail.getRepaymentPeriodFrequencyType()).thenReturn(PeriodFrequencyType.MONTHS);
Mockito.when(loanProductRelatedDetail.getRepayEvery()).thenReturn(1);
Mockito.when(loanProductRelatedDetail.getCurrency()).thenReturn(monetaryCurrency);

final ProgressiveLoanInterestScheduleModel interestSchedule = emiCalculator.generateInterestScheduleModel(expectedRepaymentPeriods,
loanProductRelatedDetail, installmentAmountInMultiplesOf);

final Money disbursedAmount = toMoney(100.0);
emiCalculator.addDisbursement(interestSchedule, LocalDate.of(2024, 1, 1), disbursedAmount);

Assertions.assertEquals(interestSchedule.getLoanTermInDays(), 366);
Assertions.assertEquals(interestSchedule.repaymentPeriods().size(), 12);

List<RepaymentPeriod> repaymentPeriods = interestSchedule.repaymentPeriods();
for (int i = 0; i < repaymentPeriods.size(); i++) {
final RepaymentPeriod repaymentPeriod = repaymentPeriods.get(i);
Assertions.assertTrue(0 < toDouble(repaymentPeriod.getDuePrincipal().getAmount()));
Assertions.assertTrue(0 < toDouble(repaymentPeriod.getDueInterest().getAmount()));
if (i == repaymentPeriods.size() - 1) {
Assertions.assertEquals(0.0, toDouble(repaymentPeriod.getOutstandingLoanBalance().getAmount()));
} else {
Assertions.assertEquals(8.65, toDouble(repaymentPeriod.getEmi().getAmount()));
Assertions.assertTrue(0 < toDouble(repaymentPeriod.getOutstandingLoanBalance().getAmount()));
}
}
}

@Test
public void testEMICalculation_disbursedAmt100_dayInYears360_daysInMonth30_repayEvery1Month() {

Expand Down

0 comments on commit f94e293

Please sign in to comment.