Source code for bamengine.relationships.loanbook

"""
LoanBook relationship for tracking loans between borrowers and lenders.

This module implements the LoanBook relationship, a many-to-many connection
between Borrower (firms) and Lender (banks) roles. It uses COO (Coordinate List)
sparse format to efficiently store active loan contracts.

Design Philosophy
-----------------
The LoanBook uses a sparse edge-list representation rather than a dense
(n_firms × n_banks) matrix. This design choice provides:

1. **Memory efficiency**: O(active_loans) instead of O(n_firms × n_banks)
2. **Cache-friendly**: Sequential access patterns for vectorized operations
3. **Dynamic growth**: Amortized O(1) append via doubling strategy
4. **Fast aggregation**: Vectorized sums using np.bincount and np.add.at

COO Format Structure
--------------------
The LoanBook stores five parallel arrays:

- source_ids (borrower IDs)
- target_ids (lender IDs)
- principal (loan amounts)
- rate (interest rates)
- interest (cached: rate × principal)
- debt (cached: principal × (1 + rate))

Only the first `size` entries in each array are valid. The remaining entries
up to `capacity` are pre-allocated but unused.

Examples
--------
Create empty LoanBook:

>>> from bamengine.relationships import LoanBook
>>> loans = LoanBook()
>>> loans.size
0
>>> loans.capacity
128

Append loans:

>>> import numpy as np
>>> loans.append_loans_for_lender(
...     lender_idx=0,
...     borrower_indices=np.array([1, 2, 3]),
...     amount=np.array([100.0, 150.0, 200.0]),
...     rate=np.array([0.02, 0.03, 0.02]),
... )
>>> loans.size
3

Aggregate debt by borrower:

>>> debt_per_borrower = loans.debt_per_borrower(n_borrowers=10)
>>> debt_per_borrower.shape
(10,)

Purge loans from bankrupt firms:

>>> bankrupt_firms = np.array([1, 5, 7])
>>> removed = loans.purge_borrowers(bankrupt_firms)
>>> removed  # doctest: +SKIP
1

See Also
--------
:class:`~bamengine.core.relationship.Relationship` : Base class with query methods
:class:`~bamengine.roles.borrower.Borrower` : Source role (firms)
:class:`~bamengine.roles.lender.Lender` : Target role (banks)
"""

from __future__ import annotations

from dataclasses import field

import numpy as np

from bamengine.core import get_role, relationship
from bamengine.typing import Bool1D, Float1D, Idx1D, Int1D


# Use @relationship decorator to define LoanBook as a Relationship between
# Borrower (source) and Lender (target) roles
# Note: We use lazy imports above to avoid circular import issues
[docs] @relationship( source=get_role("Borrower"), target=get_role("Lender"), cardinality="many-to-many", name="LoanBook", ) class LoanBook: # noinspection PyUnresolvedReferences """ Sparse edge-list ledger for managing active loan contracts. LoanBook is a Relationship between Borrower (source/firms) and Lender (target/banks), storing loan contracts in COO (Coordinate List) sparse format. This avoids the memory overhead of a dense (n_firms × n_banks) matrix while enabling efficient vectorized operations. Inherits from Relationship base class, which provides: - source_ids (borrower indices) - target_ids (lender indices) - size (number of active loans) - capacity (allocated storage) - Query methods (query_sources, query_targets) - Aggregation methods (aggregate_by_source, aggregate_by_target) - Deletion methods (drop_rows, purge_sources, purge_targets) Parameters ---------- principal : Float1D Loan principal amounts (original loan amounts at signing). rate : Float1D Contractual interest rates for each loan. interest : Float1D Cached interest amounts (rate × principal), enables O(1) aggregation. debt : Float1D Cached total debt (principal × (1 + rate)), enables O(1) aggregation. source_ids : Idx1D Borrower (firm) IDs for each loan. target_ids : Idx1D Lender (bank) IDs for each loan. size : int Number of active loans (valid entries in arrays). capacity : int Allocated array size (grows via doubling when exceeded). Examples -------- Access from simulation: >>> import bamengine as be >>> sim = be.Simulation.init(n_firms=100, n_banks=10, seed=42) >>> loans = sim.loans >>> loans.size # doctest: +SKIP 45 Append new loans from bank 0 to firms 1, 2, 3: >>> import numpy as np >>> loans.append_loans_for_lender( ... lender_idx=0, ... borrower_indices=np.array([1, 2, 3]), ... amount=np.array([100.0, 150.0, 200.0]), ... rate=np.array([0.02, 0.03, 0.02]), ... ) Query loans for specific borrower: >>> borrower_id = 5 >>> loan_mask = loans.source_ids[: loans.size] == borrower_id >>> borrower_loans = loans.principal[: loans.size][loan_mask] >>> borrower_loans.sum() # doctest: +SKIP 250.0 Aggregate total debt per borrower: >>> total_debt = loans.debt_per_borrower(n_borrowers=100) >>> total_debt.shape (100,) >>> total_debt[5] # doctest: +SKIP 255.0 Aggregate total interest per borrower: >>> total_interest = loans.interest_per_borrower(n_borrowers=100) >>> total_interest[5] # doctest: +SKIP 5.0 Purge loans from bankrupt firms: >>> bankrupt = np.array([1, 7, 12]) >>> removed = loans.purge_borrowers(bankrupt) >>> removed # doctest: +SKIP 3 Notes ----- **Memory Efficiency**: For 100 firms, 10 banks, and 50 active loans: - Dense matrix: 100 × 10 × 4 fields × 8 bytes = 32,000 bytes - Sparse COO: 50 × 6 arrays × 8 bytes = 2,400 bytes (~13x smaller) **Performance**: Aggregation operations use vectorized NumPy primitives: - debt_per_borrower: O(size) using np.add.at - purge_borrowers: O(size) using np.isin and boolean indexing - append_loans: O(1) amortized via doubling strategy **Backward Compatibility**: The borrower and lender properties provide aliases for source_ids and target_ids to maintain compatibility with existing code that predates the Relationship abstraction. See Also -------- :class:`~bamengine.core.relationship.Relationship` : Base class with query/aggregation :class:`~bamengine.roles.borrower.Borrower` : Source role (firms seeking credit) :class:`~bamengine.roles.lender.Lender` : Target role (banks providing credit) :mod:`~bamengine.events.credit_market` : Loan creation logic :mod:`~bamengine.events.revenue` : Debt repayment logic """ # Edge-specific components (loan data per edge) principal: Float1D = field(default_factory=lambda: np.empty(0, np.float64)) """Loan principal amounts (original loan amounts at signing).""" rate: Float1D = field(default_factory=lambda: np.empty(0, np.float64)) """Contractual interest rates for each loan.""" interest: Float1D = field(default_factory=lambda: np.empty(0, np.float64)) """Cached interest amounts (rate * principal).""" debt: Float1D = field(default_factory=lambda: np.empty(0, np.float64)) """Cached total debt (principal * (1 + rate)).""" # Default values for base class fields (from Relationship) # These must come after edge components due to dataclass field ordering source_ids: Idx1D = field(default_factory=lambda: np.empty(0, np.int64)) """Borrower (firm) IDs for each loan.""" target_ids: Idx1D = field(default_factory=lambda: np.empty(0, np.int64)) """Lender (bank) IDs for each loan.""" size: int = 0 """Number of active loans (valid entries in arrays).""" capacity: int = 128 """Allocated array size (grows via doubling when exceeded).""" # Backward compatibility aliases for existing code @property def borrower(self) -> Int1D: """ Alias for source_ids (borrower firm indices). Provides backward compatibility with code written before the Relationship abstraction. New code should use source_ids directly. Returns ------- Int1D Array of borrower indices (same as source_ids). Examples -------- >>> from bamengine.relationships import LoanBook >>> loans = LoanBook() >>> loans.borrower is loans.source_ids True """ return self.source_ids @borrower.setter def borrower(self, value: Int1D) -> None: """ Setter for borrower alias (updates source_ids). Parameters ---------- value : Int1D New borrower indices array. """ self.source_ids = value @property def lender(self) -> Int1D: """ Alias for target_ids (lender bank indices). Provides backward compatibility with code written before the Relationship abstraction. New code should use target_ids directly. Returns ------- Int1D Array of lender indices (same as target_ids). Examples -------- >>> from bamengine.relationships import LoanBook >>> loans = LoanBook() >>> loans.lender is loans.target_ids True """ return self.target_ids @lender.setter def lender(self, value: Int1D) -> None: """ Setter for lender alias (updates target_ids). Parameters ---------- value : Int1D New lender indices array. """ self.target_ids = value def _ensure_capacity(self, extra: int) -> None: """ Ensure capacity for additional edges, resizing arrays if needed. Uses a doubling strategy: when capacity is exceeded, doubles the current capacity or sets to max(128, needed), whichever is larger. This provides amortized O(1) append complexity. Parameters ---------- extra : int Number of additional edges to accommodate. Notes ----- This is an internal method called by append_loans_for_lender and drop_rows. It resizes all six parallel arrays (source_ids, target_ids, principal, rate, interest, debt) to maintain consistency. The doubling strategy ensures: - O(1) amortized append time - O(log n) total resize operations for n appends - Predictable memory allocation pattern Examples -------- >>> from bamengine.relationships import LoanBook >>> loans = LoanBook() >>> loans.capacity 128 >>> loans._ensure_capacity(150) # Need 150 total slots >>> loans.capacity 256 """ needed = self.size + extra if needed <= self.capacity: new_cap = self.capacity else: new_cap = max(self.capacity * 2, needed, 128) # Resize base class arrays (source_ids, target_ids) for name in ("source_ids", "target_ids"): arr = getattr(self, name) if arr.size != new_cap: # only when really needed new_arr = np.resize(arr, new_cap) setattr(self, name, new_arr) # Resize edge-specific component arrays for name in ("principal", "rate", "interest", "debt"): arr = getattr(self, name) if arr.size != new_cap: # only when really needed new_arr = np.resize(arr, new_cap) setattr(self, name, new_arr) self.capacity = new_cap # sanity check assert all( getattr(self, n).size == new_cap for n in ( "source_ids", "target_ids", "principal", "rate", "interest", "debt", ) ) # ------------------------------------------------------------------ # # API (using Relationship base methods) # # ------------------------------------------------------------------ #
[docs] def debt_per_borrower(self, n_borrowers: int) -> Float1D: """ Aggregate total debt per borrower using vectorized summation. Uses the inherited aggregate_by_source method from Relationship base class, which employs np.add.at for efficient aggregation. Parameters ---------- n_borrowers : int Number of borrowers in the simulation (typically n_firms). Returns ------- Float1D Array of shape (n_borrowers,) containing total debt per borrower. Borrowers with no loans have debt = 0.0. Examples -------- >>> from bamengine.relationships import LoanBook >>> import numpy as np >>> loans = LoanBook() >>> loans.append_loans_for_lender( ... lender_idx=0, ... borrower_indices=np.array([1, 1, 3]), ... amount=np.array([100.0, 50.0, 200.0]), ... rate=np.array([0.02, 0.03, 0.02]), ... ) >>> debt = loans.debt_per_borrower(n_borrowers=5) >>> debt.shape (5,) >>> debt[1] # doctest: +SKIP 154.5 >>> debt[3] # doctest: +SKIP 204.0 See Also -------- interest_per_borrower : Aggregate interest per borrower bamengine.core.relationship.Relationship.aggregate_by_source : Base method """ return self.aggregate_by_source(self.debt, func="sum", n_sources=n_borrowers) # type: ignore[no-any-return, attr-defined]
[docs] def interest_per_borrower(self, n_borrowers: int) -> Float1D: """ Aggregate total interest per borrower using vectorized summation. Uses the inherited aggregate_by_source method from Relationship base class, which employs np.add.at for efficient aggregation. Parameters ---------- n_borrowers : int Number of borrowers in the simulation (typically n_firms). Returns ------- Float1D Array of shape (n_borrowers,) containing total interest per borrower. Borrowers with no loans have interest = 0.0. Examples -------- >>> from bamengine.relationships import LoanBook >>> import numpy as np >>> loans = LoanBook() >>> loans.append_loans_for_lender( ... lender_idx=0, ... borrower_indices=np.array([1, 1, 3]), ... amount=np.array([100.0, 50.0, 200.0]), ... rate=np.array([0.02, 0.03, 0.02]), ... ) >>> interest = loans.interest_per_borrower(n_borrowers=5) >>> interest.shape (5,) >>> interest[1] # doctest: +SKIP 3.5 >>> interest[3] # doctest: +SKIP 4.0 See Also -------- debt_per_borrower : Aggregate debt per borrower bamengine.core.relationship.Relationship.aggregate_by_source : Base method """ return self.aggregate_by_source( # type: ignore[no-any-return, attr-defined] self.interest, func="sum", n_sources=n_borrowers )
[docs] def principal_per_borrower(self, n_borrowers: int) -> Float1D: """ Aggregate total principal per borrower using vectorized summation. Uses the inherited aggregate_by_source method from Relationship base class, which employs np.add.at for efficient aggregation. Parameters ---------- n_borrowers : int Number of borrowers in the simulation (typically n_firms). Returns ------- Float1D Array of shape (n_borrowers,) containing total principal per borrower. Borrowers with no loans have principal = 0.0. Examples -------- >>> from bamengine.relationships import LoanBook >>> import numpy as np >>> loans = LoanBook() >>> loans.append_loans_for_lender( ... lender_idx=0, ... borrower_indices=np.array([1, 1, 3]), ... amount=np.array([100.0, 50.0, 200.0]), ... rate=np.array([0.02, 0.03, 0.02]), ... ) >>> principal = loans.principal_per_borrower(n_borrowers=5) >>> principal.shape (5,) >>> principal[1] # doctest: +SKIP 150.0 >>> principal[3] # doctest: +SKIP 200.0 See Also -------- debt_per_borrower : Aggregate debt per borrower interest_per_borrower : Aggregate interest per borrower bamengine.core.relationship.Relationship.aggregate_by_source : Base method """ return self.aggregate_by_source( # type: ignore[no-any-return, attr-defined] self.principal, func="sum", n_sources=n_borrowers )
[docs] def append_loans_for_lender( self, lender_idx: np.intp, borrower_indices: Idx1D, amount: Float1D, rate: Float1D, ) -> None: """ Append new loans from a specific lender to multiple borrowers. Automatically resizes arrays if needed using doubling strategy. Caches interest and debt for O(1) aggregation later. Parameters ---------- lender_idx : np.intp Index of the lender providing loans (bank ID). borrower_indices : Idx1D Indices of borrowers receiving loans (firm IDs). amount : Float1D Principal amounts for each loan. rate : Float1D Interest rates for each loan. Notes ----- This method: 1. Ensures capacity via _ensure_capacity (may trigger resize) 2. Appends source_ids (borrowers), target_ids (lender) 3. Appends principal and rate 4. Caches interest = amount × rate 5. Caches debt = amount × (1 + rate) 6. Updates size counter The lender_idx is broadcast to all new loan entries (scalar expansion). Examples -------- >>> from bamengine.relationships import LoanBook >>> import numpy as np >>> loans = LoanBook() >>> loans.append_loans_for_lender( ... lender_idx=0, ... borrower_indices=np.array([1, 2, 3]), ... amount=np.array([100.0, 150.0, 200.0]), ... rate=np.array([0.02, 0.03, 0.02]), ... ) >>> loans.size 3 >>> loans.source_ids[:3] array([1, 2, 3]) >>> loans.target_ids[:3] array([0, 0, 0]) >>> loans.principal[:3] array([100., 150., 200.]) See Also -------- _ensure_capacity : Internal resize method """ self._ensure_capacity(amount.size) start, stop = self.size, self.size + amount.size # Use base class fields (source_ids, target_ids) self.source_ids[start:stop] = borrower_indices self.target_ids[start:stop] = lender_idx # ← scalar broadcast # Set edge-specific components self.principal[start:stop] = amount self.rate[start:stop] = rate self.interest[start:stop] = amount * rate self.debt[start:stop] = amount * (1.0 + rate) self.size = stop
[docs] def append_loans_batch( self, lender_indices: Idx1D, borrower_indices: Idx1D, amount: Float1D, rate: Float1D, ) -> None: """Append loans from multiple lenders in a single batch. Unlike :meth:`append_loans_for_lender` which takes a scalar lender, this method takes an array of lender IDs — one per loan. Used by the vectorized credit market to write all loans from one round at once. Parameters ---------- lender_indices : Idx1D Lender (bank) ID for each loan. borrower_indices : Idx1D Borrower (firm) ID for each loan. amount : Float1D Principal amounts. rate : Float1D Interest rates. """ self._ensure_capacity(amount.size) start, stop = self.size, self.size + amount.size self.source_ids[start:stop] = borrower_indices self.target_ids[start:stop] = lender_indices self.principal[start:stop] = amount self.rate[start:stop] = rate self.interest[start:stop] = amount * rate self.debt[start:stop] = amount * (1.0 + rate) self.size = stop
[docs] def drop_rows(self, rows_mask: Bool1D) -> int: """ Remove loans matching a boolean mask and compact arrays in-place. Overrides Relationship.drop_rows() to also compact loan-specific component arrays (principal, rate, interest, debt). Parameters ---------- rows_mask : Bool1D Boolean mask over active loans (length >= size). True → loan will be removed, False → loan kept. Returns ------- int Number of loans removed. Notes ----- This method: 1. Inverts mask to get loans to keep 2. Compacts all six arrays (source_ids, target_ids, principal, rate, interest, debt) 3. Updates size counter 4. Returns number of removed loans The compaction is done in-place using boolean indexing, which is cache-friendly and avoids temporary array allocations. Examples -------- >>> from bamengine.relationships import LoanBook >>> import numpy as np >>> loans = LoanBook() >>> loans.append_loans_for_lender( ... lender_idx=0, ... borrower_indices=np.array([1, 2, 3, 4]), ... amount=np.array([100.0, 150.0, 200.0, 250.0]), ... rate=np.array([0.02, 0.03, 0.02, 0.03]), ... ) >>> loans.size 4 >>> # Remove loans with principal > 150 >>> mask = loans.principal[: loans.size] > 150.0 >>> removed = loans.drop_rows(mask) >>> removed 2 >>> loans.size 2 See Also -------- purge_borrowers : Remove loans by borrower IDs purge_lenders : Remove loans by lender IDs """ if self.size == 0 or not rows_mask.any(): return 0 # nothing to do self._ensure_capacity(0) # no growth, only normalisation keep = ~rows_mask[: self.size] # rows to keep new_size = int(keep.sum()) if new_size < self.size: # only touch memory when shrinking # Compact base class arrays (source_ids, target_ids) self.source_ids[:new_size] = self.source_ids[: self.size][keep] self.target_ids[:new_size] = self.target_ids[: self.size][keep] # Compact edge-specific component arrays for name in ("principal", "rate", "interest", "debt"): col = getattr(self, name) col[:new_size] = col[: self.size][keep] removed = self.size - new_size self.size = new_size return removed return 0 # pragma: no cover - edge case: no loans to purge
[docs] def purge_borrowers(self, borrower_ids: Idx1D) -> int: """ Remove all loans from specified borrowers (firms). Uses inherited purge_sources() method from Relationship base class, which internally uses np.isin for efficient matching and drop_rows for compaction. Parameters ---------- borrower_ids : Idx1D Array of borrower (firm) indices to purge. Returns ------- int Number of loans removed. Notes ----- This is typically called during bankruptcy resolution to remove all loans from insolvent firms. Time complexity: O(size) for np.isin + O(size) for compaction = O(size). Examples -------- >>> from bamengine.relationships import LoanBook >>> import numpy as np >>> loans = LoanBook() >>> loans.append_loans_for_lender( ... lender_idx=0, ... borrower_indices=np.array([1, 2, 3, 4, 5]), ... amount=np.array([100.0, 150.0, 200.0, 250.0, 300.0]), ... rate=np.array([0.02, 0.03, 0.02, 0.03, 0.02]), ... ) >>> loans.size 5 >>> # Purge bankrupt firms 2 and 4 >>> removed = loans.purge_borrowers(np.array([2, 4])) >>> removed 2 >>> loans.size 3 >>> np.sort(loans.source_ids[: loans.size]) array([1, 3, 5]) See Also -------- purge_lenders : Remove loans by lender IDs drop_rows : Remove loans by boolean mask bamengine.core.relationship.Relationship.purge_sources : Base method """ return self.purge_sources(borrower_ids) # type: ignore[no-any-return, attr-defined]
[docs] def purge_lenders(self, lender_ids: Idx1D) -> int: """ Remove all loans from specified lenders (banks). Uses inherited purge_targets() method from Relationship base class, which internally uses np.isin for efficient matching and drop_rows for compaction. Parameters ---------- lender_ids : Idx1D Array of lender (bank) indices to purge. Returns ------- int Number of loans removed. Notes ----- This is typically called during bank bankruptcy resolution to remove all loans from insolvent banks. Time complexity: O(size) for np.isin + O(size) for compaction = O(size). Examples -------- >>> from bamengine.relationships import LoanBook >>> import numpy as np >>> loans = LoanBook() >>> # Add loans from two different banks >>> loans.append_loans_for_lender( ... lender_idx=0, ... borrower_indices=np.array([1, 2]), ... amount=np.array([100.0, 150.0]), ... rate=np.array([0.02, 0.03]), ... ) >>> loans.append_loans_for_lender( ... lender_idx=1, ... borrower_indices=np.array([3, 4]), ... amount=np.array([200.0, 250.0]), ... rate=np.array([0.02, 0.03]), ... ) >>> loans.size 4 >>> # Purge bankrupt bank 0 >>> removed = loans.purge_lenders(np.array([0])) >>> removed 2 >>> loans.size 2 >>> loans.target_ids[: loans.size] array([1, 1]) See Also -------- purge_borrowers : Remove loans by borrower IDs drop_rows : Remove loans by boolean mask bamengine.core.relationship.Relationship.purge_targets : Base method """ return self.purge_targets(lender_ids) # type: ignore[no-any-return, attr-defined]