Source code for bamengine.events.goods_market
"""
Goods market events for consumption decisions and shopping.
This module defines the goods market phase events that execute after production.
Households calculate consumption propensity, allocate income to spending,
select firms to visit, and purchase goods through sequential shopping.
Event Sequence
--------------
The goods market events execute in this order:
1. ConsumersCalcPropensity - Calculate propensity to consume based on savings
2. ConsumersDecideIncomeToSpend - Allocate income to spending budget
3. ConsumersDecideFirmsToVisit - Select firms to visit (sorted by price)
4. GoodsMarketRound - Batch-sequential shopping (handles all Z rounds internally)
5. ConsumersFinalizePurchases - Move unspent budget back to savings
Design Notes
------------
- Events operate on consumer and producer roles (Consumer, Producer)
- Propensity to consume: c = 1 / (1 + tanh(SA/SA_avg)^β)
- Loyalty rule: consumers visit previous largest producer first (if inventory available)
- Remaining Z-1 firms selected randomly (preferential attachment mechanism)
- Batch-sequential processing: consumers are shuffled and divided into batches,
each completing all Z visits before the next batch starts, preserving sequential
depletion dynamics while using vectorized NumPy operations within each batch
Examples
--------
Execute goods market events:
>>> import bamengine as be
>>> sim = be.Simulation.init(n_firms=100, n_households=500, seed=42)
>>> # Goods market events run as part of default pipeline
>>> sim.step()
Execute individual goods market event:
>>> event = sim.get_event("consumers_calc_propensity")
>>> event.execute(sim)
>>> sim.con.propensity.mean() # doctest: +SKIP
0.65
Check consumption:
>>> total_spent = sim.con.total_spent.sum()
>>> total_spent # doctest: +SKIP
2850.0
See Also
--------
bamengine.events._internal.goods_market : System function implementations
Consumer : Consumption state
Producer : Production state with inventory
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from bamengine.core.decorators import event
if TYPE_CHECKING: # pragma: no cover
from bamengine.simulation import Simulation
[docs]
@event
class ConsumersCalcPropensity:
"""
Calculate marginal propensity to consume based on relative savings.
Households with below-average savings have higher propensity to consume
(spend more), while those with above-average savings have lower propensity
(save more). This implements consumption smoothing behavior.
Algorithm
---------
For each consumer j:
1. Calculate relative savings: :math:`r_j = SA_j / \\overline{SA}`
2. Apply propensity function: :math:`c_j = 1 / (1 + \\tanh(r_j)^\\beta)`
Mathematical Notation
---------------------
.. math::
c_j = \\frac{1}{1 + \\tanh\\left(\\frac{SA_j}{\\overline{SA}}\\right)^\\beta}
where:
- :math:`c_j`: propensity to consume (:math:`0 < c_j < 1`)
- :math:`SA_j`: current savings of consumer j
- :math:`\\overline{SA}`: average savings across all consumers
- :math:`\\beta`: sensitivity parameter controlling consumption response (config)
Examples
--------
>>> import bamengine as be
>>> sim = be.Simulation.init(n_households=500, seed=42)
>>> event = sim.get_event("consumers_calc_propensity")
>>> event.execute(sim)
Check propensity distribution:
>>> sim.con.propensity.mean() # doctest: +SKIP
0.65
Verify range:
>>> import numpy as np
>>> (sim.con.propensity > 0).all() and (sim.con.propensity < 1).all()
True
High-savers have lower propensity:
>>> high_savers = sim.con.savings > sim.con.savings.mean()
>>> low_savers = sim.con.savings < sim.con.savings.mean()
>>> sim.con.propensity[low_savers].mean() > sim.con.propensity[high_savers].mean()
True
Notes
-----
This event must execute first in goods market phase.
Propensity is bounded: 0 < c < 1 (consumers always save something, always spend something).
Higher β increases sensitivity to relative savings position.
See Also
--------
ConsumersDecideIncomeToSpend : Uses propensity to allocate spending
bamengine.events._internal.goods_market.consumers_calc_propensity : Implementation
"""
[docs]
def execute(self, sim: Simulation) -> None:
from bamengine.events._internal.goods_market import consumers_calc_propensity
_avg_sav = float(sim.con.savings.mean())
consumers_calc_propensity(sim.con, avg_sav=_avg_sav, beta=sim.config.beta)
[docs]
@event
class ConsumersDecideIncomeToSpend:
"""
Allocate wealth to spending budget based on propensity to consume.
Consumers combine their savings and income into total wealth, then allocate
a portion to spending based on their propensity. The remainder stays as savings.
Algorithm
---------
For each consumer j:
1. Calculate wealth: :math:`W_j = SA_j + I_j`
2. Allocate to spending: :math:`B_j = W_j \\times c_j`
3. Update savings: :math:`SA_j = W_j - B_j`
4. Reset income: :math:`I_j = 0`
Mathematical Notation
---------------------
.. math::
W_j = SA_j + I_j
B_j = W_j \\times c_j
SA_j = W_j - B_j = W_j(1 - c_j)
I_j \\leftarrow 0
Examples
--------
>>> import bamengine as be
>>> sim = be.Simulation.init(n_households=500, seed=42)
>>> # First set propensity
>>> sim.get_event("consumers_calc_propensity")().execute(sim)
>>> # Then allocate spending
>>> initial_wealth = sim.con.savings + sim.con.income
>>> event = sim.get_event("consumers_decide_income_to_spend")
>>> event.execute(sim)
Check spending budget:
>>> sim.con.income_to_spend.sum() # doctest: +SKIP
2950.0
Verify wealth conservation:
>>> import numpy as np
>>> final_wealth = sim.con.savings + sim.con.income_to_spend
>>> np.allclose(initial_wealth, final_wealth)
True
Income reset:
>>> (sim.con.income == 0).all()
True
Notes
-----
This event must execute after ConsumersCalcPropensity (need propensity values).
Wealth is conserved: initial_wealth = final_savings + spending_budget.
Income is reset to 0 after allocation (will accumulate again next period).
See Also
--------
ConsumersCalcPropensity : Calculates propensity used for allocation
GoodsMarketRound : Uses income_to_spend as shopping budget
bamengine.events._internal.goods_market.consumers_decide_income_to_spend : Implementation
"""
[docs]
def execute(self, sim: Simulation) -> None:
from bamengine.events._internal.goods_market import (
consumers_decide_income_to_spend,
)
consumers_decide_income_to_spend(sim.con)
[docs]
@event
class ConsumersDecideFirmsToVisit:
"""
Consumers select firms to visit and set loyalty BEFORE shopping.
Consumers with spending budget build a shopping queue by:
1. Placing their loyalty firm (previous largest producer) in slot 0
2. Randomly sampling Z-1 additional firms from those with inventory
3. Sorting the randomly sampled firms by price (cheapest first)
4. Setting loyalty to the LARGEST producer in the consideration set
This implements the book's preferential attachment (PA) mechanism matching
the reference implementation. The key insight is that loyalty is
updated BEFORE shopping based on the consideration set, not during shopping
based on purchases. This allows the "rich get richer" dynamics to emerge.
Algorithm
---------
For each consumer j with B_j > 0 (spending budget):
1. Apply loyalty rule first:
- If prev_largest_producer has inventory: place in slot 0
2. Sample remaining slots randomly from firms with inventory
3. Sort sampled firms by price (cheapest first) for shopping order
4. Update loyalty to largest producer in consideration set (BEFORE shopping)
Examples
--------
>>> import bamengine as be
>>> sim = be.Simulation.init(n_firms=100, n_households=500, seed=42)
>>> # Allocate spending first
>>> sim.get_event("consumers_decide_income_to_spend")().execute(sim)
>>> # Then select firms
>>> event = sim.get_event("consumers_decide_firms_to_visit")
>>> event.execute(sim)
Check consumers with shopping plans:
>>> import numpy as np
>>> has_budget = sim.con.income_to_spend > 0
>>> has_budget.sum() # doctest: +SKIP
480
Notes
-----
This event must execute after ConsumersDecideIncomeToSpend (need spending budget).
Only consumers with positive spending budget prepare shopping queues.
The preferential attachment mechanism works as follows:
- Consumers track the "largest producer" in their consideration set
- Loyalty is updated BEFORE shopping to the largest in consideration set
- This firm is visited first (if it has inventory), minimizing rationing risk
- Even if consumer buys from cheap small firms, they track the large firm
- Over time, large firms accumulate more loyal customers, creating "rich get richer" dynamics
- This leads to emergent firm size heterogeneity and business cycle fluctuations
See Also
--------
GoodsMarketRound : Processes shopping queue
bamengine.events._internal.goods_market.consumers_decide_firms_to_visit : Implementation
"""
[docs]
def execute(self, sim: Simulation) -> None:
from bamengine.events._internal.goods_market import (
consumers_decide_firms_to_visit,
)
consumers_decide_firms_to_visit(
sim.con,
sim.prod,
max_Z=sim.config.max_Z,
rng=sim.rng,
consumer_matching=sim.config.consumer_matching,
)
[docs]
@event
class ConsumersFinalizePurchases:
"""
Return unspent budget to savings after shopping rounds complete.
Any budget remaining after all shopping rounds is moved back to savings.
This ensures wealth conservation: no money is lost during shopping.
Algorithm
---------
For each consumer j:
.. math::
SA_j \\leftarrow SA_j + B_j
B_j \\leftarrow 0
where :math:`SA_j` = savings, :math:`B_j` = income_to_spend (remaining budget).
Examples
--------
>>> import bamengine as be
>>> sim = be.Simulation.init(n_households=500, seed=42)
>>> # Shop first
>>> sim.get_event("goods_market_round")().execute(sim)
>>> # Track unspent
>>> unspent = sim.con.income_to_spend.copy()
>>> initial_savings = sim.con.savings.copy()
>>> # Finalize
>>> event = sim.get_event("consumers_finalize_purchases")
>>> event.execute(sim)
Verify unspent returned to savings:
>>> import numpy as np
>>> np.allclose(sim.con.savings, initial_savings + unspent)
True
Budget cleared:
>>> (sim.con.income_to_spend == 0).all()
True
Notes
-----
This event must execute after GoodsMarketRound completes.
Wealth conservation: unspent budget → savings (no money vanishes).
Consumers with zero unspent budget are unaffected (savings unchanged).
See Also
--------
GoodsMarketRound : Spends budget during shopping
ConsumersDecideIncomeToSpend : Initially allocates budget from wealth
bamengine.events._internal.goods_market.consumers_finalize_purchases : Implementation
"""
[docs]
def execute(self, sim: Simulation) -> None:
from bamengine.events._internal.goods_market import consumers_finalize_purchases
consumers_finalize_purchases(sim.con)
[docs]
@event
class GoodsMarketRound:
"""Sequential goods market matching.
Consumers are shuffled and each completes all Z shopping visits
before the next consumer starts. This event is called once in
the pipeline (it handles all Z rounds internally).
See Also
--------
bamengine.events._internal.goods_market.goods_market_round :
Implementation
"""
[docs]
def execute(self, sim: Simulation) -> None:
from bamengine.events._internal.goods_market import goods_market_round
goods_market_round(
sim.con,
sim.prod,
max_Z=sim.config.max_Z,
rng=sim.rng,
)