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

Commit 5be8c6e

Browse files
committed
Add tourism: inbound/outbound services trade with seasonality, ER sensitivity, COVID shock (#47)
1 parent 1ff111e commit 5be8c6e

File tree

8 files changed

+331
-21
lines changed

8 files changed

+331
-21
lines changed

src/main/scala/sfc/Main.scala

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def runSingle(seed: Int, rc: RunConfig): RunResult =
191191
// Housing: HPI, MarketValue, MortgageStock, MortgageRate, Origination,
192192
// Repayment, Default, MortgageInterest, HhHousingWealth,
193193
// HousingWealthEffect, MortgageToGdp
194-
val nCols = 190
194+
val nCols = 194
195195
val results = Array.ofDim[Double](Config.Duration, nCols)
196196

197197
for t <- 0 until Config.Duration do
@@ -494,7 +494,16 @@ def runSingle(seed: Int, rc: RunConfig): RunResult =
494494
},
495495
// Diaspora Remittances (#46)
496496
world.diasporaRemittanceInflow, // 188: DiasporaRemittanceInflow
497-
world.diasporaRemittanceInflow - world.immigration.remittanceOutflow // 189: NetRemittances
497+
world.diasporaRemittanceInflow - world.immigration.remittanceOutflow, // 189: NetRemittances
498+
// Tourism (#47)
499+
world.tourismExport, // 190: TourismExport
500+
world.tourismImport, // 191: TourismImport
501+
world.tourismExport - world.tourismImport, // 192: NetTourismBalance
502+
{ // 193: TourismSeasonalFactor
503+
val monthInYear = (t % 12) + 1
504+
1.0 + Config.TourismSeasonality *
505+
Math.cos(2 * Math.PI * (monthInYear - Config.TourismPeakMonth) / 12.0)
506+
}
498507
)
499508

500509
RunResult(results, world.hhAgg)
@@ -527,7 +536,7 @@ def runSingle(seed: Int, rc: RunConfig): RunResult =
527536

528537
// Aggregation arrays
529538
val nMonths = Config.Duration
530-
val nCols = 190
539+
val nCols = 194
531540
val allRuns = Array.ofDim[Double](nSeeds, nMonths, nCols)
532541
val allHhAgg = new Array[Option[HhAggregates]](nSeeds)
533542

@@ -597,7 +606,8 @@ def runSingle(seed: Int, rc: RunConfig): RunResult =
597606
"AggInventoryStock;InventoryChange;InventoryToGdp;" +
598607
"EffectiveShadowShare;TaxEvasionLoss;InformalEmployment;EvasionToGdpRatio;" +
599608
"AggEnergyCost;EnergyCostToGdp;EtsPrice;AggGreenCapital;GreenInvestment;GreenCapitalRatio;" +
600-
"DiasporaRemittanceInflow;NetRemittances\n")
609+
"DiasporaRemittanceInflow;NetRemittances;" +
610+
"TourismExport;TourismImport;NetTourismBalance;TourismSeasonalFactor\n")
601611
for seed <- 0 until nSeeds do
602612
val last = allRuns(seed)(nMonths - 1)
603613
termPw.write(s"${seed + 1}")
@@ -706,7 +716,8 @@ def runSingle(seed: Int, rc: RunConfig): RunResult =
706716
"AggInventoryStock", "InventoryChange", "InventoryToGdp",
707717
"EffectiveShadowShare", "TaxEvasionLoss", "InformalEmployment", "EvasionToGdpRatio",
708718
"AggEnergyCost", "EnergyCostToGdp", "EtsPrice", "AggGreenCapital", "GreenInvestment", "GreenCapitalRatio",
709-
"DiasporaRemittanceInflow", "NetRemittances")
719+
"DiasporaRemittanceInflow", "NetRemittances",
720+
"TourismExport", "TourismImport", "NetTourismBalance", "TourismSeasonalFactor")
710721
// Header: Month, then for each metric: mean, std, p05, p95
711722
aggPw.write("Month")
712723
for c <- 1 until nCols do

src/main/scala/sfc/config/SimConfig.scala

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,18 @@ object Config:
895895
val RemittanceGrowthRate: Double = sys.env.get("REMITTANCE_GROWTH_RATE").map(_.trim.toDouble).getOrElse(0.02)
896896
val RemittanceCyclicalSens: Double = sys.env.get("REMITTANCE_CYCLICAL_SENS").map(_.trim.toDouble).getOrElse(0.3)
897897

898+
// Tourism (#47)
899+
val TourismEnabled: Boolean = sys.env.get("TOURISM_ENABLED").map(_.trim.toBoolean).getOrElse(false)
900+
val TourismInboundShare: Double = sys.env.get("TOURISM_INBOUND_SHARE").map(_.trim.toDouble).getOrElse(0.05)
901+
val TourismOutboundShare: Double = sys.env.get("TOURISM_OUTBOUND_SHARE").map(_.trim.toDouble).getOrElse(0.03)
902+
val TourismErElasticity: Double = sys.env.get("TOURISM_ER_ELASTICITY").map(_.trim.toDouble).getOrElse(0.6)
903+
val TourismSeasonality: Double = sys.env.get("TOURISM_SEASONALITY").map(_.trim.toDouble).getOrElse(0.40)
904+
val TourismPeakMonth: Int = sys.env.get("TOURISM_PEAK_MONTH").map(_.trim.toInt).getOrElse(7)
905+
val TourismGrowthRate: Double = sys.env.get("TOURISM_GROWTH_RATE").map(_.trim.toDouble).getOrElse(0.03)
906+
val TourismShockMonth: Int = sys.env.get("TOURISM_SHOCK_MONTH").map(_.trim.toInt).getOrElse(0)
907+
val TourismShockSize: Double = sys.env.get("TOURISM_SHOCK_SIZE").map(_.trim.toDouble).getOrElse(0.80)
908+
val TourismShockRecovery: Double = sys.env.get("TOURISM_SHOCK_RECOVERY").map(_.trim.toDouble).getOrElse(0.03)
909+
898910
// Heterogeneous households (Paper-06)
899911
val HhCount = sys.env.get("HH_COUNT").map(_.trim.toInt).getOrElse(TotalPopulation)
900912

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package sfc.engine
33
import sfc.config.{Config, SECTORS, RunConfig}
44
import sfc.sfc.{ForexState, BopState}
55
import sfc.agents.CentralBankLogic
6+
import sfc.engine.KahanSum.*
67

78
case class OpenEconResult(
89
forex: ForexState,
@@ -25,7 +26,9 @@ object OpenEconomy:
2526
gvcIntermImports: Option[Vector[Double]] = None,
2627
remittanceOutflow: Double = 0.0,
2728
euFundsMonthly: Double = 0.0,
28-
diasporaInflow: Double = 0.0): OpenEconResult =
29+
diasporaInflow: Double = 0.0,
30+
tourismExport: Double = 0.0,
31+
tourismImport: Double = 0.0): OpenEconResult =
2932

3033
val nSectors = SECTORS.length
3134

@@ -45,6 +48,7 @@ object OpenEconomy:
4548
val exports = gvcExports.getOrElse {
4649
Config.OeExportBase * foreignGdpFactor * realExRate * ulcEffect
4750
}
51+
val totalExportsIncTourism = exports + tourismExport
4852

4953
// B. Imported intermediates (cross-border I-O)
5054
// Intermediate imports = real domestic output × import share × ER net effect.
@@ -61,13 +65,13 @@ object OpenEconomy:
6165
realOutput * Config.OeImportContent(s) * erNetEffect
6266
}.toVector
6367
}
64-
val totalImportedInterm = importedInterm.sum
68+
val totalImportedInterm = importedInterm.kahanSum
6569

6670
// C. Total imports
67-
val totalImports = importCons + techImports + totalImportedInterm
71+
val totalImports = importCons + techImports + totalImportedInterm + tourismImport
6872

6973
// D. Trade balance
70-
val tradeBalance = exports - totalImports
74+
val tradeBalance = totalExportsIncTourism - totalImports
7175

7276
// E. Current account
7377
val primaryIncome = prevBop.nfa * Config.OeNfaReturnRate / 12.0
@@ -112,7 +116,7 @@ object OpenEconomy:
112116
val newForeignAssets = prevBop.foreignAssets + Math.max(0.0, capitalAccount)
113117
val newForeignLiabilities = prevBop.foreignLiabilities + Math.max(0.0, -capitalAccount)
114118

115-
val newForex = ForexState(newExRate, totalImports, exports, tradeBalance, techImports)
119+
val newForex = ForexState(newExRate, totalImports, totalExportsIncTourism, tradeBalance, techImports)
116120

117121
val newBop = BopState(
118122
nfa = newNfa,
@@ -126,7 +130,7 @@ object OpenEconomy:
126130
fdi = fdi,
127131
portfolioFlows = portfolioFlows,
128132
reserves = prevBop.reserves + deltaReserves,
129-
exports = exports,
133+
exports = totalExportsIncTourism,
130134
totalImports = totalImports,
131135
importedIntermediates = totalImportedInterm
132136
)

src/main/scala/sfc/engine/Simulation.scala

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,27 @@ object Simulation:
500500
base * erAdj * trendAdj * cyclicalAdj
501501
else 0.0
502502

503+
// Tourism services export/import (#47)
504+
val (tourismExport, tourismImport) = if Config.TourismEnabled then
505+
val monthInYear = (m % 12) + 1
506+
val seasonalFactor = 1.0 + Config.TourismSeasonality *
507+
Math.cos(2 * Math.PI * (monthInYear - Config.TourismPeakMonth) / 12.0)
508+
val inboundErAdj = Math.pow(w.forex.exchangeRate / Config.BaseExRate, Config.TourismErElasticity)
509+
val outboundErAdj = Math.pow(Config.BaseExRate / w.forex.exchangeRate, Config.TourismErElasticity)
510+
val trendAdj = Math.pow(1.0 + Config.TourismGrowthRate / 12.0, m.toDouble)
511+
val disruption = if Config.TourismShockMonth > 0 && m >= Config.TourismShockMonth then
512+
Config.TourismShockSize * Math.pow(1.0 - Config.TourismShockRecovery,
513+
(m - Config.TourismShockMonth).toDouble)
514+
else 0.0
515+
val shockFactor = 1.0 - disruption
516+
val baseGdp = Math.max(0.0, w.gdpProxy)
517+
val inbound = Math.max(0.0, baseGdp * Config.TourismInboundShare *
518+
seasonalFactor * inboundErAdj * trendAdj * shockFactor)
519+
val outbound = Math.max(0.0, baseGdp * Config.TourismOutboundShare *
520+
seasonalFactor * outboundErAdj * trendAdj * shockFactor)
521+
(inbound, outbound)
522+
else (0.0, 0.0)
523+
503524
// Consumer credit flows
504525
val aggConsumerDS = w.bank.consumerLoans * (Config.CcAmortRate + (lendingBaseRate + Config.CcSpread) / 12.0)
505526
val aggConsumerOrig = domesticCons * 0.02 // ~2% of consumption financed by credit
@@ -629,7 +650,9 @@ object Simulation:
629650
gvcIntermImports = gvcImp,
630651
remittanceOutflow = remittanceOutflow,
631652
euFundsMonthly = euMonthly,
632-
diasporaInflow = diasporaInflow)
653+
diasporaInflow = diasporaInflow,
654+
tourismExport = tourismExport,
655+
tourismImport = tourismImport)
633656
(oeResult.forex, oeResult.bop, oeResult.valuationEffect, oeResult.fxIntervention)
634657
else
635658
val fx = Sectors.updateForeign(w.forex, importCons, totalTechAndInvImports, autoR, w.nbp.referenceRate, gdp, rc)
@@ -818,6 +841,7 @@ object Simulation:
818841
+ corpBondBankCoupon * 0.3,
819842
deposits = w.bank.deposits + (totalIncome - consumption) + jstDepositChange
820843
+ netDomesticDividends - foreignDividendOutflow - remittanceOutflow + diasporaInflow
844+
+ tourismExport - tourismImport
821845
+ consumerOrigination + insNetDepositChange + nbfiDepositDrain,
822846
consumerLoans = Math.max(0.0, w.bank.consumerLoans + consumerOrigination - consumerPrincipal - consumerDefaultAmt),
823847
consumerNpl = Math.max(0.0, w.bank.consumerNpl + consumerDefaultAmt - w.bank.consumerNpl * 0.05),
@@ -909,9 +933,11 @@ object Simulation:
909933
val bankDivOutflow = foreignDividendOutflow * ws
910934
val bankRemittance = remittanceOutflow * ws
911935
val bankDiasporaInflow = diasporaInflow * ws
936+
val bankTourismExport = tourismExport * ws
937+
val bankTourismImport = tourismImport * ws
912938
val bankInsDepChange = insNetDepositChange * ws
913939
val bankNbfiDepDrain = nbfiDepositDrain * ws
914-
val newDep = b.deposits + (bankIncomeShare - bankConsShare) + bankDivInflow - bankDivOutflow - bankRemittance + bankDiasporaInflow + bankCcOrig + bankInsDepChange + bankNbfiDepDrain
940+
val newDep = b.deposits + (bankIncomeShare - bankConsShare) + bankDivInflow - bankDivOutflow - bankRemittance + bankDiasporaInflow + bankTourismExport - bankTourismImport + bankCcOrig + bankInsDepChange + bankNbfiDepDrain
915941
// Per-bank mortgage flows (proportional to deposit share)
916942
val bankDepShare = if totalWorkers > 0 then perBankWorkers(bId) / totalWorkers else 0.0
917943
val bankMortgageIntIncome = mortgageInterestIncome * bankDepShare
@@ -1077,7 +1103,9 @@ object Simulation:
10771103
aggEnergyCost = sumEnergyCost,
10781104
aggGreenCapital = aggGreenCapital,
10791105
aggGreenInvestment = sumGreenInvestment,
1080-
diasporaRemittanceInflow = diasporaInflow)
1106+
diasporaRemittanceInflow = diasporaInflow,
1107+
tourismExport = tourismExport,
1108+
tourismImport = tourismImport)
10811109

10821110
// SFC accounting check: verify exact balance-sheet identities every step
10831111
val prevSnap = SfcCheck.snapshot(w, firms, households)
@@ -1137,7 +1165,9 @@ object Simulation:
11371165
nbfiDefaultAmount = finalNbfi.lastNbfiDefaultAmount,
11381166
fdiProfitShifting = sumProfitShifting,
11391167
fdiRepatriation = sumFdiRepatriation,
1140-
diasporaInflow = diasporaInflow
1168+
diasporaInflow = diasporaInflow,
1169+
tourismExport = tourismExport,
1170+
tourismImport = tourismImport
11411171
)
11421172
val sfcResult = SfcCheck.validate(m, prevSnap, currSnap, sfcFlows)
11431173
if !sfcResult.passed then

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,7 @@ case class World(
5353
aggEnergyCost: Double = 0.0,
5454
aggGreenCapital: Double = 0.0,
5555
aggGreenInvestment: Double = 0.0,
56-
diasporaRemittanceInflow: Double = 0.0
56+
diasporaRemittanceInflow: Double = 0.0,
57+
tourismExport: Double = 0.0,
58+
tourismImport: Double = 0.0
5759
)

src/main/scala/sfc/sfc/SfcCheck.scala

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ object SfcCheck:
8787
nbfiDefaultAmount: Double = 0.0, // #42: NBFI gross monthly defaults
8888
fdiProfitShifting: Double = 0.0, // #33: FDI profit shifting (service import)
8989
fdiRepatriation: Double = 0.0, // #33: FDI dividend repatriation (primary income debit)
90-
diasporaInflow: Double = 0.0 // #46: diaspora remittance inflow → deposit inflow
90+
diasporaInflow: Double = 0.0, // #46: diaspora remittance inflow → deposit inflow
91+
tourismExport: Double = 0.0, // #47: inbound tourism → deposit inflow + export
92+
tourismImport: Double = 0.0 // #47: outbound tourism → deposit outflow + import
9193
)
9294

9395
/** Result of the SFC check: ten exact balance-sheet identity checks. */
@@ -145,7 +147,7 @@ object SfcCheck:
145147
* The monetary circuit closes via sector-level flow-of-funds (Identity 10).
146148
*
147149
* 1. Bank capital: Δ = -nplLoss - mortgageNplLoss - consumerNplLoss + (interestIncome + hhDebtService + bankBondIncome + mortgageInterestIncome + consumerDebtService - depositInterestPaid + reserveInterest + standingFacilityIncome + interbankInterest) × 0.3
148-
* 2. Bank deposits: Δ = totalIncome - totalConsumption + jstDepositChange + dividendIncome - foreignDividendOutflow - remittanceOutflow + diasporaInflow + consumerOrigination + insNetDepositChange + nbfiDepositDrain
150+
* 2. Bank deposits: Δ = totalIncome - totalConsumption + jstDepositChange + dividendIncome - foreignDividendOutflow - remittanceOutflow + diasporaInflow + tourismExport - tourismImport + consumerOrigination + insNetDepositChange + nbfiDepositDrain
149151
* 3. Gov debt: Δ = govSpending - govRevenue (govRevenue includes dividendTax + zusGovSubvention)
150152
* 4. NFA: Δ = currentAccount + valuationEffect (currentAccount includes -foreignDividendOutflow, -fdiProfitShifting, -fdiRepatriation, +diasporaInflow)
151153
* 5. Bond clearing: bankBondHoldings + nbpBondHoldings + ppkBondHoldings + insuranceGovBondHoldings + tfiGovBondHoldings = bondsOutstanding
@@ -178,9 +180,10 @@ object SfcCheck:
178180
val actualBankCapChange = curr.bankCapital - prev.bankCapital
179181
val bankCapErr = actualBankCapChange - expectedBankCapChange
180182

181-
// Identity 2: Bank deposits (+ JST deposit flows + dividend flows - remittance outflow + diaspora inflow + consumer origination + insurance + NBFI)
183+
// Identity 2: Bank deposits (+ JST deposit flows + dividend flows - remittance outflow + diaspora inflow + tourism + consumer origination + insurance + NBFI)
182184
val expectedDepChange = flows.totalIncome - flows.totalConsumption + flows.jstDepositChange +
183185
flows.dividendIncome - flows.foreignDividendOutflow - flows.remittanceOutflow + flows.diasporaInflow +
186+
flows.tourismExport - flows.tourismImport +
184187
flows.consumerOrigination + flows.insNetDepositChange + flows.nbfiDepositDrain
185188
val actualDepChange = curr.bankDeposits - prev.bankDeposits
186189
val bankDepErr = actualDepChange - expectedDepChange

src/test/scala/sfc/engine/IntegrationSpec.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class IntegrationSpec extends AnyFlatSpec with Matchers:
1818
val result = runSingle(42, rc)
1919
result.timeSeries.length shouldBe Config.Duration
2020
for row <- result.timeSeries do
21-
row.length shouldBe 190
21+
row.length shouldBe 194
2222
}
2323

2424
it should "have Month column = 1..120" in {
@@ -44,7 +44,7 @@ class IntegrationSpec extends AnyFlatSpec with Matchers:
4444
it should "be reproducible with the same seed" in {
4545
val r1 = runSingle(42, rc)
4646
val r2 = runSingle(42, rc)
47-
for t <- 0 until Config.Duration; c <- 0 until 190 do
47+
for t <- 0 until Config.Duration; c <- 0 until 194 do
4848
r1.timeSeries(t)(c) shouldBe r2.timeSeries(t)(c)
4949
}
5050

0 commit comments

Comments
 (0)