From 1e17c93c6a113d56ce449e3ba3bef2fbb1313ce6 Mon Sep 17 00:00:00 2001 From: vizitor Date: Mon, 3 Nov 2025 21:21:33 +0300 Subject: [PATCH] add rate calculator --- .gitea/workflows/pushAction.yaml | 5 +- .../kotlin/dev/darktale/RateCalculator.kt | 38 +++++++ src/main/kotlin/dev/darktale/model/Rates.kt | 44 +++++++++ .../darktale/AmountByRateCalculatorTest.kt | 98 +++++++++++++++++++ 4 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/dev/darktale/RateCalculator.kt create mode 100644 src/main/kotlin/dev/darktale/model/Rates.kt create mode 100644 src/test/java/dev/darktale/AmountByRateCalculatorTest.kt diff --git a/.gitea/workflows/pushAction.yaml b/.gitea/workflows/pushAction.yaml index f44c91f..71d68bb 100644 --- a/.gitea/workflows/pushAction.yaml +++ b/.gitea/workflows/pushAction.yaml @@ -24,10 +24,7 @@ jobs: distribution: 'temurin' cache: maven - name: Build with Maven - run: mvn -B clean package - - run: | - echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner." - echo "🖥️ The workflow is now ready to test your code on the runner." + run: mvn -B package - name: List files in the repository run: | ls ${{ gitea.workspace }} diff --git a/src/main/kotlin/dev/darktale/RateCalculator.kt b/src/main/kotlin/dev/darktale/RateCalculator.kt new file mode 100644 index 0000000..b7a2f4d --- /dev/null +++ b/src/main/kotlin/dev/darktale/RateCalculator.kt @@ -0,0 +1,38 @@ +package dev.darktale + +import dev.darktale.model.Rate +import java.math.BigDecimal +import java.time.LocalDate + +class AmountByRateCalculator { + private val leapYearDuration = BigDecimal.valueOf(366L) + private val nonLeapYearDuration = BigDecimal.valueOf(365L) + fun calculate(amount: BigDecimal, rate: Rate, dateFrom: LocalDate, dateTo: LocalDate): BigDecimal { + var result = amount + var curRate = rate.rateOnDate(dateFrom) + while (curRate != null && curRate.since < dateTo) { + val curDateFrom = max(curRate.since, dateFrom) + val curDateTo = min(dateTo, curRate.till) + result *= (BigDecimal.ONE + getRateForPeriod(curRate.rate!!, curDateFrom, curDateTo)) + curRate = rate.rateOnDate(curDateTo.plusDays(1))?.let { + if (it === curRate) null else it + } + } + return result + } + + private fun getRateForPeriod(rate: BigDecimal, dateFrom: LocalDate, dateTo: LocalDate): BigDecimal { + return if (dateFrom.year == dateTo.year) { + rate.setScale(20) * + (dateTo.dayOfYear - dateFrom.dayOfYear + 1).toBigDecimal() / + if (dateFrom.isLeapYear) leapYearDuration else nonLeapYearDuration + } else getRateForPeriod(rate, dateFrom, LocalDate.of(dateFrom.year, 12, 31)) + + getRateForPeriod(rate, LocalDate.of(dateTo.year, 1, 1), dateTo) + } +} + +private fun max(localDate1: LocalDate, localDate2: LocalDate?) = + if (localDate2 == null || localDate1 >= localDate2) localDate1 else localDate2 + +private fun min(localDate1: LocalDate, localDate2: LocalDate?) = + if (localDate2 == null || localDate1 <= localDate2) localDate1 else localDate2 diff --git a/src/main/kotlin/dev/darktale/model/Rates.kt b/src/main/kotlin/dev/darktale/model/Rates.kt new file mode 100644 index 0000000..e2d56ab --- /dev/null +++ b/src/main/kotlin/dev/darktale/model/Rates.kt @@ -0,0 +1,44 @@ +package dev.darktale.model + +import java.math.BigDecimal +import java.time.LocalDate +import java.util.* + +sealed class Rate { + open val rate: BigDecimal? = null + abstract val since: LocalDate + open val till: LocalDate? = null + abstract fun rateOnDate(date: LocalDate): Rate? + + override fun toString() = "Rate($rate since $since ${till?.let { "till $it" }}" +} + +class SimpleRate( + override val rate: BigDecimal, + override val since: LocalDate, + override val till: LocalDate? = null, +) : Rate() { + + constructor(rate: Double, since: LocalDate, till: LocalDate?) : + this(BigDecimal.valueOf(rate), since, till) + + override fun rateOnDate(date: LocalDate) = + if (date >= since && (till == null || date <= till)) this else null +} + +class PeriodicRate( + private val rates: TreeMap +) : Rate() { + + constructor(vararg rates: Rate) : this(TreeMap(rates.associateBy { it.since })) + + override val since: LocalDate + get() = rates.firstKey() + + override fun rateOnDate(date: LocalDate) = + rates.floorEntry(date)?.value?.let { + if (it.till == null || it.till!! >= date) it else null + } + + override fun toString() = rates.values.toString() +} \ No newline at end of file diff --git a/src/test/java/dev/darktale/AmountByRateCalculatorTest.kt b/src/test/java/dev/darktale/AmountByRateCalculatorTest.kt new file mode 100644 index 0000000..9bbe895 --- /dev/null +++ b/src/test/java/dev/darktale/AmountByRateCalculatorTest.kt @@ -0,0 +1,98 @@ +package dev.darktale + +import dev.darktale.model.PeriodicRate +import dev.darktale.model.Rate +import dev.darktale.model.SimpleRate +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.ArgumentsProvider +import org.junit.jupiter.params.provider.ArgumentsSource +import java.math.BigDecimal +import java.time.LocalDate +import java.util.stream.Stream +import kotlin.math.abs +import kotlin.math.pow +import kotlin.test.DefaultAsserter.assertTrue + +class AmountByRateCalculatorTest { + private val amountByRateCalculator = AmountByRateCalculator() + + @ParameterizedTest + @ArgumentsSource(RateProvider::class) + fun testRateCalculator( + rate: Rate, + sourceAmount: BigDecimal, + dateFrom: LocalDate, + dateTo: LocalDate, + expectedAmount: BigDecimal + ) { + val result = amountByRateCalculator.calculate(sourceAmount, rate, dateFrom, dateTo) + val maxDelta = 10.0.pow(-expectedAmount.scale().toDouble()) + assertTrue( + "expected amount was $expectedAmount ± ${ + BigDecimal.valueOf(maxDelta).toPlainString() + }, but actual is $result", + abs((expectedAmount - result).toDouble()) <= maxDelta, + ) + } +} + +class RateProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext): Stream { + val baseAmount = BigDecimal.valueOf(1000L) + val startOfYear = LocalDate.of(2025, 1, 1) + val endOfMonth = LocalDate.of(2025, 1, 31) + val endOfMonth2 = LocalDate.of(2025, 2, 28) + val endOfYear = LocalDate.of(2025, 12, 31) + val middleOfYear = startOfYear.plusDays((endOfYear.dayOfYear / 2).toLong()) + val simpleRate = SimpleRate(0.1, startOfYear, endOfYear) + val periodicRate = PeriodicRate( + SimpleRate(0.1, startOfYear, endOfMonth), + SimpleRate(0.1, endOfMonth.plusDays(1), endOfMonth2) + ) + val decemberOfNonLeapYear = LocalDate.of(2023, 12, 1) + val januaryOfLeapYear = LocalDate.of(2024, 1, 31) + val nonLeapAndLeapYearRate = SimpleRate( + 0.1, + decemberOfNonLeapYear, + januaryOfLeapYear + ) + + return Stream.of( + Arguments.of( + simpleRate, baseAmount, startOfYear, endOfYear, BigDecimal.valueOf(1100L) + ), + Arguments.of( + simpleRate, + baseAmount, + startOfYear, + middleOfYear, + BigDecimal.valueOf(1050.137) + ), + Arguments.of( + periodicRate, + baseAmount, + startOfYear, + endOfMonth2, + BigDecimal.valueOf(1016.2295) + ), + Arguments.of( + periodicRate, + baseAmount, + startOfYear, + middleOfYear, + BigDecimal.valueOf(1016.2295) + ), + Arguments.of( + nonLeapAndLeapYearRate, + baseAmount, + nonLeapAndLeapYearRate.since, + nonLeapAndLeapYearRate.till, + BigDecimal.valueOf(1016.963096) + ) + ) + } + +} +