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
8 changes: 4 additions & 4 deletions src/main/scala/sfc/agents/Banking.scala
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ object Banking:
* zero.
*/
def hhDepositRate(refRate: Rate)(using p: SimParams): Rate =
Rate(Math.max(0.0, refRate.toDouble - p.household.depositSpread.toDouble))
(refRate - p.household.depositSpread).max(Rate.Zero)

/** Lending rate for a bank: refRate + baseSpread + bankSpread + nplSpread +
* carPenalty. Failed banks get flat refRate + FailedBankSpread.
Expand All @@ -296,7 +296,7 @@ object Banking:
if bank.car.toDouble < p.banking.minCar.toDouble * CarPenaltyThreshMult then
Math.max(0.0, (p.banking.minCar.toDouble * CarPenaltyThreshMult - bank.car.toDouble) * CarPenaltyScale)
else 0.0
Rate(refRate.toDouble + p.banking.baseSpread.toDouble + cfg.lendingSpread.toDouble + nplSpread + carPenalty)
refRate + p.banking.baseSpread + cfg.lendingSpread + Rate(nplSpread + carPenalty)

/** Interbank rate (WIBOR proxy): deposit rate + stress × (lombard − deposit).
* stress = aggNplRate / stressThreshold, clipped to [0,1].
Expand Down Expand Up @@ -448,7 +448,7 @@ object Banking:
if b.id == absorberId then
b.copy(
deposits = b.deposits + PLN(addDep),
loans = b.loans + PLN(Math.max(0, addLoans)),
loans = b.loans + PLN(addLoans).max(PLN.Zero),
govBondHoldings = b.govBondHoldings + PLN(addBonds),
corpBondHoldings = b.corpBondHoldings + PLN(addCorpB),
consumerLoans = b.consumerLoans + PLN(addCC),
Expand Down Expand Up @@ -602,7 +602,7 @@ object Banking:
val perBank = banks.map: b =>
if b.failed then PLN.Zero
else if b.reservesAtNbp > PLN.Zero then b.reservesAtNbp * depositRate / 12.0
else if b.interbankNet < PLN.Zero then PLN(b.interbankNet.toDouble.abs * lombardRate.toDouble / 12.0 * -1.0)
else if b.interbankNet < PLN.Zero then -(b.interbankNet.abs * lombardRate.monthly)
else PLN.Zero
PerBankAmounts(perBank, PLN(perBank.map(_.toDouble).kahanSum))

Expand Down
8 changes: 4 additions & 4 deletions src/main/scala/sfc/agents/Firm.scala
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ object Firm:
rng: Random,
)(using p: SimParams): Decision =
val pnl = computePnL(firm, w.hhAgg.marketWage, w.flows.sectorDemandMult(firm.sector.toInt), w.priceLevel, lendRate, w.month)
val ready2 = Ratio(Math.min(1.0, firm.digitalReadiness.toDouble + HybridMonthlyDrDrift))
val ready2 = (firm.digitalReadiness + Ratio(HybridMonthlyDrDrift)).min(Ratio.One)

val upCapex = computeAiCapex(firm) * HybridToFullCapexMul
val upLoan = upCapex * FullAiLoanShare
Expand Down Expand Up @@ -553,7 +553,7 @@ object Firm:
diminishing * (0.5 + competitive)
if canAfford && rng.nextDouble() < digiProb then
val boost = p.firm.digiInvestBoost.toDouble * diminishing
val newDR = Ratio(Math.min(1.0, firm.digitalReadiness.toDouble + boost))
val newDR = (firm.digitalReadiness + Ratio(boost)).min(Ratio.One)
Decision.DigiInvest(pnl, digiCost, newDR)
else if nc < PLN.Zero then attemptDownsize(firm, pnl, nc, workers, TechState.Traditional(_), w.hhAgg.marketWage, BankruptReason.LaborCostInsolvency)
else Decision.Survive(pnl, nc)
Expand Down Expand Up @@ -669,7 +669,7 @@ object Firm:
if p.firm.digiDrift <= Ratio.Zero then return r
val f = r.firm
if !isAlive(f) then return r
val newDR = Ratio(Math.min(1.0, f.digitalReadiness.toDouble + p.firm.digiDrift.toDouble))
val newDR = (f.digitalReadiness + p.firm.digiDrift).min(Ratio.One)
r.copy(firm = f.copy(digitalReadiness = newDR))

/** Apply physical capital investment after firm decision. Depreciation,
Expand Down Expand Up @@ -813,7 +813,7 @@ object Firm:
* clamped to [0, 1].
*/
private def effectiveShadowShare(sector: SectorIdx, cyclicalAdj: Double)(using p: SimParams): Ratio =
Ratio(Math.min(1.0, p.informal.sectorShares.map(_.toDouble)(sector.toInt) + cyclicalAdj))
(p.informal.sectorShares(sector.toInt) + Ratio(cyclicalAdj)).min(Ratio.One)

/** CIT evasion fraction for a sector — shadow share × CIT evasion rate. */
private def citEvasionFrac(sector: SectorIdx, cyclicalAdj: Double)(using p: SimParams): Ratio =
Expand Down
8 changes: 4 additions & 4 deletions src/main/scala/sfc/agents/Household.scala
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ object Household:
monthlyRent = rent,
skill = Ratio(skill),
healthPenalty = Ratio.Zero,
mpc = Ratio(Math.max(MpcFloor, Math.min(MpcCeiling, mpc))),
mpc = Ratio(mpc).clamp(Ratio(MpcFloor), Ratio(MpcCeiling)),
status = HhStatus.Employed(firm.id, sectorIdx, wage),
socialNeighbors =
if hhId < socialNetwork.length then socialNetwork(hhId).map(HhId(_)) else Array.empty[HhId],
Expand Down Expand Up @@ -337,7 +337,7 @@ object Household:
else
p.fiscal.pitBracket1Annual * p.fiscal.pitRate1.toDouble +
(annualized - p.fiscal.pitBracket1Annual) * p.fiscal.pitRate2.toDouble
PLN(Math.max(0.0, grossTax.toDouble - p.fiscal.pitTaxCreditAnnual.toDouble) / 12.0)
(grossTax - p.fiscal.pitTaxCreditAnnual).max(PLN.Zero) / 12.0

/** Compute 800+ social transfer (PIT-exempt, lump-sum per child ≤ 18). */
def computeSocialTransfer(numChildren: Int)(using p: SimParams): PLN =
Expand Down Expand Up @@ -705,14 +705,14 @@ object Household:
private def applySkillDecay(hh: State, status: HhStatus)(using p: SimParams): Ratio =
status match
case HhStatus.Unemployed(months) if months >= p.household.scarringOnset =>
hh.skill * (1.0 - p.household.skillDecayRate.toDouble)
hh.skill * (Ratio.One - p.household.skillDecayRate)
case _ => hh.skill

/** Apply health scarring for long-term unemployed (cumulative, capped). */
private def applyHealthScarring(hh: State, status: HhStatus)(using p: SimParams): Ratio =
status match
case HhStatus.Unemployed(months) if months >= p.household.scarringOnset =>
Ratio(Math.min(p.household.scarringCap.toDouble, hh.healthPenalty.toDouble + p.household.scarringRate.toDouble))
(hh.healthPenalty + p.household.scarringRate).min(p.household.scarringCap)
case _ => hh.healthPenalty

/** Fraction of social neighbors in distress (BitSet, O(k) per HH). */
Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/sfc/agents/Nbfi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ object Nbfi:

/** 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)))
Ratio((bankNplRatio.toDouble - NplTightnessFloor) / NplTightnessRange).clamp(Ratio.Zero, Ratio.One)

/** TFI net inflow: proportional to wage bill, modulated by excess returns. */
def tfiInflow(employed: Int, wage: PLN, equityReturn: Rate, govBondYield: Rate, depositRate: Rate)(using
Expand Down
6 changes: 3 additions & 3 deletions src/main/scala/sfc/agents/Nbp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ object Nbp:

/** Floor/ceiling clamp to [rateFloor, rateCeiling]. */
private def clampRate(rate: Double)(using p: SimParams): Rate =
Rate(Math.max(p.monetary.rateFloor.toDouble, Math.min(p.monetary.rateCeiling.toDouble, rate)))
Rate(rate).clamp(p.monetary.rateFloor, p.monetary.rateCeiling)

/** Update NBP reference rate via Taylor rule. Symmetric (dual mandate) or
* asymmetric (inflation-only) depending on flags.nbpSymmetric.
Expand Down Expand Up @@ -126,7 +126,7 @@ object Nbp:
val fiscalRisk = Math.min(FiscalRiskCap, p.fiscal.govFiscalRiskBeta * Math.max(0.0, debtToGdp - DebtThreshold))
val qeCompress = QeCompressionCoeff * nbpBondGdpShare
val foreignDemand = if nfa > PLN.Zero then ForeignDemandDiscount else 0.0
Rate(Math.max(0.0, refRate.toDouble + termPremium + fiscalRisk - qeCompress - foreignDemand + credibilityPremium))
(refRate + Rate(termPremium + fiscalRisk - qeCompress - foreignDemand + credibilityPremium)).max(Rate.Zero)

// ---------------------------------------------------------------------------
// QE
Expand Down Expand Up @@ -183,4 +183,4 @@ object Nbp:
val newReserves = reserves + eurTraded
val gdpEffect = if gdp > 0 then Math.abs(eurTraded) * p.forex.baseExRate / gdp else 0.0
val erEffect = direction * gdpEffect * p.monetary.fxStrength.toDouble
FxInterventionResult(erEffect, PLN(eurTraded), PLN(Math.max(0.0, newReserves)))
FxInterventionResult(erEffect, PLN(eurTraded), PLN(newReserves).max(PLN.Zero))
4 changes: 2 additions & 2 deletions src/main/scala/sfc/config/HouseholdConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ case class HouseholdConfig(
mpcAlpha: Double = 8.2,
mpcBeta: Double = 1.8,
// Skill decay & scarring
skillDecayRate: Rate = Rate(0.02),
scarringRate: Rate = Rate(0.02),
skillDecayRate: Ratio = Ratio(0.02),
scarringRate: Ratio = Ratio(0.02),
scarringCap: Ratio = Ratio(0.50),
scarringOnset: Int = 3,
// Retraining
Expand Down
6 changes: 3 additions & 3 deletions src/main/scala/sfc/engine/markets/CorporateBondMarket.scala
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ object CorporateBondMarket:
def computeYield(govBondYield: Rate, nplRatio: Ratio)(using p: SimParams): Rate =
val cyclicalSpread = p.corpBond.spread.toDouble * (1.0 + nplRatio.toDouble * NplSensitivity)
val spread = Math.min(MaxSpread, cyclicalSpread)
Rate(Math.max(MinYield, govBondYield.toDouble + spread))
(govBondYield + Rate(spread)).max(Rate(MinYield))

/** @param total
* total monthly coupon across all holders
Expand Down Expand Up @@ -132,7 +132,7 @@ object CorporateBondMarket:
if aggBankCar <= minCar then MinAbsorption
else if aggBankCar.toDouble >= minCar.toDouble + CarBufferZone then 1.0
else MinAbsorption + (1.0 - MinAbsorption) * (aggBankCar.toDouble - minCar.toDouble) / CarBufferZone
Ratio(Math.max(MinAbsorption, Math.min(1.0, spreadAbsorption * carAbsorption)))
Ratio(spreadAbsorption * carAbsorption).clamp(Ratio(MinAbsorption), Ratio.One)

/** Process new issuance: allocate to holders proportionally. */
def processIssuance(state: State, issuance: PLN)(using p: SimParams): State =
Expand Down Expand Up @@ -171,7 +171,7 @@ object CorporateBondMarket:
ppkHoldings = (in.prev.ppkHoldings - in.prev.ppkHoldings * reductionFrac).max(PLN.Zero),
otherHoldings = (in.prev.otherHoldings - in.prev.otherHoldings * reductionFrac).max(PLN.Zero),
corpBondYield = newYield,
creditSpread = Rate(Math.max(0.0, newYield.toDouble - in.govBondYield.toDouble)),
creditSpread = (newYield - in.govBondYield).max(Rate.Zero),
lastAmortization = amort,
lastDefaultAmount = defaults.grossDefault,
lastDefaultLoss = defaults.lossAfterRecovery,
Expand Down
4 changes: 2 additions & 2 deletions src/main/scala/sfc/engine/markets/EquityMarket.scala
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,11 @@ object EquityMarket:

// Dividend yield: payout ratio x earnings yield (mean-reverting to calibrated)
val impliedDivYield = newEarningsYield.toDouble * PayoutRatio
val newDivYield = Rate(in.prev.dividendYield.toDouble * (1.0 - DivYieldSmoothing) + impliedDivYield * DivYieldSmoothing)
val newDivYield = in.prev.dividendYield * (1.0 - DivYieldSmoothing) + Rate(impliedDivYield * DivYieldSmoothing)

// Foreign ownership: slow-moving, mean-reverting to calibrated share
val newForeignOwnership =
Ratio(in.prev.foreignOwnership.toDouble * (1.0 - ForeignReversionSpeed) + p.equity.foreignShare.toDouble * ForeignReversionSpeed)
in.prev.foreignOwnership * (1.0 - ForeignReversionSpeed) + p.equity.foreignShare * ForeignReversionSpeed

val mReturn = if in.prev.index > 0 then newIndex / in.prev.index - 1.0 else 0.0

Expand Down
14 changes: 7 additions & 7 deletions src/main/scala/sfc/engine/markets/HousingMarket.scala
Original file line number Diff line number Diff line change
Expand Up @@ -286,13 +286,13 @@ object HousingMarket:
if !p.flags.re || prev.mortgageStock <= PLN.Zero
then MortgageFlows(PLN.Zero, PLN.Zero, PLN.Zero, PLN.Zero)
else
val stock = prev.mortgageStock.toDouble
val interest = PLN(stock * Math.max(0.0, mortgageRate.toDouble) / 12.0)
val principal = PLN(stock / p.housing.mortgageMaturity.toDouble)
val defaultRate = p.housing.defaultBase.toDouble +
p.housing.defaultUnempSens * Math.max(0.0, unemploymentRate.toDouble - 0.05)
val defaultAmount = PLN(stock * defaultRate)
val defaultLoss = defaultAmount * (1.0 - p.housing.mortgageRecovery.toDouble)
val stock = prev.mortgageStock
val interest = stock * mortgageRate.max(Rate.Zero).monthly
val principal = stock / p.housing.mortgageMaturity.toDouble
val defaultRate = p.housing.defaultBase +
Ratio(p.housing.defaultUnempSens * (unemploymentRate - Ratio(0.05)).max(Ratio.Zero).toDouble)
val defaultAmount = stock * defaultRate
val defaultLoss = defaultAmount * (Ratio.One - p.housing.mortgageRecovery)
MortgageFlows(interest, principal, defaultAmount, defaultLoss)

// --- Apply flows ---
Expand Down
9 changes: 5 additions & 4 deletions src/main/scala/sfc/engine/mechanisms/FirmEntry.scala
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ object FirmEntry:
tech = tech,
riskProfile = Ratio(rng.between(0.1, 0.9)),
innovationCostFactor = rng.between(0.8, 1.5),
digitalReadiness = Ratio(dr),
digitalReadiness = dr,
sector = SectorIdx(newSector),
neighbors = newNeighbors,
bankId = newBankId,
Expand All @@ -134,10 +134,11 @@ object FirmEntry:
* conventional entrants draw from sector baseline with Gaussian noise,
* clamped to the feasible range for non-digital firms.
*/
private def drawDigitalReadiness(isAiNative: Boolean, sector: Int, rng: Random)(using p: SimParams): Double =
if isAiNative then rng.between(AiNativeMinDr, AiNativeMaxDr)
private def drawDigitalReadiness(isAiNative: Boolean, sector: Int, rng: Random)(using p: SimParams): Ratio =
if isAiNative then Ratio(rng.between(AiNativeMinDr, AiNativeMaxDr))
else
Math.max(ConventionalDrFloor, Math.min(ConventionalDrCap, p.sectorDefs(sector).baseDigitalReadiness.toDouble + rng.nextGaussian() * ConventionalDrNoise))
Ratio(p.sectorDefs(sector).baseDigitalReadiness.toDouble + rng.nextGaussian() * ConventionalDrNoise)
.clamp(Ratio(ConventionalDrFloor), Ratio(ConventionalDrCap))

/** Assign network neighbors from the living firm population. */
private def assignNeighbors(livingIds: Vector[Int], rng: Random): Vector[FirmId] =
Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/sfc/engine/mechanisms/Macroprudential.scala
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ object Macroprudential:

// CCyB rule: build gradually above activation gap, release immediately below release gap
val newCcyb =
if gap > p.banking.ccybActivationGap.toDouble then Rate(Math.min(p.banking.ccybMax.toDouble, prev.ccyb.toDouble + CcybBuildRate))
if gap > p.banking.ccybActivationGap.toDouble then (prev.ccyb + Rate(CcybBuildRate)).min(p.banking.ccybMax)
else if gap < p.banking.ccybReleaseGap then Rate.Zero
else prev.ccyb

Expand Down
12 changes: 6 additions & 6 deletions src/main/scala/sfc/engine/steps/OpenEconomyStep.scala
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,11 @@ object OpenEconomyStep:
val newBondYield = Nbp.bondYield(newRefRate, debtToGdp, nbpBondGdpShare, in.w.bop.nfa, credPremium)

// Debt service: use LAGGED bond stock
val rawDebtService = in.w.gov.bondsOutstanding.toDouble * newBondYield.toDouble / 12.0
val monthlyDebtService = PLN(Math.min(rawDebtService, in.w.gdpProxy * MaxDebtServiceGdpShare))
val bankBondIncome = PLN(in.w.bank.govBondHoldings.toDouble * newBondYield.toDouble / 12.0)
val nbpBondIncome = in.w.nbp.govBondHoldings.toDouble * newBondYield.toDouble / 12.0
val nbpRemittance = PLN(nbpBondIncome - interbank.reserveInterest.toDouble - interbank.standingFacilityIncome.toDouble)
val rawDebtService = in.w.gov.bondsOutstanding * newBondYield.monthly
val monthlyDebtService = rawDebtService.min(PLN(in.w.gdpProxy * MaxDebtServiceGdpShare))
val bankBondIncome = in.w.bank.govBondHoldings * newBondYield.monthly
val nbpBondIncome = in.w.nbp.govBondHoldings * newBondYield.monthly
val nbpRemittance = nbpBondIncome - interbank.reserveInterest - interbank.standingFacilityIncome

// QE logic
val qeActivate = Nbp.shouldActivateQe(newRefRate, in.s7.newInfl)
Expand Down Expand Up @@ -379,7 +379,7 @@ object OpenEconomyStep:
InsuranceResult(newInsurance)

private def stepNbfi(in: Input, postFxNbp: Nbp.State, newBondYield: Rate)(using p: SimParams): NbfiResult =
val nbfiDepositRate = Rate(Math.max(0.0, postFxNbp.referenceRate.toDouble - NbfiDepositRateSpread))
val nbfiDepositRate = (postFxNbp.referenceRate - Rate(NbfiDepositRateSpread)).max(Rate.Zero)
val nbfiUnempRate = Ratio(1.0 - in.s2.employed.toDouble / in.w.totalPopulation)
val newNbfi =
if p.flags.nbfi then
Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/sfc/engine/steps/WorldAssemblyStep.scala
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ object WorldAssemblyStep:
mechanisms = MechanismsState(
macropru = in.s7.newMacropru,
expectations = in.s8.monetary.newExp,
bfgFundBalance = PLN(in.w.mechanisms.bfgFundBalance.toDouble + in.s9.bfgLevy.toDouble),
bfgFundBalance = in.w.mechanisms.bfgFundBalance + in.s9.bfgLevy,
informalCyclicalAdj = informal.cyclicalAdj,
effectiveShadowShare = informal.effectiveShadowShare,
),
Expand Down
4 changes: 2 additions & 2 deletions src/main/scala/sfc/init/FirmInit.scala
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,15 @@ object FirmInit:
val firmSize = FirmSizeDistribution.draw(rng)
val sizeMult = firmSize.toDouble / p.pop.workersPerFirm
val baseCash = rng.between(CashMin, CashMax) + (if rng.nextDouble() < LargeCashProb then LargeCashBonus else 0.0)
val dr = Math.max(DrFloor, Math.min(DrCap, sec.baseDigitalReadiness.toDouble + rng.nextGaussian() * DrNoise))
val dr = Ratio(sec.baseDigitalReadiness.toDouble + rng.nextGaussian() * DrNoise).clamp(Ratio(DrFloor), Ratio(DrCap))
Firm.State(
id = FirmId(i),
cash = PLN(baseCash * sizeMult),
debt = PLN.Zero,
tech = TechState.Traditional(firmSize),
riskProfile = Ratio(rng.between(RiskProfileMin, RiskProfileMax)),
innovationCostFactor = rng.between(InnovCostMin, InnovCostMax),
digitalReadiness = Ratio(dr),
digitalReadiness = dr,
sector = SectorIdx(sectorAssignments(i)),
neighbors = adjList(i).iterator.map(FirmId(_)).toVector,
bankId = BankId(0),
Expand Down
Loading
Loading