Skip to content
This repository was archived by the owner on Mar 12, 2026. It is now read-only.

Commit 29a136c

Browse files
committed
Refactor Nbfi.scala to opaque types, no defaults, named constants
- State: remove 7 defaults, all 13 fields explicit - zero: def → val in object State companion - initial: remove redundant PLN wrapping - step: wage/domesticCons → PLN, unempRate/bankNplRatio → Ratio, yields/returns/depositRate → Rate - Helper methods: bankTightness(Ratio)→Ratio, tfiInflow → PLN, nbfiOrigination/Repayment/Defaults take PLN/Ratio, return PLN - Extract 6 named constants (NplTightnessFloor/Range, UnempDefaultThreshold, ExcessReturnSens/Cap, MonthsPerYear) - Tests: mkStep helper, opaque type assertions
1 parent a148905 commit 29a136c

5 files changed

Lines changed: 193 additions & 129 deletions

File tree

src/main/scala/sfc/agents/Nbfi.scala

Lines changed: 108 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -7,91 +7,137 @@ import sfc.types.*
77
* fintech).
88
*/
99
object 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
)

src/main/scala/sfc/engine/World.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ object FinancialMarketsState:
141141
equity = EquityMarket.zero,
142142
corporateBonds = CorporateBondMarket.zero,
143143
insurance = Insurance.State.zero,
144-
nbfi = Nbfi.zero,
144+
nbfi = Nbfi.State.zero,
145145
)
146146

147147
/** Structural external-sector state carried across steps (not recomputed from

src/main/scala/sfc/engine/steps/OpenEconomyStep.scala

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,22 +217,22 @@ object OpenEconomyStep:
217217
val insNetDepositChange = newInsurance.lastNetDepositChange.toDouble
218218

219219
// --- Shadow Banking / NBFI step ---
220-
val nbfiDepositRate = Math.max(0.0, postFxNbp.referenceRate.toDouble - 0.02)
221-
val nbfiUnempRate = 1.0 - in.s2.employed.toDouble / in.w.totalPopulation
220+
val nbfiDepositRate = Rate(Math.max(0.0, postFxNbp.referenceRate.toDouble - 0.02))
221+
val nbfiUnempRate = Ratio(1.0 - in.s2.employed.toDouble / in.w.totalPopulation)
222222
val newNbfi =
223223
if p.flags.nbfi then
224224
Nbfi.step(
225225
in.w.financial.nbfi,
226226
in.s2.employed,
227-
in.s2.newWage,
227+
PLN(in.s2.newWage),
228228
in.w.priceLevel,
229229
nbfiUnempRate,
230-
in.w.bank.nplRatio.toDouble,
231-
newBondYield,
232-
in.w.financial.corporateBonds.corpBondYield.toDouble,
233-
in.w.financial.equity.monthlyReturn.toDouble,
230+
in.w.bank.nplRatio,
231+
Rate(newBondYield),
232+
in.w.financial.corporateBonds.corpBondYield,
233+
in.w.financial.equity.monthlyReturn,
234234
nbfiDepositRate,
235-
in.s3.domesticCons,
235+
PLN(in.s3.domesticCons),
236236
)
237237
else in.w.financial.nbfi
238238
val nbfiDepositDrain = newNbfi.lastDepositDrain.toDouble

src/main/scala/sfc/init/NbfiInit.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ object NbfiInit:
88

99
def create()(using p: SimParams): Nbfi.State =
1010
if p.flags.nbfi then Nbfi.initial
11-
else Nbfi.zero
11+
else Nbfi.State.zero

0 commit comments

Comments
 (0)