add rate calculator
Gitea Actions / Gitea-Actions (push) Successful in 18s

This commit is contained in:
2025-11-03 21:21:33 +03:00
parent a19da9a161
commit 1e17c93c6a
4 changed files with 181 additions and 4 deletions
@@ -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
@@ -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<LocalDate, Rate>
) : 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()
}
@@ -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<out Arguments> {
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)
)
)
}
}