Skip to content
This repository was archived by the owner on Mar 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 108 additions & 62 deletions src/main/scala/sfc/agents/Nbfi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
)
2 changes: 1 addition & 1 deletion src/main/scala/sfc/engine/World.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions src/main/scala/sfc/engine/steps/OpenEconomyStep.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/sfc/init/NbfiInit.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading