From a87f4ca2d0efa26782bdb751d48ffda009ee2636 Mon Sep 17 00:00:00 2001 From: Frederik Gartenmeister Date: Wed, 29 May 2024 22:12:31 +0200 Subject: [PATCH] Feat: No maturity date (#1843) * feat: try no matirirty date * fix: wrongly using notional instead of given price * Revert "fix: wrongly using notional instead of given price" This reverts commit f3b5746e87d2cfc558e950ba7691b0711afd55ad. * fix: external pricing with no maturity * chore: better readability * chore: simpler test util * chore: cleaner error message * fix: tests --- pallets/loans/src/entities/loans.rs | 9 +-- .../loans/src/entities/pricing/external.rs | 22 +++---- .../loans/src/entities/pricing/internal.rs | 9 ++- pallets/loans/src/lib.rs | 4 ++ pallets/loans/src/tests/mock.rs | 3 + .../loans/src/tests/portfolio_valuation.rs | 59 +++++++++++++++++++ pallets/loans/src/tests/util.rs | 22 +++++++ pallets/loans/src/types/mod.rs | 17 ++++-- 8 files changed, 122 insertions(+), 23 deletions(-) diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 0077121f9a..105ecd8ca0 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -223,7 +223,7 @@ impl ActiveLoan { &self.borrower } - pub fn maturity_date(&self) -> Seconds { + pub fn maturity_date(&self) -> Option { self.schedule.maturity.date() } @@ -260,9 +260,10 @@ impl ActiveLoan { ) -> Result { let now = T::Time::now(); match trigger { - WriteOffTrigger::PrincipalOverdue(overdue_secs) => { - Ok(now >= self.maturity_date().ensure_add(*overdue_secs)?) - } + WriteOffTrigger::PrincipalOverdue(overdue_secs) => match self.maturity_date() { + Some(maturity) => Ok(now >= maturity.ensure_add(*overdue_secs)?), + None => Ok(false), + }, WriteOffTrigger::PriceOutdated(secs) => match &self.pricing { ActivePricing::External(pricing) => { Ok(now >= pricing.last_updated(pool_id).ensure_add(*secs)?) diff --git a/pallets/loans/src/entities/pricing/external.rs b/pallets/loans/src/entities/pricing/external.rs index 3730c6d4fc..e9652ac518 100644 --- a/pallets/loans/src/entities/pricing/external.rs +++ b/pallets/loans/src/entities/pricing/external.rs @@ -156,31 +156,31 @@ impl ExternalActivePricing { fn maybe_with_linear_accrual_price( &self, - maturity: Seconds, + maturity: Option, price: T::Balance, price_last_updated: Seconds, ) -> Result { - if self.info.with_linear_pricing { + if let (Some(maturity), true) = (maturity, self.info.with_linear_pricing) { if min(price_last_updated, maturity) == maturity { // We can not have 2 'xs' with different 'y' in a rect. // That only happens at maturity return Ok(self.info.notional); } - Ok(cfg_utils::math::y_coord_in_rect( + return Ok(cfg_utils::math::y_coord_in_rect( (min(price_last_updated, maturity), price), (maturity, self.info.notional), min(T::Time::now(), maturity), - )?) - } else { - Ok(price) + )?); } + + Ok(price) } pub fn current_price( &self, pool_id: T::PoolId, - maturity: Seconds, + maturity: Option, ) -> Result { self.current_price_inner( maturity, @@ -190,7 +190,7 @@ impl ExternalActivePricing { fn current_price_inner( &self, - maturity: Seconds, + maturity: Option, oracle: Option>, ) -> Result { if let Some((oracle_price, oracle_provided_at)) = oracle { @@ -211,7 +211,7 @@ impl ExternalActivePricing { pub fn outstanding_principal( &self, pool_id: T::PoolId, - maturity: Seconds, + maturity: Option, ) -> Result { let price = self.current_price(pool_id, maturity)?; Ok(self.outstanding_quantity.ensure_mul_int(price)?) @@ -229,7 +229,7 @@ impl ExternalActivePricing { pub fn present_value( &self, pool_id: T::PoolId, - maturity: Seconds, + maturity: Option, ) -> Result { self.outstanding_principal(pool_id, maturity) } @@ -237,7 +237,7 @@ impl ExternalActivePricing { pub fn present_value_cached( &self, cache: &BTreeMap>, - maturity: Seconds, + maturity: Option, ) -> Result { let price = self.current_price_inner(maturity, cache.get(&self.info.price_id).copied())?; Ok(self.outstanding_quantity.ensure_mul_int(price)?) diff --git a/pallets/loans/src/entities/pricing/internal.rs b/pallets/loans/src/entities/pricing/internal.rs index 1c285ce261..10ea0d779a 100644 --- a/pallets/loans/src/entities/pricing/internal.rs +++ b/pallets/loans/src/entities/pricing/internal.rs @@ -90,10 +90,13 @@ impl InternalActivePricing { &self, debt: T::Balance, origination_date: Seconds, - maturity_date: Seconds, + maturity_date: Option, ) -> Result { match &self.info.valuation_method { ValuationMethod::DiscountedCashFlow(dcf) => { + let maturity_date = + maturity_date.ok_or(Error::::MaturityDateNeededForValuationMethod)?; + let now = T::Time::now(); Ok(dcf.compute_present_value( debt, @@ -110,7 +113,7 @@ impl InternalActivePricing { pub fn present_value( &self, origination_date: Seconds, - maturity_date: Seconds, + maturity_date: Option, ) -> Result { let debt = self.interest.current_debt()?; self.compute_present_value(debt, origination_date, maturity_date) @@ -120,7 +123,7 @@ impl InternalActivePricing { &self, cache: &Rates, origination_date: Seconds, - maturity_date: Seconds, + maturity_date: Option, ) -> Result where Rates: RateCollection, diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index ee49c8a378..8b592b2eab 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -398,6 +398,10 @@ pub mod pallet { TransferDebtToSameLoan, /// Emits when debt is transfered with different repaid/borrow amounts TransferDebtAmountMismatched, + /// Emits when the loan has no maturity date set, but the valuation + /// method needs one. Making valuation and maturity settings + /// incompatible. + MaturityDateNeededForValuationMethod, } impl From for Error { diff --git a/pallets/loans/src/tests/mock.rs b/pallets/loans/src/tests/mock.rs index c0238546a6..6bcdd32e2a 100644 --- a/pallets/loans/src/tests/mock.rs +++ b/pallets/loans/src/tests/mock.rs @@ -64,6 +64,9 @@ pub const POOL_OTHER_ACCOUNT: AccountId = 100; pub const COLLATERAL_VALUE: Balance = 10000; pub const DEFAULT_INTEREST_RATE: f64 = 0.5; +pub const DEFAULT_DISCOUNT_RATE: f64 = 0.02; +pub const DEFAULT_PROBABILITY_OF_DEFAULT: f64 = 0.1; +pub const DEFAULT_LOSS_GIVEN_DEFAULT: f64 = 0.5; pub const POLICY_PERCENTAGE: f64 = 0.5; pub const POLICY_PENALTY: f64 = 0.5; pub const REGISTER_PRICE_ID: PriceId = 42; diff --git a/pallets/loans/src/tests/portfolio_valuation.rs b/pallets/loans/src/tests/portfolio_valuation.rs index 86b63a558d..a6887adde4 100644 --- a/pallets/loans/src/tests/portfolio_valuation.rs +++ b/pallets/loans/src/tests/portfolio_valuation.rs @@ -270,3 +270,62 @@ fn no_linear_pricing_either_settlement_or_oracle() { expected_portfolio(QUANTITY.saturating_mul_int(MARKET_PRICE_VALUE)); }); } + +#[test] +fn internal_dcf_with_no_maturity() { + new_test_ext().execute_with(|| { + let mut internal = util::dcf_internal_loan(); + internal.schedule.maturity = Maturity::None; + + let loan_id = util::create_loan(LoanInfo { + collateral: ASSET_BA, + ..internal + }); + + MockPools::mock_withdraw(|_, _, _| Ok(())); + + assert_noop!( + Loans::borrow( + RuntimeOrigin::signed(util::borrower(loan_id)), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE), + ), + Error::::MaturityDateNeededForValuationMethod + ); + }); +} + +#[test] +fn internal_oustanding_debt_with_no_maturity() { + new_test_ext().execute_with(|| { + let mut internal = util::base_internal_loan(); + internal.schedule.maturity = Maturity::None; + + let loan_id = util::create_loan(LoanInfo { + collateral: ASSET_BA, + ..internal + }); + util::borrow_loan(loan_id, PrincipalInput::Internal(COLLATERAL_VALUE)); + + config_mocks(); + let pv = util::current_loan_pv(loan_id); + update_portfolio(); + expected_portfolio(pv); + + advance_time(YEAR); + + update_portfolio(); + expected_portfolio( + Rate::from_float(util::interest_for(DEFAULT_INTEREST_RATE, YEAR)) + .checked_mul_int(COLLATERAL_VALUE) + .unwrap(), + ); + + util::repay_loan(loan_id, PrincipalInput::Internal(COLLATERAL_VALUE)); + + config_mocks(); + update_portfolio(); + expected_portfolio(0); + }); +} diff --git a/pallets/loans/src/tests/util.rs b/pallets/loans/src/tests/util.rs index a089150882..c12c180bdc 100644 --- a/pallets/loans/src/tests/util.rs +++ b/pallets/loans/src/tests/util.rs @@ -81,6 +81,28 @@ pub fn base_internal_pricing() -> InternalPricing { } } +pub fn dcf_internal_pricing() -> InternalPricing { + InternalPricing { + collateral_value: COLLATERAL_VALUE, + max_borrow_amount: util::total_borrowed_rate(1.0), + valuation_method: ValuationMethod::DiscountedCashFlow(DiscountedCashFlow { + probability_of_default: Rate::from_float(DEFAULT_PROBABILITY_OF_DEFAULT), + loss_given_default: Rate::from_float(DEFAULT_LOSS_GIVEN_DEFAULT), + discount_rate: InterestRate::Fixed { + rate_per_year: Rate::from_float(DEFAULT_DISCOUNT_RATE), + compounding: CompoundingSchedule::Secondly, + }, + }), + } +} + +pub fn dcf_internal_loan() -> LoanInfo { + LoanInfo { + pricing: Pricing::Internal(dcf_internal_pricing()), + ..base_internal_loan() + } +} + pub fn base_internal_loan() -> LoanInfo { LoanInfo { schedule: RepaymentSchedule { diff --git a/pallets/loans/src/types/mod.rs b/pallets/loans/src/types/mod.rs index 922f3cda39..333b8ae0c9 100644 --- a/pallets/loans/src/types/mod.rs +++ b/pallets/loans/src/types/mod.rs @@ -19,7 +19,7 @@ use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use sp_runtime::{ traits::{EnsureAdd, EnsureAddAssign, EnsureSubAssign}, - ArithmeticError, + ArithmeticError, DispatchError, }; pub mod policy; @@ -95,6 +95,8 @@ pub enum Maturity { /// Extension in secs, without special permissions extension: Seconds, }, + /// No Maturity date + None, } impl Maturity { @@ -102,24 +104,29 @@ impl Maturity { Self::Fixed { date, extension: 0 } } - pub fn date(&self) -> Seconds { + pub fn date(&self) -> Option { match self { - Maturity::Fixed { date, .. } => *date, + Maturity::Fixed { date, .. } => Some(*date), + Maturity::None => None, } } pub fn is_valid(&self, now: Seconds) -> bool { match self { Maturity::Fixed { date, .. } => *date > now, + Maturity::None => true, } } - pub fn extends(&mut self, value: Seconds) -> Result<(), ArithmeticError> { + pub fn extends(&mut self, value: Seconds) -> Result<(), DispatchError> { match self { Maturity::Fixed { date, extension } => { date.ensure_add_assign(value)?; - extension.ensure_sub_assign(value) + extension.ensure_sub_assign(value).map_err(Into::into) } + Maturity::None => Err(DispatchError::Other( + "No maturity date that could be extended.", + )), } } }