diff --git a/src/main/scala/sfc/agents/Nbfi.scala b/src/main/scala/sfc/agents/Nbfi.scala index 4d2002d..8431a48 100644 --- a/src/main/scala/sfc/agents/Nbfi.scala +++ b/src/main/scala/sfc/agents/Nbfi.scala @@ -7,91 +7,137 @@ import sfc.types.* * fintech). */ object Nbfi: + + // --------------------------------------------------------------------------- + // Named constants + // --------------------------------------------------------------------------- + + private val NplTightnessFloor = 0.03 // NPL ratio below which bank tightness = 0 + private val NplTightnessRange = 0.03 // NPL range over which tightness rises from 0 → 1 + private val UnempDefaultThreshold = 0.05 // unemployment rate below which no cyclical default add-on + private val ExcessReturnSens = 5.0 // TFI inflow sensitivity to excess fund vs deposit return + private val ExcessReturnCap = 0.05 // cap on absolute excess return signal + private val MonthsPerYear = 12.0 + + // --------------------------------------------------------------------------- + // State + // --------------------------------------------------------------------------- + case class State( // TFI component - tfiAum: PLN, // Total AUM - tfiGovBondHoldings: PLN, // Gov bonds (target share) - tfiCorpBondHoldings: PLN, // Corp bonds (target share) - tfiEquityHoldings: PLN, // Equities (target share) - tfiCashHoldings: PLN, // Cash/money market (residual) + tfiAum: PLN, // total assets under management + tfiGovBondHoldings: PLN, // gov bonds (target share) + tfiCorpBondHoldings: PLN, // corp bonds (target share) + tfiEquityHoldings: PLN, // equities (target share) + tfiCashHoldings: PLN, // cash/money market (residual) // NBFI credit component (leasing + fintech) - nbfiLoanStock: PLN, // Outstanding NBFI loans + nbfiLoanStock: PLN, // outstanding NBFI loans // Flow tracking - lastTfiNetInflow: PLN = PLN.Zero, // HH net fund purchases - lastNbfiOrigination: PLN = PLN.Zero, // Monthly new NBFI credit - lastNbfiRepayment: PLN = PLN.Zero, // Monthly principal repaid - lastNbfiDefaultAmount: PLN = PLN.Zero, // Monthly gross defaults - lastNbfiInterestIncome: PLN = PLN.Zero, // NBFI interest earned - lastBankTightness: Ratio = Ratio.Zero, // Counter-cyclical signal - lastDepositDrain: PLN = PLN.Zero, // Net deposit outflow (TFI inflow) + lastTfiNetInflow: PLN, // HH net fund purchases this month + lastNbfiOrigination: PLN, // monthly new NBFI credit + lastNbfiRepayment: PLN, // monthly principal repaid + lastNbfiDefaultAmount: PLN, // monthly gross defaults + lastNbfiInterestIncome: PLN, // NBFI interest earned this month + lastBankTightness: Ratio, // counter-cyclical signal [0,1] + lastDepositDrain: PLN, // net deposit outflow (TFI inflow) ) - def zero: State = State(PLN.Zero, PLN.Zero, PLN.Zero, PLN.Zero, PLN.Zero, PLN.Zero) + object State: + val zero: State = State( + PLN.Zero, + PLN.Zero, + PLN.Zero, + PLN.Zero, + PLN.Zero, + PLN.Zero, + PLN.Zero, + PLN.Zero, + PLN.Zero, + PLN.Zero, + PLN.Zero, + Ratio.Zero, + PLN.Zero, + ) + /** Initialize from SimParams calibration. */ def initial(using p: SimParams): State = - val aum = PLN(p.nbfi.tfiInitAum.toDouble) + val aum = p.nbfi.tfiInitAum State( tfiAum = aum, tfiGovBondHoldings = aum * p.nbfi.tfiGovBondShare.toDouble, tfiCorpBondHoldings = aum * p.nbfi.tfiCorpBondShare.toDouble, tfiEquityHoldings = aum * p.nbfi.tfiEquityShare.toDouble, tfiCashHoldings = aum * (1.0 - p.nbfi.tfiGovBondShare.toDouble - p.nbfi.tfiCorpBondShare.toDouble - p.nbfi.tfiEquityShare.toDouble), - nbfiLoanStock = PLN(p.nbfi.creditInitStock.toDouble), + nbfiLoanStock = p.nbfi.creditInitStock, + lastTfiNetInflow = PLN.Zero, + lastNbfiOrigination = PLN.Zero, + lastNbfiRepayment = PLN.Zero, + lastNbfiDefaultAmount = PLN.Zero, + lastNbfiInterestIncome = PLN.Zero, + lastBankTightness = Ratio.Zero, + lastDepositDrain = PLN.Zero, ) - /** Bank credit tightness signal: 0 at NPL <= 3%, rises linearly, 1.0 at 6%. - */ - def bankTightness(bankNplRatio: Double): Double = - Math.max(0.0, Math.min(1.0, (bankNplRatio - 0.03) / 0.03)) + // --------------------------------------------------------------------------- + // Pure helper functions + // --------------------------------------------------------------------------- + + /** Bank credit tightness signal: 0 at NPL ≤ 3%, rises linearly, 1.0 at 6%. */ + def bankTightness(bankNplRatio: Ratio): Ratio = + Ratio(Math.max(0.0, Math.min(1.0, (bankNplRatio.toDouble - NplTightnessFloor) / NplTightnessRange))) /** TFI net inflow: proportional to wage bill, modulated by excess returns. */ - def tfiInflow(employed: Int, wage: Double, equityReturn: Double, govBondYield: Double, depositRate: Double)(using + def tfiInflow(employed: Int, wage: PLN, equityReturn: Rate, govBondYield: Rate, depositRate: Rate)(using p: SimParams, - ): Double = - val wageBill = employed.toDouble * wage + ): PLN = + val wageBill = wage * employed.toDouble val base = wageBill * p.nbfi.tfiInflowRate.toDouble // Excess return: weighted avg of fund returns vs deposit rate - val fundReturn = govBondYield * p.nbfi.tfiGovBondShare.toDouble + - equityReturn * 12.0 * p.nbfi.tfiEquityShare.toDouble + - govBondYield * p.nbfi.tfiCorpBondShare.toDouble // proxy: corp ~ gov yield - val excessReturn = Math.max(-0.05, Math.min(0.05, fundReturn - depositRate)) - base * (1.0 + excessReturn * 5.0) + val fundReturn = govBondYield.toDouble * p.nbfi.tfiGovBondShare.toDouble + + equityReturn.toDouble * MonthsPerYear * p.nbfi.tfiEquityShare.toDouble + + govBondYield.toDouble * p.nbfi.tfiCorpBondShare.toDouble // proxy: corp ~ gov yield + val excessReturn = Math.max(-ExcessReturnCap, Math.min(ExcessReturnCap, fundReturn - depositRate.toDouble)) + base * (1.0 + excessReturn * ExcessReturnSens) /** NBFI credit origination: counter-cyclical to bank tightness. */ - def nbfiOrigination(domesticCons: Double, bankNplRatio: Double)(using p: SimParams): Double = + def nbfiOrigination(domesticCons: PLN, bankNplRatio: Ratio)(using p: SimParams): PLN = val tight = bankTightness(bankNplRatio) - domesticCons * p.nbfi.creditBaseRate.toDouble * (1.0 + p.nbfi.countercyclical * tight) + domesticCons * p.nbfi.creditBaseRate.toDouble * (1.0 + p.nbfi.countercyclical * tight.toDouble) /** NBFI loan repayment: stock / maturity. */ - def nbfiRepayment(loanStock: Double)(using p: SimParams): Double = + def nbfiRepayment(loanStock: PLN)(using p: SimParams): PLN = loanStock / p.nbfi.creditMaturity - /** NBFI defaults: base rate widening with unemployment (sensitivity 3.0). */ - def nbfiDefaults(loanStock: Double, unempRate: Double)(using p: SimParams): Double = - loanStock * p.nbfi.defaultBase.toDouble * (1.0 + p.nbfi.defaultUnempSens * Math.max(0.0, unempRate - 0.05)) + /** NBFI defaults: base rate widening with unemployment. */ + def nbfiDefaults(loanStock: PLN, unempRate: Ratio)(using p: SimParams): PLN = + loanStock * p.nbfi.defaultBase.toDouble * (1.0 + p.nbfi.defaultUnempSens * Math.max(0.0, unempRate.toDouble - UnempDefaultThreshold)) + + // --------------------------------------------------------------------------- + // Monthly step + // --------------------------------------------------------------------------- - /** Full monthly step: TFI inflow -> investment income -> rebalance; NBFI - * credit flows. + /** Full monthly step: TFI inflow → investment income → rebalance; NBFI credit + * flows. */ def step( prev: State, - employed: Int, - wage: Double, - priceLevel: Double, - unempRate: Double, - bankNplRatio: Double, - govBondYield: Double, - corpBondYield: Double, - equityReturn: Double, - depositRate: Double, - domesticCons: Double, + employed: Int, // employed workers + wage: PLN, // average monthly wage + priceLevel: Double, // CPI price level (unused in current spec, kept for interface stability) + unempRate: Ratio, // unemployment rate + bankNplRatio: Ratio, // aggregate bank NPL ratio (tightness signal) + govBondYield: Rate, // government bond yield (annualised) + corpBondYield: Rate, // corporate bond yield (annualised) + equityReturn: Rate, // equity monthly return + depositRate: Rate, // bank deposit rate (TFI opportunity cost) + domesticCons: PLN, // domestic consumption (NBFI credit base) )(using p: SimParams): State = // TFI: inflow + investment income + rebalance val netInflow = tfiInflow(employed, wage, equityReturn, govBondYield, depositRate) - val invIncome = prev.tfiGovBondHoldings * govBondYield / 12.0 + - prev.tfiCorpBondHoldings * corpBondYield / 12.0 + - prev.tfiEquityHoldings * equityReturn - val newAum = (prev.tfiAum + PLN(netInflow) + invIncome).max(PLN.Zero) + val invIncome = prev.tfiGovBondHoldings * govBondYield.toDouble / MonthsPerYear + + prev.tfiCorpBondHoldings * corpBondYield.toDouble / MonthsPerYear + + prev.tfiEquityHoldings * equityReturn.toDouble + val newAum = (prev.tfiAum + netInflow + invIncome).max(PLN.Zero) // Rebalance towards target allocation val s = p.nbfi.tfiRebalanceSpeed.toDouble @@ -103,16 +149,16 @@ object Nbfi: val newEq = prev.tfiEquityHoldings + (targetEq - prev.tfiEquityHoldings) * s val newCash = newAum - newGov - newCorp - newEq - // Deposit drain: HH buys fund units -> deposits decrease - val depositDrain = PLN(-netInflow) + // Deposit drain: HH buys fund units → deposits decrease + val depositDrain = -netInflow - // NBFI credit: counter-cyclical origination -> repayment -> defaults + // NBFI credit: counter-cyclical origination → repayment → defaults val tight = bankTightness(bankNplRatio) val origination = nbfiOrigination(domesticCons, bankNplRatio) - val repayment = nbfiRepayment(prev.nbfiLoanStock.toDouble) - val defaults = nbfiDefaults(prev.nbfiLoanStock.toDouble, unempRate) - val newLoanStock = (prev.nbfiLoanStock + PLN(origination) - PLN(repayment) - PLN(defaults)).max(PLN.Zero) - val interestIncome = prev.nbfiLoanStock * p.nbfi.creditRate.toDouble / 12.0 + val repayment = nbfiRepayment(prev.nbfiLoanStock) + val defaults = nbfiDefaults(prev.nbfiLoanStock, unempRate) + val newLoanStock = (prev.nbfiLoanStock + origination - repayment - defaults).max(PLN.Zero) + val interestIncome = prev.nbfiLoanStock * p.nbfi.creditRate.toDouble / MonthsPerYear State( tfiAum = newAum, @@ -121,11 +167,11 @@ object Nbfi: tfiEquityHoldings = newEq, tfiCashHoldings = newCash, nbfiLoanStock = newLoanStock, - lastTfiNetInflow = PLN(netInflow), - lastNbfiOrigination = PLN(origination), - lastNbfiRepayment = PLN(repayment), - lastNbfiDefaultAmount = PLN(defaults), + lastTfiNetInflow = netInflow, + lastNbfiOrigination = origination, + lastNbfiRepayment = repayment, + lastNbfiDefaultAmount = defaults, lastNbfiInterestIncome = interestIncome, - lastBankTightness = Ratio(tight), + lastBankTightness = tight, lastDepositDrain = depositDrain, ) diff --git a/src/main/scala/sfc/engine/World.scala b/src/main/scala/sfc/engine/World.scala index 5ddf30f..97f19fc 100644 --- a/src/main/scala/sfc/engine/World.scala +++ b/src/main/scala/sfc/engine/World.scala @@ -141,7 +141,7 @@ object FinancialMarketsState: equity = EquityMarket.zero, corporateBonds = CorporateBondMarket.zero, insurance = Insurance.State.zero, - nbfi = Nbfi.zero, + nbfi = Nbfi.State.zero, ) /** Structural external-sector state carried across steps (not recomputed from diff --git a/src/main/scala/sfc/engine/steps/OpenEconomyStep.scala b/src/main/scala/sfc/engine/steps/OpenEconomyStep.scala index a05204b..3060e2b 100644 --- a/src/main/scala/sfc/engine/steps/OpenEconomyStep.scala +++ b/src/main/scala/sfc/engine/steps/OpenEconomyStep.scala @@ -217,22 +217,22 @@ object OpenEconomyStep: val insNetDepositChange = newInsurance.lastNetDepositChange.toDouble // --- Shadow Banking / NBFI step --- - val nbfiDepositRate = Math.max(0.0, postFxNbp.referenceRate.toDouble - 0.02) - val nbfiUnempRate = 1.0 - in.s2.employed.toDouble / in.w.totalPopulation + val nbfiDepositRate = Rate(Math.max(0.0, postFxNbp.referenceRate.toDouble - 0.02)) + val nbfiUnempRate = Ratio(1.0 - in.s2.employed.toDouble / in.w.totalPopulation) val newNbfi = if p.flags.nbfi then Nbfi.step( in.w.financial.nbfi, in.s2.employed, - in.s2.newWage, + PLN(in.s2.newWage), in.w.priceLevel, nbfiUnempRate, - in.w.bank.nplRatio.toDouble, - newBondYield, - in.w.financial.corporateBonds.corpBondYield.toDouble, - in.w.financial.equity.monthlyReturn.toDouble, + in.w.bank.nplRatio, + Rate(newBondYield), + in.w.financial.corporateBonds.corpBondYield, + in.w.financial.equity.monthlyReturn, nbfiDepositRate, - in.s3.domesticCons, + PLN(in.s3.domesticCons), ) else in.w.financial.nbfi val nbfiDepositDrain = newNbfi.lastDepositDrain.toDouble diff --git a/src/main/scala/sfc/init/NbfiInit.scala b/src/main/scala/sfc/init/NbfiInit.scala index 85f8fab..e0e7a77 100644 --- a/src/main/scala/sfc/init/NbfiInit.scala +++ b/src/main/scala/sfc/init/NbfiInit.scala @@ -8,4 +8,4 @@ object NbfiInit: def create()(using p: SimParams): Nbfi.State = if p.flags.nbfi then Nbfi.initial - else Nbfi.zero + else Nbfi.State.zero diff --git a/src/test/scala/sfc/agents/ShadowBankingSpec.scala b/src/test/scala/sfc/agents/ShadowBankingSpec.scala index 6a04820..c601922 100644 --- a/src/test/scala/sfc/agents/ShadowBankingSpec.scala +++ b/src/test/scala/sfc/agents/ShadowBankingSpec.scala @@ -10,22 +10,37 @@ class ShadowBankingSpec extends AnyFlatSpec with Matchers: given SimParams = SimParams.defaults private val p: SimParams = summon[SimParams] + private def mkStep( + prev: Nbfi.State = Nbfi.initial, + employed: Int = 50000, + wage: PLN = PLN(8000.0), + priceLevel: Double = 1.0, + unempRate: Ratio = Ratio(0.05), + bankNplRatio: Ratio = Ratio(0.02), + govBondYield: Rate = Rate(0.05), + corpBondYield: Rate = Rate(0.07), + equityReturn: Rate = Rate(0.005), + depositRate: Rate = Rate(0.03), + domesticCons: PLN = PLN(1e8), + ): Nbfi.State = + Nbfi.step(prev, employed, wage, priceLevel, unempRate, bankNplRatio, govBondYield, corpBondYield, equityReturn, depositRate, domesticCons) + // ---- zero / initial ---- - "Nbfi.zero" should "have all fields at zero" in { - val z = Nbfi.zero - z.tfiAum.toDouble shouldBe 0.0 - z.tfiGovBondHoldings.toDouble shouldBe 0.0 - z.tfiCorpBondHoldings.toDouble shouldBe 0.0 - z.tfiEquityHoldings.toDouble shouldBe 0.0 - z.tfiCashHoldings.toDouble shouldBe 0.0 - z.nbfiLoanStock.toDouble shouldBe 0.0 - z.lastTfiNetInflow.toDouble shouldBe 0.0 - z.lastNbfiOrigination.toDouble shouldBe 0.0 - z.lastNbfiRepayment.toDouble shouldBe 0.0 - z.lastNbfiDefaultAmount.toDouble shouldBe 0.0 + "Nbfi.State.zero" should "have all fields at zero" in { + val z = Nbfi.State.zero + z.tfiAum shouldBe PLN.Zero + z.tfiGovBondHoldings shouldBe PLN.Zero + z.tfiCorpBondHoldings shouldBe PLN.Zero + z.tfiEquityHoldings shouldBe PLN.Zero + z.tfiCashHoldings shouldBe PLN.Zero + z.nbfiLoanStock shouldBe PLN.Zero + z.lastTfiNetInflow shouldBe PLN.Zero + z.lastNbfiOrigination shouldBe PLN.Zero + z.lastNbfiRepayment shouldBe PLN.Zero + z.lastNbfiDefaultAmount shouldBe PLN.Zero z.lastBankTightness shouldBe Ratio.Zero - z.lastDepositDrain.toDouble shouldBe 0.0 + z.lastDepositDrain shouldBe PLN.Zero } "Nbfi.initial" should "have correct AUM" in { @@ -63,91 +78,91 @@ class ShadowBankingSpec extends AnyFlatSpec with Matchers: // ---- bankTightness ---- "Nbfi.bankTightness" should "be 0 at NPL <= 3%" in { - Nbfi.bankTightness(0.01) shouldBe 0.0 - Nbfi.bankTightness(0.03) shouldBe 0.0 + Nbfi.bankTightness(Ratio(0.01)) shouldBe Ratio.Zero + Nbfi.bankTightness(Ratio(0.03)) shouldBe Ratio.Zero } it should "be positive at NPL > 3%" in { - Nbfi.bankTightness(0.04) should be > 0.0 + Nbfi.bankTightness(Ratio(0.04)) should be > Ratio.Zero } it should "be 1.0 at NPL = 6%" in { - Nbfi.bankTightness(0.06) shouldBe 1.0 +- 0.001 + Nbfi.bankTightness(Ratio(0.06)).toDouble shouldBe 1.0 +- 0.001 } it should "be capped at 1.0 for NPL > 6%" in { - Nbfi.bankTightness(0.10) shouldBe 1.0 + Nbfi.bankTightness(Ratio(0.10)) shouldBe Ratio.One } it should "be 0.5 at NPL = 4.5%" in { - Nbfi.bankTightness(0.045) shouldBe 0.5 +- 0.001 + Nbfi.bankTightness(Ratio(0.045)).toDouble shouldBe 0.5 +- 0.001 } // ---- tfiInflow ---- "Nbfi.tfiInflow" should "be proportional to employment and wage" in { - val i1 = Nbfi.tfiInflow(1000, 8000.0, 0.0, 0.05, 0.03) - val i2 = Nbfi.tfiInflow(2000, 8000.0, 0.0, 0.05, 0.03) + val i1 = Nbfi.tfiInflow(1000, PLN(8000.0), Rate(0.0), Rate(0.05), Rate(0.03)) + val i2 = Nbfi.tfiInflow(2000, PLN(8000.0), Rate(0.0), Rate(0.05), Rate(0.03)) i2 should be > i1 // Approximately double (modulated by returns, but base scales linearly) (i2 / i1) shouldBe 2.0 +- 0.5 } it should "increase with excess returns" in { - val low = Nbfi.tfiInflow(1000, 8000.0, 0.0, 0.03, 0.05) // fund < deposit - val high = Nbfi.tfiInflow(1000, 8000.0, 0.0, 0.08, 0.02) // fund > deposit + val low = Nbfi.tfiInflow(1000, PLN(8000.0), Rate(0.0), Rate(0.03), Rate(0.05)) // fund < deposit + val high = Nbfi.tfiInflow(1000, PLN(8000.0), Rate(0.0), Rate(0.08), Rate(0.02)) // fund > deposit high should be > low } // ---- nbfiOrigination ---- "Nbfi.nbfiOrigination" should "be proportional to consumption" in { - val o1 = Nbfi.nbfiOrigination(1000000.0, 0.02) - val o2 = Nbfi.nbfiOrigination(2000000.0, 0.02) + val o1 = Nbfi.nbfiOrigination(PLN(1000000.0), Ratio(0.02)) + val o2 = Nbfi.nbfiOrigination(PLN(2000000.0), Ratio(0.02)) (o2 / o1) shouldBe 2.0 +- 0.01 } it should "be counter-cyclical (increase with bank tightness)" in { - val normal = Nbfi.nbfiOrigination(1000000.0, 0.02) // NPL 2% → tightness 0 - val tight = Nbfi.nbfiOrigination(1000000.0, 0.06) // NPL 6% → tightness 1 + val normal = Nbfi.nbfiOrigination(PLN(1000000.0), Ratio(0.02)) // NPL 2% → tightness 0 + val tight = Nbfi.nbfiOrigination(PLN(1000000.0), Ratio(0.06)) // NPL 6% → tightness 1 tight should be > normal } it should "equal base at zero tightness" in { - val base = Nbfi.nbfiOrigination(1000000.0, 0.03) // NPL 3% → tightness 0 - base shouldBe (1000000.0 * p.nbfi.creditBaseRate.toDouble) +- 1.0 + val base = Nbfi.nbfiOrigination(PLN(1000000.0), Ratio(0.03)) // NPL 3% → tightness 0 + base.toDouble shouldBe (1000000.0 * p.nbfi.creditBaseRate.toDouble) +- 1.0 } // ---- nbfiRepayment ---- "Nbfi.nbfiRepayment" should "equal stock / maturity" in { - Nbfi.nbfiRepayment(360000.0) shouldBe (360000.0 / p.nbfi.creditMaturity) +- 0.01 + Nbfi.nbfiRepayment(PLN(360000.0)).toDouble shouldBe (360000.0 / p.nbfi.creditMaturity) +- 0.01 } it should "be zero for zero stock" in { - Nbfi.nbfiRepayment(0.0) shouldBe 0.0 + Nbfi.nbfiRepayment(PLN.Zero) shouldBe PLN.Zero } // ---- nbfiDefaults ---- "Nbfi.nbfiDefaults" should "use base rate at 5% unemployment" in { - val d = Nbfi.nbfiDefaults(100000.0, 0.05) - d shouldBe (100000.0 * p.nbfi.defaultBase.toDouble) +- 0.01 + val d = Nbfi.nbfiDefaults(PLN(100000.0), Ratio(0.05)) + d.toDouble shouldBe (100000.0 * p.nbfi.defaultBase.toDouble) +- 0.01 } it should "increase with unemployment above 5%" in { - val low = Nbfi.nbfiDefaults(100000.0, 0.05) - val high = Nbfi.nbfiDefaults(100000.0, 0.10) + val low = Nbfi.nbfiDefaults(PLN(100000.0), Ratio(0.05)) + val high = Nbfi.nbfiDefaults(PLN(100000.0), Ratio(0.10)) high should be > low } it should "be zero for zero stock" in { - Nbfi.nbfiDefaults(0.0, 0.10) shouldBe 0.0 + Nbfi.nbfiDefaults(PLN.Zero, Ratio(0.10)) shouldBe PLN.Zero } it should "be sensitive to unemployment (sensitivity 3.0)" in { - val d5 = Nbfi.nbfiDefaults(100000.0, 0.05) - val d10 = Nbfi.nbfiDefaults(100000.0, 0.10) + val d5 = Nbfi.nbfiDefaults(PLN(100000.0), Ratio(0.05)) + val d10 = Nbfi.nbfiDefaults(PLN(100000.0), Ratio(0.10)) // At 10%, excess = 5%, sensitivity = 3.0: factor = 1 + 3.0 * 0.05 = 1.15 (d10 / d5) shouldBe 1.15 +- 0.01 } @@ -156,26 +171,24 @@ class ShadowBankingSpec extends AnyFlatSpec with Matchers: "Nbfi.step" should "grow AUM with positive inflow" in { val init = Nbfi.initial - val result = Nbfi.step(init, 50000, 8000.0, 1.0, 0.05, 0.02, 0.05, 0.07, 0.005, 0.03, 1e8) - result.tfiAum > init.tfiAum shouldBe true + val result = mkStep(prev = init) + result.tfiAum should be > init.tfiAum } it should "produce deposit drain equal to negative inflow" in { - val init = Nbfi.initial - val result = Nbfi.step(init, 50000, 8000.0, 1.0, 0.05, 0.02, 0.05, 0.07, 0.005, 0.03, 1e8) + val result = mkStep() result.lastDepositDrain.toDouble shouldBe -result.lastTfiNetInflow.toDouble +- 0.01 } it should "maintain Identity 13 (NBFI credit stock)" in { val init = Nbfi.initial - val result = Nbfi.step(init, 50000, 8000.0, 1.0, 0.05, 0.02, 0.05, 0.07, 0.005, 0.03, 1e8) + val result = mkStep(prev = init) val expectedChange = (result.lastNbfiOrigination - result.lastNbfiRepayment - result.lastNbfiDefaultAmount).toDouble val actualChange = (result.nbfiLoanStock - init.nbfiLoanStock).toDouble actualChange shouldBe expectedChange +- 0.01 } it should "rebalance TFI portfolio towards targets" in { - // Start with all in cash (off-target) val offTarget = Nbfi.State( tfiAum = PLN(1000000.0), tfiGovBondHoldings = PLN.Zero, @@ -183,24 +196,29 @@ class ShadowBankingSpec extends AnyFlatSpec with Matchers: tfiEquityHoldings = PLN.Zero, tfiCashHoldings = PLN(1000000.0), nbfiLoanStock = PLN(100000.0), + lastTfiNetInflow = PLN.Zero, + lastNbfiOrigination = PLN.Zero, + lastNbfiRepayment = PLN.Zero, + lastNbfiDefaultAmount = PLN.Zero, + lastNbfiInterestIncome = PLN.Zero, + lastBankTightness = Ratio.Zero, + lastDepositDrain = PLN.Zero, ) - val result = Nbfi.step(offTarget, 50000, 8000.0, 1.0, 0.05, 0.02, 0.05, 0.07, 0.005, 0.03, 1e8) - // Gov bond holdings should increase towards target - result.tfiGovBondHoldings > PLN.Zero shouldBe true + val result = mkStep(prev = offTarget) + result.tfiGovBondHoldings should be > PLN.Zero } it should "increase origination when bank NPL is high (counter-cyclical)" in { - val init = Nbfi.initial - val normal = Nbfi.step(init, 50000, 8000.0, 1.0, 0.05, 0.02, 0.05, 0.07, 0.005, 0.03, 1e8) - val tight = Nbfi.step(init, 50000, 8000.0, 1.0, 0.05, 0.06, 0.05, 0.07, 0.005, 0.03, 1e8) - tight.lastNbfiOrigination > normal.lastNbfiOrigination shouldBe true - tight.lastBankTightness.toDouble should be > normal.lastBankTightness.toDouble + val normal = mkStep(bankNplRatio = Ratio(0.02)) + val tight = mkStep(bankNplRatio = Ratio(0.06)) + tight.lastNbfiOrigination should be > normal.lastNbfiOrigination + tight.lastBankTightness should be > normal.lastBankTightness } it should "produce positive interest income from loan stock" in { val init = Nbfi.initial - val result = Nbfi.step(init, 50000, 8000.0, 1.0, 0.05, 0.02, 0.05, 0.07, 0.005, 0.03, 1e8) - if init.nbfiLoanStock > PLN.Zero then result.lastNbfiInterestIncome > PLN.Zero shouldBe true + val result = mkStep(prev = init) + if init.nbfiLoanStock > PLN.Zero then result.lastNbfiInterestIncome should be > PLN.Zero } // ---- Config defaults ---- @@ -210,7 +228,7 @@ class ShadowBankingSpec extends AnyFlatSpec with Matchers: } it should "have correct TFI allocation shares" in { - p.nbfi.tfiGovBondShare.toDouble shouldBe 0.40 - p.nbfi.tfiCorpBondShare.toDouble shouldBe 0.10 - p.nbfi.tfiEquityShare.toDouble shouldBe 0.10 + p.nbfi.tfiGovBondShare shouldBe Ratio(0.40) + p.nbfi.tfiCorpBondShare shouldBe Ratio(0.10) + p.nbfi.tfiEquityShare shouldBe Ratio(0.10) }