use std::error::Error as StdError;
use std::fmt;
use std::str::FromStr;
use crate::daycount::{
ActualActualIcma, Business252, DayCount, DayCountError, IcmaError, Thirty360Us, ThirtyE360Isda,
};
use crate::error::{IdentifierError, Interval, ValueError};
use crate::identifier::{Entry, Identifiers};
use crate::numeric;
use crate::time::{Date, Frequency};
pub fn decimal_from_percent(percent: f64) -> Result<f64, RateError> {
let percent = numeric::finite("percent", percent)?;
unit_result("decimal rate", percent / 100.0, percent)
}
pub fn decimal_from_basis_points(basis_points: f64) -> Result<f64, RateError> {
let basis_points = numeric::finite("basis points", basis_points)?;
unit_result("decimal rate", basis_points / 10_000.0, basis_points)
}
pub fn percent_from_decimal(rate: f64) -> Result<f64, RateError> {
let rate = numeric::finite("decimal rate", rate)?;
unit_result("percent", rate * 100.0, rate)
}
pub fn basis_points_from_decimal(rate: f64) -> Result<f64, RateError> {
let rate = numeric::finite("decimal rate", rate)?;
unit_result("basis points", rate * 10_000.0, rate)
}
fn unit_result(quantity: &'static str, value: f64, source: f64) -> Result<f64, RateError> {
if source == 0.0 {
return Ok(0.0);
}
let domain = if source > 0.0 {
Interval::greater_than(0.0)
} else {
Interval::less_than(0.0)
};
Ok(numeric::result_within(quantity, value, domain)?)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[expect(
clippy::exhaustive_enums,
reason = "FS-RATE specifies exactly five methods"
)]
pub enum CompoundingMethod {
Simple,
Compounded,
Continuous,
SimpleThenCompounded,
CompoundedThenSimple,
}
impl CompoundingMethod {
pub const ALL: [Self; 5] = [
Self::Simple,
Self::Compounded,
Self::Continuous,
Self::SimpleThenCompounded,
Self::CompoundedThenSimple,
];
#[must_use]
pub const fn canonical_identifier(self) -> &'static str {
match self {
Self::Simple => "Simple",
Self::Compounded => "Compounded",
Self::Continuous => "Continuous",
Self::SimpleThenCompounded => "SimpleThenCompounded",
Self::CompoundedThenSimple => "CompoundedThenSimple",
}
}
}
impl fmt::Display for CompoundingMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.canonical_identifier())
}
}
impl FromStr for CompoundingMethod {
type Err = IdentifierError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
METHOD_IDENTIFIERS.parse(text)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Compounding {
kind: CompoundingKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum CompoundingKind {
Simple,
Continuous,
Periodic {
method: CompoundingMethod,
frequency: Frequency,
count: u32,
},
}
impl Compounding {
pub const SIMPLE: Self = Self {
kind: CompoundingKind::Simple,
};
pub const CONTINUOUS: Self = Self {
kind: CompoundingKind::Continuous,
};
pub fn compounded(frequency: Frequency) -> Result<Self, RateError> {
Self::periodic(CompoundingMethod::Compounded, frequency)
}
pub fn simple_then_compounded(frequency: Frequency) -> Result<Self, RateError> {
Self::periodic(CompoundingMethod::SimpleThenCompounded, frequency)
}
pub fn compounded_then_simple(frequency: Frequency) -> Result<Self, RateError> {
Self::periodic(CompoundingMethod::CompoundedThenSimple, frequency)
}
fn periodic(method: CompoundingMethod, frequency: Frequency) -> Result<Self, RateError> {
let Some(count) = frequency.events_per_year().filter(|&count| count > 0) else {
return Err(RateError::InvalidFrequency { frequency });
};
Ok(Self {
kind: CompoundingKind::Periodic {
method,
frequency,
count,
},
})
}
#[must_use]
pub const fn method(self) -> CompoundingMethod {
match self.kind {
CompoundingKind::Simple => CompoundingMethod::Simple,
CompoundingKind::Continuous => CompoundingMethod::Continuous,
CompoundingKind::Periodic { method, .. } => method,
}
}
#[must_use]
pub const fn frequency(self) -> Option<Frequency> {
match self.kind {
CompoundingKind::Periodic { frequency, .. } => Some(frequency),
_ => None,
}
}
fn periodic_count(self) -> Option<f64> {
match self.kind {
CompoundingKind::Periodic { count, .. } => Some(f64::from(count)),
_ => None,
}
}
pub(crate) fn active_branch(self, time: f64) -> ActiveBranch {
match self.kind {
CompoundingKind::Simple => ActiveBranch::Simple,
CompoundingKind::Continuous => ActiveBranch::Continuous,
CompoundingKind::Periodic { method, count, .. } => {
let threshold = 1.0 / f64::from(count);
match method {
CompoundingMethod::SimpleThenCompounded if time <= threshold => {
ActiveBranch::Simple
}
CompoundingMethod::CompoundedThenSimple if time > threshold => {
ActiveBranch::Simple
}
_ => ActiveBranch::Periodic(count),
}
}
}
}
fn phase(self, time: f64) -> Phase {
match self.active_branch(time) {
ActiveBranch::Simple => Phase::Simple,
ActiveBranch::Continuous => Phase::Continuous,
ActiveBranch::Periodic(count) => Phase::Periodic(f64::from(count)),
}
}
}
impl fmt::Display for Compounding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.method().fmt(f)?;
if let Some(frequency) = self.frequency() {
write!(f, "({frequency})")?;
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum ActiveBranch {
Simple,
Continuous,
Periodic(u32),
}
#[derive(Clone, Copy, PartialEq)]
enum Phase {
Simple,
Continuous,
Periodic(f64),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum AccrualConvention {
Scalar(DayCount),
Icma(ActualActualIcma),
ThirtyE360Isda(ThirtyE360Isda),
Thirty360Us(Thirty360Us),
Business252(Business252),
}
impl AccrualConvention {
pub fn year_fraction(&self, start: Date, end: Date) -> Result<f64, RateError> {
match self {
Self::Scalar(value) => Ok(value.year_fraction(start, end)?),
Self::Icma(value) => Ok(value.year_fraction(start, end)?),
Self::ThirtyE360Isda(value) => Ok(value.year_fraction(start, end)?),
Self::Thirty360Us(value) => Ok(value.year_fraction(start, end)?),
Self::Business252(value) => Ok(value.year_fraction(start, end)?),
}
}
}
impl From<DayCount> for AccrualConvention {
fn from(value: DayCount) -> Self {
Self::Scalar(value)
}
}
impl From<ActualActualIcma> for AccrualConvention {
fn from(value: ActualActualIcma) -> Self {
Self::Icma(value)
}
}
impl From<ThirtyE360Isda> for AccrualConvention {
fn from(value: ThirtyE360Isda) -> Self {
Self::ThirtyE360Isda(value)
}
}
impl From<Thirty360Us> for AccrualConvention {
fn from(value: Thirty360Us) -> Self {
Self::Thirty360Us(value)
}
}
impl From<Business252> for AccrualConvention {
fn from(value: Business252) -> Self {
Self::Business252(value)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct InterestRate {
rate: f64,
accrual: AccrualConvention,
compounding: Compounding,
}
impl InterestRate {
pub fn new(
rate: f64,
accrual: impl Into<AccrualConvention>,
compounding: Compounding,
) -> Result<Self, RateError> {
let rate = validate_rate(rate, compounding)?;
Ok(Self {
rate,
accrual: accrual.into(),
compounding,
})
}
#[must_use]
pub const fn rate(&self) -> f64 {
self.rate
}
#[must_use]
pub const fn accrual(&self) -> &AccrualConvention {
&self.accrual
}
#[must_use]
pub const fn compounding(&self) -> Compounding {
self.compounding
}
#[doc(alias = "compoundFactor")]
pub fn compound_factor(&self, time: f64) -> Result<f64, RateError> {
compound_factor_at(self.rate, self.compounding, time)
}
#[doc(alias = "discountFactor")]
pub fn discount_factor(&self, time: f64) -> Result<f64, RateError> {
discount_factor_at(self.rate, self.compounding, time)
}
#[doc(alias = "compoundFactor")]
pub fn compound_factor_between(&self, start: Date, end: Date) -> Result<f64, RateError> {
self.compound_factor(self.date_time(start, end)?)
}
#[doc(alias = "discountFactor")]
pub fn discount_factor_between(&self, start: Date, end: Date) -> Result<f64, RateError> {
self.discount_factor(self.date_time(start, end)?)
}
fn date_time(&self, start: Date, end: Date) -> Result<f64, RateError> {
require_order(start, end)?;
time_input(self.accrual.year_fraction(start, end)?)
}
#[expect(
clippy::float_cmp,
reason = "zero-time inference requires exactly the supplied unit factor, not approximate closeness"
)]
#[doc(alias = "impliedRate")]
pub fn implied_rate(
factor: f64,
time: f64,
accrual: impl Into<AccrualConvention>,
compounding: Compounding,
) -> Result<Self, RateError> {
let factor = numeric::within("compound factor", factor, Interval::greater_than(0.0))?;
let time = time_input(time)?;
if time == 0.0 {
if factor != 1.0 {
return Err(RateError::ZeroTime {
quantity: "compound factor",
factor,
});
}
return Self::new(0.0, accrual, compounding);
}
let log_growth = factor.ln();
let rate = if matches!(compounding.phase(time), Phase::Simple) {
(factor - 1.0) / time
} else {
rate_from_annual(log_growth / time, time, compounding.phase(time))
};
let rate = rate_result("implied rate", rate, log_growth, compounding, time)?;
Self::new(rate, accrual, compounding)
}
#[expect(
clippy::float_cmp,
reason = "zero-time inference requires exactly the supplied unit discount, not approximate closeness"
)]
pub fn implied_rate_from_discount(
discount: f64,
time: f64,
accrual: impl Into<AccrualConvention>,
compounding: Compounding,
) -> Result<Self, RateError> {
let discount = numeric::within("discount factor", discount, Interval::greater_than(0.0))?;
let time = time_input(time)?;
if time == 0.0 {
if discount != 1.0 {
return Err(RateError::ZeroTime {
quantity: "discount factor",
factor: discount,
});
}
return Self::new(0.0, accrual, compounding);
}
let log_growth = -discount.ln();
let rate = if matches!(compounding.phase(time), Phase::Simple) {
if discount < 0.5 {
((1.0 - discount) / time) / discount
} else {
((1.0 - discount) / discount) / time
}
} else {
rate_from_annual(log_growth / time, time, compounding.phase(time))
};
let rate = rate_result("implied rate", rate, log_growth, compounding, time)?;
Self::new(rate, accrual, compounding)
}
#[doc(alias = "equivalentRate")]
pub fn equivalent_rate(&self, target: Compounding, time: f64) -> Result<Self, RateError> {
let time = time_input(time)?;
if time == 0.0 || self.rate == 0.0 {
return Self::new(0.0, self.accrual.clone(), target);
}
validate_simple_domain(self.rate, time, self.compounding.phase(time))?;
if target.phase(time) == self.compounding.phase(time) {
let rate = rate_result("equivalent rate", self.rate, self.rate, target, time)?;
return Self::new(rate, self.accrual.clone(), target);
}
let growth = annual_growth(self.rate, time, self.compounding.phase(time))?;
let rate = rate_from_annual(growth, time, target.phase(time));
let rate = rate_result("equivalent rate", rate, self.rate, target, time)?;
Self::new(rate, self.accrual.clone(), target)
}
#[doc(alias = "equivalentRate")]
pub fn equivalent_rate_between(
&self,
accrual: impl Into<AccrualConvention>,
target: Compounding,
start: Date,
end: Date,
) -> Result<Self, RateError> {
let source_time = self.date_time(start, end)?;
let accrual = accrual.into();
let target_time = time_input(accrual.year_fraction(start, end)?)?;
if source_time == 0.0 || self.rate == 0.0 {
return Self::new(0.0, accrual, target);
}
if target_time == 0.0 {
return Err(RateError::ZeroTargetTime {
source_time,
rate: self.rate,
});
}
validate_simple_domain(self.rate, source_time, self.compounding.phase(source_time))?;
if source_time.to_bits() == target_time.to_bits() {
let rate = self.equivalent_rate(target, source_time)?.rate();
return Self::new(rate, accrual, target);
}
let growth = annual_growth(self.rate, source_time, self.compounding.phase(source_time))?;
let target_growth = growth * (source_time / target_time);
let rate = rate_from_annual(target_growth, target_time, target.phase(target_time));
let rate = rate_result("equivalent rate", rate, self.rate, target, target_time)?;
Self::new(rate, accrual, target)
}
pub fn present_value(&self, amount: f64, time: f64) -> Result<f64, RateError> {
present_value_at(self.rate, self.compounding, amount, time)
}
pub fn present_value_between(
&self,
amount: f64,
start: Date,
end: Date,
) -> Result<f64, RateError> {
self.present_value(amount, self.date_time(start, end)?)
}
}
pub(crate) fn validate_rate(rate: f64, compounding: Compounding) -> Result<f64, RateError> {
let rate = numeric::finite("rate", rate)?;
if let Some(count) = compounding.periodic_count() {
numeric::within("rate", rate, Interval::greater_than(-count))?;
}
Ok(canonical_zero(rate))
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct PresentValueObservation {
pub(crate) branch: ActiveBranch,
pub(crate) present_value: f64,
pub(crate) simple_compound_factor: f64,
}
fn compound_and_branch_at(
rate: f64,
compounding: Compounding,
time: f64,
) -> Result<(f64, ActiveBranch), RateError> {
let time = time_input(time)?;
let branch = compounding.active_branch(time);
if time == 0.0 || rate == 0.0 {
return Ok((1.0, branch));
}
let value = match branch {
ActiveBranch::Simple => rate.mul_add(time, 1.0),
ActiveBranch::Continuous => exponential_factor(rate * time)?,
ActiveBranch::Periodic(count) => {
exponential_factor(periodic_growth(rate, f64::from(count)) * time)?
}
};
let factor = numeric::result_within("compound factor", value, Interval::greater_than(0.0))?;
Ok((factor, branch))
}
fn discount_and_branch_at(
rate: f64,
compounding: Compounding,
time: f64,
) -> Result<(f64, f64, ActiveBranch), RateError> {
let (factor, branch) = compound_and_branch_at(rate, compounding, time)?;
if time != 0.0 && rate != 0.0 && !matches!(branch, ActiveBranch::Simple) {
require_resolved_boundary("discount factor", -factor.ln(), f64::MAX.ln())?;
}
let discount =
numeric::result_within("discount factor", 1.0 / factor, Interval::greater_than(0.0))?;
Ok((discount, factor, branch))
}
pub(crate) fn compound_factor_at(
rate: f64,
compounding: Compounding,
time: f64,
) -> Result<f64, RateError> {
Ok(compound_and_branch_at(rate, compounding, time)?.0)
}
pub(crate) fn discount_factor_at(
rate: f64,
compounding: Compounding,
time: f64,
) -> Result<f64, RateError> {
Ok(discount_and_branch_at(rate, compounding, time)?.0)
}
pub(crate) fn present_value_at(
rate: f64,
compounding: Compounding,
amount: f64,
time: f64,
) -> Result<f64, RateError> {
Ok(observe_present_value_at(rate, compounding, amount, time)?.present_value)
}
pub(crate) fn observe_present_value_at(
rate: f64,
compounding: Compounding,
amount: f64,
time: f64,
) -> Result<PresentValueObservation, RateError> {
let amount = numeric::finite("amount", amount)?;
let (discount, compound_factor, branch) = discount_and_branch_at(rate, compounding, time)?;
let value = amount * discount;
let present_value = if amount == 0.0 {
0.0
} else {
let domain = if amount > 0.0 {
Interval::greater_than(0.0)
} else {
Interval::less_than(0.0)
};
numeric::result_within("present value", value, domain)?
};
Ok(PresentValueObservation {
branch,
present_value,
simple_compound_factor: compound_factor,
})
}
fn canonical_zero(value: f64) -> f64 {
if value == 0.0 { 0.0 } else { value }
}
fn time_input(time: f64) -> Result<f64, RateError> {
Ok(canonical_zero(numeric::within(
"time",
time,
Interval::at_least(0.0),
)?))
}
fn require_order(start: Date, end: Date) -> Result<(), RateError> {
if start > end {
Err(RateError::ReversedDates { start, end })
} else {
Ok(())
}
}
fn periodic_growth(rate: f64, count: f64) -> f64 {
let scaled = rate / count;
if scaled.abs() < f64::EPSILON {
rate
} else if scaled <= -0.5 {
count * ((count + rate) / count).ln()
} else {
count * scaled.ln_1p()
}
}
fn exponential_factor(logarithm: f64) -> Result<f64, RateError> {
require_resolved_boundary("compound factor", logarithm, f64::MAX.ln())?;
let zero_boundary = f64::from_bits(1).ln() - std::f64::consts::LN_2;
require_resolved_boundary("compound factor", logarithm, zero_boundary)?;
Ok(logarithm.exp())
}
fn require_resolved_boundary(
quantity: &'static str,
logarithm: f64,
boundary: f64,
) -> Result<(), RateError> {
let allowance = 8.0 * (f64::EPSILON / 2.0) * (1.0 + logarithm.abs());
if logarithm.is_finite() && (logarithm - boundary).abs() <= allowance {
Err(RateError::NumericalBoundary {
quantity,
logarithm,
})
} else {
Ok(())
}
}
fn validate_simple_domain(rate: f64, time: f64, phase: Phase) -> Result<(), RateError> {
if matches!(phase, Phase::Simple) {
let factor = rate.mul_add(time, 1.0);
if factor <= 0.0 {
numeric::result_within("compound factor", factor, Interval::greater_than(0.0))?;
}
}
Ok(())
}
fn annual_growth(rate: f64, time: f64, phase: Phase) -> Result<f64, RateError> {
let growth = match phase {
Phase::Continuous => rate,
Phase::Periodic(count) => periodic_growth(rate, count),
Phase::Simple => {
let product = rate * time;
if product.abs() < f64::EPSILON {
return Ok(rate);
}
let log = if product.is_infinite() {
rate.ln() + time.ln()
} else if product <= -0.5 {
rate.mul_add(time, 1.0).ln()
} else {
product.ln_1p()
};
log / time
}
};
Ok(numeric::finite_result("annual log growth", growth)?)
}
fn rate_from_annual(growth: f64, time: f64, phase: Phase) -> f64 {
match phase {
Phase::Continuous => growth,
Phase::Periodic(count) => {
let scaled = growth / count;
if scaled.abs() < f64::EPSILON {
growth
} else {
count * scaled.exp_m1()
}
}
Phase::Simple => {
let log_growth = growth * time;
if log_growth.abs() < f64::EPSILON {
growth
} else {
simple_rate(log_growth, time)
}
}
}
}
fn simple_rate(log_growth: f64, time: f64) -> f64 {
let increment = log_growth.exp_m1();
if increment.is_infinite() && log_growth > 0.0 && time > 1.0 {
(log_growth - time.ln()).exp() - 1.0 / time
} else {
increment / time
}
}
fn rate_result(
quantity: &'static str,
rate: f64,
sign: f64,
compounding: Compounding,
time: f64,
) -> Result<f64, RateError> {
let rate = numeric::finite_result(quantity, rate)?;
if sign > 0.0 {
numeric::result_within(quantity, rate, Interval::greater_than(0.0))?;
} else if sign < 0.0 {
numeric::result_within(quantity, rate, Interval::less_than(0.0))?;
}
if let Some(count) = compounding.periodic_count() {
numeric::result_within(quantity, rate, Interval::greater_than(-count))?;
}
validate_simple_domain(rate, time, compounding.phase(time))?;
Ok(canonical_zero(rate))
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum RateError {
Value(ValueError),
DayCount(DayCountError),
Icma(IcmaError),
#[non_exhaustive]
NumericalBoundary {
quantity: &'static str,
logarithm: f64,
},
#[non_exhaustive]
InvalidFrequency {
frequency: Frequency,
},
#[non_exhaustive]
ReversedDates {
start: Date,
end: Date,
},
#[non_exhaustive]
ZeroTime {
quantity: &'static str,
factor: f64,
},
#[non_exhaustive]
ZeroTargetTime {
source_time: f64,
rate: f64,
},
}
impl RateError {
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Value(_) => "rate.value",
Self::DayCount(_) => "rate.day_count",
Self::Icma(_) => "rate.icma",
Self::NumericalBoundary { .. } => "rate.numerical_boundary",
Self::InvalidFrequency { .. } => "rate.invalid_frequency",
Self::ReversedDates { .. } => "rate.reversed_dates",
Self::ZeroTime { .. } => "rate.zero_time",
Self::ZeroTargetTime { .. } => "rate.zero_target_time",
}
}
}
impl fmt::Display for RateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Value(error) => error.fmt(f),
Self::DayCount(error) => error.fmt(f),
Self::Icma(error) => error.fmt(f),
Self::NumericalBoundary {
quantity,
logarithm,
} => write!(
f,
"{quantity} range is unresolved near a binary64 boundary (logarithm {logarithm})"
),
Self::InvalidFrequency { frequency } => {
write!(f, "{frequency} is not a periodic compounding frequency")
}
Self::ReversedDates { start, end } => write!(
f,
"rate valuation requires start {start} not after end {end}"
),
Self::ZeroTime { quantity, factor } => {
write!(f, "zero time cannot imply {quantity} {factor}")
}
Self::ZeroTargetTime { source_time, rate } => write!(
f,
"zero target time cannot preserve source rate {rate} over horizon {source_time}"
),
}
}
}
impl StdError for RateError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
Self::Value(error) => Some(error),
Self::DayCount(error) => Some(error),
Self::Icma(error) => Some(error),
_ => None,
}
}
}
impl From<ValueError> for RateError {
fn from(value: ValueError) -> Self {
Self::Value(value)
}
}
impl From<DayCountError> for RateError {
fn from(value: DayCountError) -> Self {
Self::DayCount(value)
}
}
impl From<IcmaError> for RateError {
fn from(value: IcmaError) -> Self {
Self::Icma(value)
}
}
const METHOD_IDENTIFIERS: Identifiers<CompoundingMethod> = Identifiers::new(
"compounding method",
&[
Entry {
value: CompoundingMethod::Simple,
canonical: "Simple",
aliases: &[],
},
Entry {
value: CompoundingMethod::Compounded,
canonical: "Compounded",
aliases: &[],
},
Entry {
value: CompoundingMethod::Continuous,
canonical: "Continuous",
aliases: &[],
},
Entry {
value: CompoundingMethod::SimpleThenCompounded,
canonical: "SimpleThenCompounded",
aliases: &[],
},
Entry {
value: CompoundingMethod::CompoundedThenSimple,
canonical: "CompoundedThenSimple",
aliases: &[],
},
],
&[],
);