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

Commit beb9c05

Browse files
committed
Jst opaque types + StepResult, FxInterventionResult PLN, BankRates Vector[Rate]
Jst.scala: - step params Double → PLN, remove pitRevenue default - (State, Double) return → StepResult named type - @param Scaladoc → inline comments - Extract FallbackPitRate constant Nbp.scala: - FxInterventionResult: eurTraded/newReserves → PLN Household.scala: - BankRates: Array[Double] → Vector[Rate]
1 parent bc56130 commit beb9c05

12 files changed

Lines changed: 108 additions & 108 deletions

src/main/scala/sfc/agents/Household.scala

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ enum HhStatus:
2121

2222
/** Per-bank lending and deposit rates for individual HH mode. */
2323
case class BankRates(
24-
lendingRates: Array[Double], // annual lending rate per bank (index = BankId)
25-
depositRates: Array[Double], // annual deposit rate per bank (index = BankId)
24+
lendingRates: Vector[Rate], // annual lending rate per bank (index = BankId)
25+
depositRates: Vector[Rate], // annual deposit rate per bank (index = BankId)
2626
)
2727

2828
/** Per-bank HH flow accumulator for multi-bank mode (one per BankId). */
@@ -466,7 +466,7 @@ object Household:
466466
rng: Random,
467467
)(using p: SimParams): CreditResult =
468468
val consumerRate = bankRates match
469-
case Some(br) => br.lendingRates(hh.bankId.toInt) + p.household.ccSpread.toDouble
469+
case Some(br) => br.lendingRates(hh.bankId.toInt).toDouble + p.household.ccSpread.toDouble
470470
case None => world.nbp.referenceRate.toDouble + p.household.ccSpread.toDouble
471471
val consumerDebtSvc = hh.consumerDebt * (p.household.ccAmortRate.toDouble + consumerRate / 12.0)
472472
val consumerPrin = hh.consumerDebt * p.household.ccAmortRate.toDouble
@@ -506,12 +506,12 @@ object Household:
506506

507507
// Variable-rate debt service (monetary transmission channel 1)
508508
val debtServiceRate = bankRates match
509-
case Some(br) => p.household.baseAmortRate.toDouble + br.lendingRates(hh.bankId.toInt) / 12.0
509+
case Some(br) => p.household.baseAmortRate.toDouble + br.lendingRates(hh.bankId.toInt).toDouble / 12.0
510510
case None => p.household.debtServiceRate.toDouble
511511

512512
// Deposit interest (monetary transmission channel 2)
513513
val depInterest = bankRates match
514-
case Some(br) => PLN(br.depositRates(hh.bankId.toInt) / 12.0 * hh.savings.toDouble)
514+
case Some(br) => PLN(br.depositRates(hh.bankId.toInt).toDouble / 12.0 * hh.savings.toDouble)
515515
case None => PLN.Zero
516516

517517
val grossIncome = baseIncome + depInterest.max(PLN.Zero)

src/main/scala/sfc/agents/Jst.scala

Lines changed: 38 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,42 +7,49 @@ import sfc.types.*
77
* tax, subventions/dotacje. JST deposits sit in commercial banks.
88
*/
99
object Jst:
10-
/** Compute JST monthly step.
11-
*
12-
* @param prev
13-
* previous JST state
14-
* @param govTaxRevenue
15-
* central government total tax revenue (CIT + VAT)
16-
* @param totalWageIncome
17-
* total wage income (for PIT proxy)
18-
* @param gdp
19-
* GDP proxy for subvention/dotacje
20-
* @param nFirms
21-
* number of living firms (for property tax)
22-
* @return
23-
* (newJstState, depositChange) where depositChange affects bank deposits
24-
* (SFC Identity 2)
10+
11+
// Fallback effective PIT rate when PIT mechanism disabled
12+
private val FallbackPitRate = 0.12
13+
14+
case class State(
15+
deposits: PLN, // JST deposits in commercial banks
16+
debt: PLN, // cumulative JST debt
17+
revenue: PLN, // this month's revenue
18+
spending: PLN, // this month's spending
19+
deficit: PLN, // spending − revenue (positive = deficit)
20+
)
21+
22+
object State:
23+
val zero: State = State(PLN.Zero, PLN.Zero, PLN.Zero, PLN.Zero, PLN.Zero)
24+
25+
/** Result of monthly JST step. */
26+
case class StepResult(
27+
state: State, // updated JST state
28+
depositChange: PLN, // effect on bank deposits (SFC Identity 2)
29+
)
30+
31+
/** Monthly JST step: revenue (PIT/CIT shares, property tax, subventions,
32+
* dotacje) → spending (revenue × mult) → deficit → deposit change.
2533
*/
2634
def step(
2735
prev: State,
28-
govTaxRevenue: Double,
29-
totalWageIncome: Double,
30-
gdp: Double,
31-
nFirms: Int,
32-
pitRevenue: Double = 0.0,
33-
)(using p: SimParams): (State, Double) =
34-
if !p.flags.jst then (prev, 0.0)
36+
govTaxRevenue: PLN, // central government total tax revenue (CIT + VAT)
37+
totalWageIncome: PLN, // total wage income (for PIT proxy)
38+
gdp: PLN, // GDP proxy for subvention/dotacje
39+
nFirms: Int, // number of living firms (for property tax)
40+
pitRevenue: PLN, // PIT revenue (zero when PIT mechanism disabled)
41+
)(using p: SimParams): StepResult =
42+
if !p.flags.jst then StepResult(prev, PLN.Zero)
3543
else
3644
// Revenue sources:
3745
// 1. PIT share: JST gets ~38.46% of PIT collected
38-
// When PIT mechanism enabled, use actual pitRevenue; otherwise proxy from wage income
3946
val jstPitIncome =
40-
if p.flags.pit && pitRevenue > 0 then pitRevenue * p.fiscal.jstPitShare.toDouble
41-
else totalWageIncome * 0.12 * p.fiscal.jstPitShare.toDouble // fallback proxy
47+
if p.flags.pit && pitRevenue > PLN.Zero then pitRevenue * p.fiscal.jstPitShare.toDouble
48+
else totalWageIncome * (FallbackPitRate * p.fiscal.jstPitShare.toDouble)
4249
// 2. CIT share: JST gets ~6.71% of CIT
43-
val citRevenue = govTaxRevenue * p.fiscal.jstCitShare.toDouble // govTaxRevenue includes CIT
50+
val citRevenue = govTaxRevenue * p.fiscal.jstCitShare.toDouble
4451
// 3. Property tax: fixed per firm per year
45-
val propertyTax = nFirms.toDouble * p.fiscal.jstPropertyTax.toDouble / 12.0
52+
val propertyTax = PLN(nFirms.toDouble * p.fiscal.jstPropertyTax.toDouble / 12.0)
4653
// 4. Subwencja oświatowa (education subvention): ~3% of GDP annually
4754
val subvention = gdp * p.fiscal.jstSubventionShare.toDouble / 12.0
4855
// 5. Dotacje celowe (targeted grants): ~1% of GDP annually
@@ -52,19 +59,8 @@ object Jst:
5259
// JST spending: revenue × spending multiplier (slightly > 1 → deficit bias)
5360
val totalSpending = totalRevenue * p.fiscal.jstSpendingMult
5461
val deficit = totalSpending - totalRevenue
55-
val newDebt = prev.debt + PLN(deficit)
56-
val depositChange = totalRevenue - totalSpending // negative when deficit (JST draws down deposits)
57-
val newDeposits = prev.deposits + PLN(depositChange)
58-
59-
(State(newDeposits, newDebt, PLN(totalRevenue), PLN(totalSpending), PLN(deficit)), depositChange)
60-
61-
case class State(
62-
deposits: PLN, // JST deposits in commercial banks
63-
debt: PLN, // cumulative JST debt
64-
revenue: PLN, // this month's revenue
65-
spending: PLN, // this month's spending
66-
deficit: PLN, // spending - revenue (positive = deficit)
67-
)
62+
val depositChange = totalRevenue - totalSpending // negative when deficit
63+
val newDeposits = prev.deposits + depositChange
64+
val newDebt = prev.debt + deficit
6865

69-
object State:
70-
val zero: State = State(PLN.Zero, PLN.Zero, PLN.Zero, PLN.Zero, PLN.Zero)
66+
StepResult(State(newDeposits, newDebt, totalRevenue, totalSpending, deficit), depositChange)

src/main/scala/sfc/agents/Nbp.scala

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ object Nbp:
4848

4949
/** FX intervention result. */
5050
case class FxInterventionResult(
51-
erEffect: Double, // added to erChange in OpenEconomy
52-
eurTraded: Double, // positive = bought EUR (weakened PLN), negative = sold EUR
53-
newReserves: Double, // updated reserve level
51+
erEffect: Double, // dimensionless ER change added to erChange in OpenEconomy
52+
eurTraded: PLN, // positive = bought EUR (weakened PLN), negative = sold EUR
53+
newReserves: PLN, // updated reserve level
5454
)
5555

5656
// ---------------------------------------------------------------------------
@@ -171,10 +171,10 @@ object Nbp:
171171
gdp: Double,
172172
enabled: Boolean,
173173
)(using p: SimParams): FxInterventionResult =
174-
if !enabled then FxInterventionResult(0.0, 0.0, reserves)
174+
if !enabled then FxInterventionResult(0.0, PLN.Zero, PLN(reserves))
175175
else
176176
val erDev = (prevER - p.forex.baseExRate) / p.forex.baseExRate
177-
if Math.abs(erDev) <= p.monetary.fxBand.toDouble then FxInterventionResult(0.0, 0.0, reserves)
177+
if Math.abs(erDev) <= p.monetary.fxBand.toDouble then FxInterventionResult(0.0, PLN.Zero, PLN(reserves))
178178
else
179179
val direction = -Math.signum(erDev)
180180
val maxByReserves = reserves * p.monetary.fxMaxMonthly.toDouble
@@ -185,4 +185,4 @@ object Nbp:
185185
val newReserves = reserves + eurTraded
186186
val gdpEffect = if gdp > 0 then Math.abs(eurTraded) * p.forex.baseExRate / gdp else 0.0
187187
val erEffect = direction * gdpEffect * p.monetary.fxStrength.toDouble
188-
FxInterventionResult(erEffect, eurTraded, Math.max(0.0, newReserves))
188+
FxInterventionResult(erEffect, PLN(eurTraded), PLN(Math.max(0.0, newReserves)))

src/main/scala/sfc/engine/markets/OpenEconomy.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ object OpenEconomy:
3737
bop: BopState,
3838
importedIntermediates: Vector[Double], // per-sector import cost (6 elements)
3939
valuationEffect: Double, // exact valuation effect used in NFA update
40-
fxIntervention: Nbp.FxInterventionResult = Nbp.FxInterventionResult(0.0, 0.0, 0.0),
40+
fxIntervention: Nbp.FxInterventionResult = Nbp.FxInterventionResult(0.0, PLN.Zero, PLN.Zero),
4141
)
4242

4343
def step(

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,16 +117,18 @@ object BankUpdateStep:
117117
val newGovWithYield = newGov.copy(bondYield = Rate(in.s8.newBondYield))
118118

119119
// JST (local government)
120-
val nLivingFirms = in.s5.ioFirms.count(Firm.isAlive)
121-
val (newJst, jstDepositChange) =
120+
val nLivingFirms = in.s5.ioFirms.count(Firm.isAlive)
121+
val jstResult =
122122
Jst.step(
123123
in.w.social.jst,
124-
newGovWithYield.taxRevenue.toDouble,
125-
in.s3.totalIncome,
126-
in.s7.gdp,
124+
newGovWithYield.taxRevenue,
125+
PLN(in.s3.totalIncome),
126+
PLN(in.s7.gdp),
127127
nLivingFirms,
128-
pitAfterEvasion,
128+
PLN(pitAfterEvasion),
129129
)
130+
val newJst = jstResult.state
131+
val jstDepositChange = jstResult.depositChange.toDouble
130132

131133
// ---- Housing market step ----
132134
val unempRate = 1.0 - in.s2.employed.toDouble / in.w.totalPopulation

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ object HouseholdIncomeStep:
4343
val nBanksHh = bsec.banks.length
4444
val hhBankRates = Some(
4545
BankRates(
46-
lendingRates = bsec.banks.zip(bsec.configs).map((b, cfg) => Banking.lendingRate(b, cfg, Rate(in.s1.lendingBaseRate)).toDouble).toArray,
47-
depositRates = bsec.banks.map(_ => Banking.hhDepositRate(in.w.nbp.referenceRate).toDouble).toArray,
46+
lendingRates = bsec.banks.zip(bsec.configs).map((b, cfg) => Banking.lendingRate(b, cfg, Rate(in.s1.lendingBaseRate))),
47+
depositRates = bsec.banks.map(_ => Banking.hhDepositRate(in.w.nbp.referenceRate)),
4848
),
4949
)
5050
val eqReturn = in.w.financial.equity.monthlyReturn.toDouble

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ object OpenEconomyStep:
105105
in.s7.gdp,
106106
in.rc,
107107
)
108-
(fx, in.w.bop, 0.0, Nbp.FxInterventionResult(0.0, 0.0, in.w.nbp.fxReserves.toDouble))
108+
(fx, in.w.bop, 0.0, Nbp.FxInterventionResult(0.0, PLN.Zero, in.w.nbp.fxReserves))
109109

110110
// Adjust BOP for foreign dividend outflow (primary income component) + EU funds tracking
111111
val newBop1 =
@@ -188,8 +188,8 @@ object OpenEconomyStep:
188188
val postQeNbp = qeResult.state
189189
val qePurchaseAmount = qeResult.purchased.toDouble
190190
val postFxNbp = postQeNbp.copy(
191-
fxReserves = PLN(fxResult.newReserves),
192-
lastFxTraded = PLN(fxResult.eurTraded),
191+
fxReserves = fxResult.newReserves,
192+
lastFxTraded = fxResult.eurTraded,
193193
)
194194

195195
// --- Corporate bond market step (#40) ---

src/test/scala/sfc/agents/FxInterventionPropertySpec.scala

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import org.scalacheck.Gen
44
import org.scalatest.flatspec.AnyFlatSpec
55
import org.scalatest.matchers.should.Matchers
66
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
7+
import sfc.types.*
78

89
class FxInterventionPropertySpec extends AnyFlatSpec with Matchers with ScalaCheckPropertyChecks:
910

@@ -25,15 +26,15 @@ class FxInterventionPropertySpec extends AnyFlatSpec with Matchers with ScalaChe
2526
"Nbp.fxIntervention (enabled)" should "never produce negative reserves" in
2627
forAll(genER, genReserves, genGdp) { (er, reserves, gdp) =>
2728
val result = fxEnabled(er, reserves, gdp)
28-
result.newReserves should be >= 0.0
29+
result.newReserves should be >= PLN.Zero
2930
}
3031

3132
it should "bound eurTraded by reserves" in
3233
forAll(genER, genReserves, genGdp) { (er, reserves, gdp) =>
3334
val result = fxEnabled(er, reserves, gdp)
3435
// When selling EUR (eurTraded < 0), magnitude <= reserves
3536
// When buying EUR (eurTraded > 0), magnitude <= reserves * maxMonthly
36-
Math.abs(result.eurTraded) should be <= (reserves + 1e-6)
37+
result.eurTraded.abs should be <= PLN(reserves + 1e-6)
3738
}
3839

3940
it should "have erEffect opposing deviation when outside band" in
@@ -51,8 +52,8 @@ class FxInterventionPropertySpec extends AnyFlatSpec with Matchers with ScalaChe
5152
forAll(genER, genReserves, genGdp) { (er, reserves, gdp) =>
5253
val result = Nbp.fxIntervention(er, reserves, gdp, enabled = false)
5354
result.erEffect shouldBe 0.0
54-
result.eurTraded shouldBe 0.0
55-
result.newReserves shouldBe reserves
55+
result.eurTraded shouldBe PLN.Zero
56+
result.newReserves shouldBe PLN(reserves)
5657
}
5758

5859
"Nbp.fxIntervention (enabled)" should "return zero effect when ER within band" in {
@@ -64,7 +65,7 @@ class FxInterventionPropertySpec extends AnyFlatSpec with Matchers with ScalaChe
6465
forAll(genERInBand, genReserves, genGdp) { (er, reserves, gdp) =>
6566
val result = fxEnabled(er, reserves, gdp)
6667
result.erEffect shouldBe 0.0
67-
result.eurTraded shouldBe 0.0
68+
result.eurTraded shouldBe PLN.Zero
6869
}
6970
}
7071

@@ -74,5 +75,5 @@ class FxInterventionPropertySpec extends AnyFlatSpec with Matchers with ScalaChe
7475
// newReserves = max(0, reserves + eurTraded)
7576
// When reserves + eurTraded >= 0: |newReserves - reserves| = |eurTraded|
7677
// Tolerance 1.0 for large magnitudes (~1e10), consistent with SFC check
77-
if result.newReserves > 0 then Math.abs(result.newReserves - reserves) shouldBe (Math.abs(result.eurTraded) +- 1.0)
78+
if result.newReserves > PLN.Zero then Math.abs(result.newReserves.toDouble - reserves) shouldBe (result.eurTraded.abs.toDouble +- 1.0)
7879
}

src/test/scala/sfc/agents/FxInterventionSpec.scala

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,24 @@ class FxInterventionSpec extends AnyFlatSpec with Matchers:
2020
// p.flags.nbpFxIntervention defaults to false
2121
val result = Nbp.fxIntervention(p.forex.baseExRate * 1.5, 1e10, 1e9, enabled = false)
2222
result.erEffect shouldBe 0.0
23-
result.eurTraded shouldBe 0.0
24-
result.newReserves shouldBe 1e10
23+
result.eurTraded shouldBe PLN.Zero
24+
result.newReserves shouldBe PLN(1e10)
2525
}
2626

2727
it should "return zero effect when ER within band" in {
2828
// ER deviation = 5% < default band of 10%
2929
val er = p.forex.baseExRate * 1.05
3030
val result = fxEnabled(er, 1e10, 1e9)
3131
result.erEffect shouldBe 0.0
32-
result.eurTraded shouldBe 0.0
32+
result.eurTraded shouldBe PLN.Zero
3333
}
3434

3535
it should "return zero effect when ER just inside band boundary" in {
3636
// Use 9.9% deviation (just inside default 10% band) to avoid FP edge case
3737
val er = p.forex.baseExRate * 1.099
3838
val result = fxEnabled(er, 1e10, 1e9)
3939
result.erEffect shouldBe 0.0
40-
result.eurTraded shouldBe 0.0
40+
result.eurTraded shouldBe PLN.Zero
4141
}
4242

4343
it should "intervene when PLN depreciates beyond band (sell EUR)" in {
@@ -46,9 +46,9 @@ class FxInterventionSpec extends AnyFlatSpec with Matchers:
4646
val er = p.forex.baseExRate * 1.20 // 20% depreciation
4747
val reserves = 1e10
4848
val result = fxEnabled(er, reserves, 1e9)
49-
result.eurTraded should be < 0.0 // sold EUR
49+
result.eurTraded should be < PLN.Zero // sold EUR
5050
result.erEffect should be < 0.0 // dampens upward ER deviation
51-
result.newReserves should be < reserves
51+
result.newReserves should be < PLN(reserves)
5252
}
5353

5454
it should "intervene when PLN appreciates beyond band (buy EUR)" in {
@@ -57,17 +57,17 @@ class FxInterventionSpec extends AnyFlatSpec with Matchers:
5757
val er = p.forex.baseExRate * 0.80 // 20% appreciation
5858
val reserves = 1e10
5959
val result = fxEnabled(er, reserves, 1e9)
60-
result.eurTraded should be > 0.0 // bought EUR
60+
result.eurTraded should be > PLN.Zero // bought EUR
6161
result.erEffect should be > 0.0 // dampens downward ER deviation
62-
result.newReserves should be > reserves
62+
result.newReserves should be > PLN(reserves)
6363
}
6464

6565
it should "not sell more EUR than available reserves" in {
6666
val er = p.forex.baseExRate * 1.50 // massive depreciation
6767
val reserves = 100.0 // tiny reserves
6868
val result = fxEnabled(er, reserves, 1e9)
69-
result.newReserves should be >= 0.0
70-
Math.abs(result.eurTraded) should be <= reserves
69+
result.newReserves should be >= PLN.Zero
70+
result.eurTraded.abs should be <= PLN(reserves)
7171
}
7272

7373
it should "produce erEffect opposing the deviation direction" in {
@@ -87,31 +87,31 @@ class FxInterventionSpec extends AnyFlatSpec with Matchers:
8787
val reserves = 1e10
8888
val result = fxEnabled(er, reserves, 1e9)
8989
// newReserves = max(0, reserves + eurTraded)
90-
result.newReserves shouldBe Math.max(0.0, reserves + result.eurTraded) +- 1e-6
90+
result.newReserves.toDouble shouldBe Math.max(0.0, reserves + result.eurTraded.toDouble) +- 1e-6
9191
}
9292

9393
it should "produce zero erEffect when gdp is zero (no div-by-zero)" in {
9494
val er = p.forex.baseExRate * 1.25
9595
val result = fxEnabled(er, 1e10, 0.0)
9696
result.erEffect shouldBe 0.0
9797
// But intervention still occurs (reserves change)
98-
result.eurTraded should be < 0.0
98+
result.eurTraded should be < PLN.Zero
9999
}
100100

101101
it should "produce no intervention when ER equals baseER (Eurozone scenario)" in {
102102
// In EUR regime, ER = baseER → erDev = 0 → within any band
103103
val result = fxEnabled(p.forex.baseExRate, 1e10, 1e9)
104104
result.erEffect shouldBe 0.0
105-
result.eurTraded shouldBe 0.0
105+
result.eurTraded shouldBe PLN.Zero
106106
}
107107

108108
// --- FxInterventionResult ---
109109

110110
"FxInterventionResult" should "be constructable with all fields" in {
111-
val r = Nbp.FxInterventionResult(0.01, -5e8, 9.5e9)
111+
val r = Nbp.FxInterventionResult(0.01, PLN(-5e8), PLN(9.5e9))
112112
r.erEffect shouldBe 0.01
113-
r.eurTraded shouldBe -5e8
114-
r.newReserves shouldBe 9.5e9
113+
r.eurTraded shouldBe PLN(-5e8)
114+
r.newReserves shouldBe PLN(9.5e9)
115115
}
116116

117117
// --- NbpState FX fields ---

0 commit comments

Comments
 (0)