@@ -7,91 +7,137 @@ import sfc.types.*
77 * fintech).
88 */
99object Nbfi :
10+
11+ // ---------------------------------------------------------------------------
12+ // Named constants
13+ // ---------------------------------------------------------------------------
14+
15+ private val NplTightnessFloor = 0.03 // NPL ratio below which bank tightness = 0
16+ private val NplTightnessRange = 0.03 // NPL range over which tightness rises from 0 → 1
17+ private val UnempDefaultThreshold = 0.05 // unemployment rate below which no cyclical default add-on
18+ private val ExcessReturnSens = 5.0 // TFI inflow sensitivity to excess fund vs deposit return
19+ private val ExcessReturnCap = 0.05 // cap on absolute excess return signal
20+ private val MonthsPerYear = 12.0
21+
22+ // ---------------------------------------------------------------------------
23+ // State
24+ // ---------------------------------------------------------------------------
25+
1026 case class State (
1127 // TFI component
12- tfiAum : PLN , // Total AUM
13- tfiGovBondHoldings : PLN , // Gov bonds (target share)
14- tfiCorpBondHoldings : PLN , // Corp bonds (target share)
15- tfiEquityHoldings : PLN , // Equities (target share)
16- tfiCashHoldings : PLN , // Cash /money market (residual)
28+ tfiAum : PLN , // total assets under management
29+ tfiGovBondHoldings : PLN , // gov bonds (target share)
30+ tfiCorpBondHoldings : PLN , // corp bonds (target share)
31+ tfiEquityHoldings : PLN , // equities (target share)
32+ tfiCashHoldings : PLN , // cash /money market (residual)
1733 // NBFI credit component (leasing + fintech)
18- nbfiLoanStock : PLN , // Outstanding NBFI loans
34+ nbfiLoanStock : PLN , // outstanding NBFI loans
1935 // Flow tracking
20- lastTfiNetInflow : PLN = PLN . Zero , // HH net fund purchases
21- lastNbfiOrigination : PLN = PLN . Zero , // Monthly new NBFI credit
22- lastNbfiRepayment : PLN = PLN . Zero , // Monthly principal repaid
23- lastNbfiDefaultAmount : PLN = PLN . Zero , // Monthly gross defaults
24- lastNbfiInterestIncome : PLN = PLN . Zero , // NBFI interest earned
25- lastBankTightness : Ratio = Ratio . Zero , // Counter -cyclical signal
26- lastDepositDrain : PLN = PLN . Zero , // Net deposit outflow (TFI inflow)
36+ lastTfiNetInflow : PLN , // HH net fund purchases this month
37+ lastNbfiOrigination : PLN , // monthly new NBFI credit
38+ lastNbfiRepayment : PLN , // monthly principal repaid
39+ lastNbfiDefaultAmount : PLN , // monthly gross defaults
40+ lastNbfiInterestIncome : PLN , // NBFI interest earned this month
41+ lastBankTightness : Ratio , // counter -cyclical signal [0,1]
42+ lastDepositDrain : PLN , // net deposit outflow (TFI inflow)
2743 )
2844
29- def zero : State = State (PLN .Zero , PLN .Zero , PLN .Zero , PLN .Zero , PLN .Zero , PLN .Zero )
45+ object State :
46+ val zero : State = State (
47+ PLN .Zero ,
48+ PLN .Zero ,
49+ PLN .Zero ,
50+ PLN .Zero ,
51+ PLN .Zero ,
52+ PLN .Zero ,
53+ PLN .Zero ,
54+ PLN .Zero ,
55+ PLN .Zero ,
56+ PLN .Zero ,
57+ PLN .Zero ,
58+ Ratio .Zero ,
59+ PLN .Zero ,
60+ )
3061
62+ /** Initialize from SimParams calibration. */
3163 def initial (using p : SimParams ): State =
32- val aum = PLN ( p.nbfi.tfiInitAum.toDouble)
64+ val aum = p.nbfi.tfiInitAum
3365 State (
3466 tfiAum = aum,
3567 tfiGovBondHoldings = aum * p.nbfi.tfiGovBondShare.toDouble,
3668 tfiCorpBondHoldings = aum * p.nbfi.tfiCorpBondShare.toDouble,
3769 tfiEquityHoldings = aum * p.nbfi.tfiEquityShare.toDouble,
3870 tfiCashHoldings = aum * (1.0 - p.nbfi.tfiGovBondShare.toDouble - p.nbfi.tfiCorpBondShare.toDouble - p.nbfi.tfiEquityShare.toDouble),
39- nbfiLoanStock = PLN (p.nbfi.creditInitStock.toDouble),
71+ nbfiLoanStock = p.nbfi.creditInitStock,
72+ lastTfiNetInflow = PLN .Zero ,
73+ lastNbfiOrigination = PLN .Zero ,
74+ lastNbfiRepayment = PLN .Zero ,
75+ lastNbfiDefaultAmount = PLN .Zero ,
76+ lastNbfiInterestIncome = PLN .Zero ,
77+ lastBankTightness = Ratio .Zero ,
78+ lastDepositDrain = PLN .Zero ,
4079 )
4180
42- /** Bank credit tightness signal: 0 at NPL <= 3%, rises linearly, 1.0 at 6%.
43- */
44- def bankTightness (bankNplRatio : Double ): Double =
45- Math .max(0.0 , Math .min(1.0 , (bankNplRatio - 0.03 ) / 0.03 ))
81+ // ---------------------------------------------------------------------------
82+ // Pure helper functions
83+ // ---------------------------------------------------------------------------
84+
85+ /** Bank credit tightness signal: 0 at NPL ≤ 3%, rises linearly, 1.0 at 6%. */
86+ def bankTightness (bankNplRatio : Ratio ): Ratio =
87+ Ratio (Math .max(0.0 , Math .min(1.0 , (bankNplRatio.toDouble - NplTightnessFloor ) / NplTightnessRange )))
4688
4789 /** TFI net inflow: proportional to wage bill, modulated by excess returns. */
48- def tfiInflow (employed : Int , wage : Double , equityReturn : Double , govBondYield : Double , depositRate : Double )(using
90+ def tfiInflow (employed : Int , wage : PLN , equityReturn : Rate , govBondYield : Rate , depositRate : Rate )(using
4991 p : SimParams ,
50- ): Double =
51- val wageBill = employed.toDouble * wage
92+ ): PLN =
93+ val wageBill = wage * employed.toDouble
5294 val base = wageBill * p.nbfi.tfiInflowRate.toDouble
5395 // Excess return: weighted avg of fund returns vs deposit rate
54- val fundReturn = govBondYield * p.nbfi.tfiGovBondShare.toDouble +
55- equityReturn * 12.0 * p.nbfi.tfiEquityShare.toDouble +
56- govBondYield * p.nbfi.tfiCorpBondShare.toDouble // proxy: corp ~ gov yield
57- val excessReturn = Math .max(- 0.05 , Math .min(0.05 , fundReturn - depositRate))
58- base * (1.0 + excessReturn * 5.0 )
96+ val fundReturn = govBondYield.toDouble * p.nbfi.tfiGovBondShare.toDouble +
97+ equityReturn.toDouble * MonthsPerYear * p.nbfi.tfiEquityShare.toDouble +
98+ govBondYield.toDouble * p.nbfi.tfiCorpBondShare.toDouble // proxy: corp ~ gov yield
99+ val excessReturn = Math .max(- ExcessReturnCap , Math .min(ExcessReturnCap , fundReturn - depositRate.toDouble ))
100+ base * (1.0 + excessReturn * ExcessReturnSens )
59101
60102 /** NBFI credit origination: counter-cyclical to bank tightness. */
61- def nbfiOrigination (domesticCons : Double , bankNplRatio : Double )(using p : SimParams ): Double =
103+ def nbfiOrigination (domesticCons : PLN , bankNplRatio : Ratio )(using p : SimParams ): PLN =
62104 val tight = bankTightness(bankNplRatio)
63- domesticCons * p.nbfi.creditBaseRate.toDouble * (1.0 + p.nbfi.countercyclical * tight)
105+ domesticCons * p.nbfi.creditBaseRate.toDouble * (1.0 + p.nbfi.countercyclical * tight.toDouble )
64106
65107 /** NBFI loan repayment: stock / maturity. */
66- def nbfiRepayment (loanStock : Double )(using p : SimParams ): Double =
108+ def nbfiRepayment (loanStock : PLN )(using p : SimParams ): PLN =
67109 loanStock / p.nbfi.creditMaturity
68110
69- /** NBFI defaults: base rate widening with unemployment (sensitivity 3.0). */
70- def nbfiDefaults (loanStock : Double , unempRate : Double )(using p : SimParams ): Double =
71- loanStock * p.nbfi.defaultBase.toDouble * (1.0 + p.nbfi.defaultUnempSens * Math .max(0.0 , unempRate - 0.05 ))
111+ /** NBFI defaults: base rate widening with unemployment. */
112+ def nbfiDefaults (loanStock : PLN , unempRate : Ratio )(using p : SimParams ): PLN =
113+ loanStock * p.nbfi.defaultBase.toDouble * (1.0 + p.nbfi.defaultUnempSens * Math .max(0.0 , unempRate.toDouble - UnempDefaultThreshold ))
114+
115+ // ---------------------------------------------------------------------------
116+ // Monthly step
117+ // ---------------------------------------------------------------------------
72118
73- /** Full monthly step: TFI inflow -> investment income -> rebalance; NBFI
74- * credit flows.
119+ /** Full monthly step: TFI inflow → investment income → rebalance; NBFI credit
120+ * flows.
75121 */
76122 def step (
77123 prev : State ,
78- employed : Int ,
79- wage : Double ,
80- priceLevel : Double ,
81- unempRate : Double ,
82- bankNplRatio : Double ,
83- govBondYield : Double ,
84- corpBondYield : Double ,
85- equityReturn : Double ,
86- depositRate : Double ,
87- domesticCons : Double ,
124+ employed : Int , // employed workers
125+ wage : PLN , // average monthly wage
126+ priceLevel : Double , // CPI price level (unused in current spec, kept for interface stability)
127+ unempRate : Ratio , // unemployment rate
128+ bankNplRatio : Ratio , // aggregate bank NPL ratio (tightness signal)
129+ govBondYield : Rate , // government bond yield (annualised)
130+ corpBondYield : Rate , // corporate bond yield (annualised)
131+ equityReturn : Rate , // equity monthly return
132+ depositRate : Rate , // bank deposit rate (TFI opportunity cost)
133+ domesticCons : PLN , // domestic consumption (NBFI credit base)
88134 )(using p : SimParams ): State =
89135 // TFI: inflow + investment income + rebalance
90136 val netInflow = tfiInflow(employed, wage, equityReturn, govBondYield, depositRate)
91- val invIncome = prev.tfiGovBondHoldings * govBondYield / 12.0 +
92- prev.tfiCorpBondHoldings * corpBondYield / 12.0 +
93- prev.tfiEquityHoldings * equityReturn
94- val newAum = (prev.tfiAum + PLN ( netInflow) + invIncome).max(PLN .Zero )
137+ val invIncome = prev.tfiGovBondHoldings * govBondYield.toDouble / MonthsPerYear +
138+ prev.tfiCorpBondHoldings * corpBondYield.toDouble / MonthsPerYear +
139+ prev.tfiEquityHoldings * equityReturn.toDouble
140+ val newAum = (prev.tfiAum + netInflow + invIncome).max(PLN .Zero )
95141
96142 // Rebalance towards target allocation
97143 val s = p.nbfi.tfiRebalanceSpeed.toDouble
@@ -103,16 +149,16 @@ object Nbfi:
103149 val newEq = prev.tfiEquityHoldings + (targetEq - prev.tfiEquityHoldings) * s
104150 val newCash = newAum - newGov - newCorp - newEq
105151
106- // Deposit drain: HH buys fund units -> deposits decrease
107- val depositDrain = PLN ( - netInflow)
152+ // Deposit drain: HH buys fund units → deposits decrease
153+ val depositDrain = - netInflow
108154
109- // NBFI credit: counter-cyclical origination -> repayment -> defaults
155+ // NBFI credit: counter-cyclical origination → repayment → defaults
110156 val tight = bankTightness(bankNplRatio)
111157 val origination = nbfiOrigination(domesticCons, bankNplRatio)
112- val repayment = nbfiRepayment(prev.nbfiLoanStock.toDouble )
113- val defaults = nbfiDefaults(prev.nbfiLoanStock.toDouble , unempRate)
114- val newLoanStock = (prev.nbfiLoanStock + PLN ( origination) - PLN ( repayment) - PLN ( defaults) ).max(PLN .Zero )
115- val interestIncome = prev.nbfiLoanStock * p.nbfi.creditRate.toDouble / 12.0
158+ val repayment = nbfiRepayment(prev.nbfiLoanStock)
159+ val defaults = nbfiDefaults(prev.nbfiLoanStock, unempRate)
160+ val newLoanStock = (prev.nbfiLoanStock + origination - repayment - defaults).max(PLN .Zero )
161+ val interestIncome = prev.nbfiLoanStock * p.nbfi.creditRate.toDouble / MonthsPerYear
116162
117163 State (
118164 tfiAum = newAum,
@@ -121,11 +167,11 @@ object Nbfi:
121167 tfiEquityHoldings = newEq,
122168 tfiCashHoldings = newCash,
123169 nbfiLoanStock = newLoanStock,
124- lastTfiNetInflow = PLN ( netInflow) ,
125- lastNbfiOrigination = PLN ( origination) ,
126- lastNbfiRepayment = PLN ( repayment) ,
127- lastNbfiDefaultAmount = PLN ( defaults) ,
170+ lastTfiNetInflow = netInflow,
171+ lastNbfiOrigination = origination,
172+ lastNbfiRepayment = repayment,
173+ lastNbfiDefaultAmount = defaults,
128174 lastNbfiInterestIncome = interestIncome,
129- lastBankTightness = Ratio ( tight) ,
175+ lastBankTightness = tight,
130176 lastDepositDrain = depositDrain,
131177 )
0 commit comments