Skip to content

Default cascades with thresholds and recovery

A cascade of defaults through interbank exposures, from a shock to the external assets of some banks.

  • Name: default_cascade
  • Field: economics/finance
  • Formalism: agent-based system
  • References: Thurner et al. 2018; Bardoscia et al. 2021; Gai and Kapadia 2010

Select this system

from skcomplex.systems import create_simulation, create_system, describe

system = create_system('default_cascade')
entry = describe('default_cascade')
trajectory = create_simulation('default_cascade').prepare().run().trajectory_

Factory

@register_system(
    field='economics/finance',
    tags=('systemic risk', 'network', 'contagion', 'default cascade', 'agents'),
    references=('Thurner et al. 2018', 'Bardoscia et al. 2021', 'Gai and Kapadia 2010'),
    defaults={'until': 30},
)
def default_cascade(
    liabilities: Sequence[Sequence[float]] | None = None,
    equity: Sequence[float] | None = None,
    *,
    shocked: Sequence[int] = (0,),
    shock: float = 1.0,
    threshold: float = 0.0,
    recovery: float = 0.0,
    n_banks: int = 250,
    degree: float = 4.0,
    interbank_share: float = 0.2,
    capital_ratio: float = 0.04,
    random_state: int | np.random.Generator | None = 0,
) -> System:
    """Return a cascade of defaults through interbank exposures, from a shock to the external assets of some banks.

    Banks are linked by contracts, `liabilities[i][j]` the amount bank `i` owes bank `j`. A bank whose equity falls
    below the threshold `h` defaults, and its creditors write off their claims on it, losing `(1 - R) L_ij` of their
    equity at the next step, which may default them in turn. This is the cascade of Thurner et al. (2018, Section
    5.5.3.2), and with `h = 0` and the recovery rate `R = 0` it is the contagion on default of Gai and Kapadia (2010) as
    Bardoscia et al. (2021, Section 3.1) review it. The shocked banks lose the share `shock` of their external assets at
    the start. Without a given network, every bank has assets of 100, a fifth of them interbank claims split evenly over
    a random number of debtors of mean `degree`, and capital of 4 percent, the network of Gai and Kapadia, whose
    cascades are large in a window of intermediate degrees and rare for sparse or dense networks.

    Args:
        liabilities:
            The interbank liabilities, or `None` for the random network of Gai and Kapadia.

        equity:
            The equity of every bank before the shock, or `None` for the capital of the random network.

        shocked:
            The indices of the banks whose external assets the shock hits.

        shock:
            The share of their external assets the shocked banks lose.

        threshold:
            The equity below which a bank defaults.

        recovery:
            The share of a claim on a defaulted bank that its creditor recovers.

        n_banks:
            The number of banks of the random network.

        degree:
            The mean number of debtors of a bank of the random network.

        interbank_share:
            The share of the assets of a bank of the random network that are interbank claims.

        capital_ratio:
            The capital of a bank of the random network as a share of its assets.

        random_state:
            The seed or generator of the random network.

    Returns:
        system:
            The system, with the group `banks` and the cascade `cascade`, whose reporters are the number of defaulted
            banks `cascade.defaults` and the losses written off in the step `cascade.losses`.

    Examples:
        >>> from skcomplex.systems.economics.finance import default_cascade
        >>> default_cascade([[0.0, 5.0], [0.0, 0.0]], [1.0, 2.0]).elements[0].prepare().size_
        2
    """
    if liabilities is None or equity is None:
        rng = np.random.default_rng(random_state)
        matrix, external, capital = _gai_kapadia_network(n_banks, degree, interbank_share, capital_ratio, rng)
    else:
        matrix, capital = np.asarray(liabilities, dtype=float), np.asarray(equity, dtype=float)
        external = np.maximum(capital, 0.0)
    size = len(matrix)
    hit = np.zeros(size, dtype=bool)
    hit[list(shocked)] = True
    losses = np.where(hit, shock * external, 0.0)
    after = capital - losses
    status = np.where(after < threshold, DEFAULTING, SOLVENT).astype(float)
    banks = GroupElement(
        'banks',
        element=BalanceSheetElement,
        size=size,
        values={'external': external - losses, 'equity': after, 'status': status},
    )
    cascade = DefaultCascadeInteraction(
        'cascade',
        banks='banks',
        liabilities=matrix.tolist(),
        threshold=threshold,
        recovery=recovery,
    )
    return System(elements=[banks], interactions=[cascade])

Components

Elements:

  • BalanceSheetElement, from Element

Interactions:

  • DefaultCascadeInteraction, from FeedbackInteraction
class BalanceSheetElement(Element):
    """Bank of an interbank network, with its external assets, its equity, its payments, and its state of default.

    The bank is a passive agent that the contagion of the system updates, a solvency cascade through its equity or a
    clearing of payments through its payment.

    Args:
        label:
            The label of the agent or group.

        visual:
            How the element is drawn, or `None` for `default_visual_`.

        external:
            The external assets.

        equity:
            The equity, the assets minus the liabilities.

        payment:
            The payment the bank makes on its interbank liabilities.

        status:
            0 for solvent, 1 for defaulting in this step, and 2 for defaulted.

    Examples:
        >>> from skcomplex.core.components.elements import GroupElement
        >>> from skcomplex.systems.economics.finance import BalanceSheetElement
        >>> GroupElement('banks', element=BalanceSheetElement, size=3, values={'equity': 4.0}).size
        3
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = VisualSpec('agents', {'color_by': 'status'}),
        *,
        external: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
        equity: Annotated[float, StateSpec()] = 0.0,
        payment: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
        status: Annotated[float, StateSpec(domain=NN)] = SOLVENT,
    ) -> None:
        super().__init__(label, visual=visual)
        self.external = external
        self.equity = equity
        self.payment = payment
        self.status = status

    @classmethod
    def _get_view(cls) -> type[BalanceSheetView]:
        """Return the view of the agents of the element."""
        return BalanceSheetView

    def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
        """Return no laws, since the contagion updates every bank at once.

        Args:
            s:
                The namespace of the symbols the laws can use.

        Returns:
            laws:
                The laws of the component.
        """
        return []

class DefaultCascadeInteraction(FeedbackInteraction[SystemView]):
    """Synchronous cascade of defaults, in which the creditors of a defaulting bank write off their claims on it.

    Every step the banks that defaulted in the last step pass their losses on: every creditor `j` of a defaulting bank
    `i` loses `(1 - recovery) L_ij` of its equity, and a solvent bank whose equity falls below the threshold defaults.

    Args:
        label:
            The label of the cascade.

        rule:
            Unused, since the cascade overrides `act`.

        phase:
            The phase the cascade acts in, or `None` for the default phase.

        banks:
            The path of the group of banks.

        liabilities:
            The interbank liabilities, `liabilities[i][j]` owed by bank `i` to bank `j`.

        threshold:
            The equity below which a bank defaults.

        recovery:
            The share of a claim on a defaulted bank that its creditor recovers.

    Examples:
        >>> from skcomplex.systems.economics.finance import DefaultCascadeInteraction
        >>> DefaultCascadeInteraction('cascade', banks='banks').reports
        ('defaults', 'losses')
    """

    reports: ClassVar[tuple[str, ...]] = ('defaults', 'losses')

    def __init__(
        self,
        label: str,
        *,
        rule: None = None,
        phase: str | None = None,
        banks: Annotated[str, RoleSpec()],
        liabilities: Sequence[Sequence[float]] = (),
        threshold: Annotated[float, ParameterSpec()] = 0.0,
        recovery: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 0.0,
    ) -> None:
        super().__init__(label, rule=rule, phase=phase)
        self.banks = banks
        self.liabilities = liabilities
        self.threshold = threshold
        self.recovery = recovery

    def act(self, system: SystemView) -> None:
        """Pass on the losses of the defaulting banks and default the banks below the threshold.

        Args:
            system:
                The system view of the cascade.
        """
        banks = sorted(system.agents('banks', BalanceSheetView), key=lambda bank: bank.id)
        status = np.array([bank.status for bank in banks])
        equity = np.array([bank.equity for bank in banks])
        defaulting = status == DEFAULTING
        losses = (1 - system.parameter('recovery')) * np.asarray(self.liabilities, dtype=float).T @ defaulting
        equity = equity - losses
        hit = (status == SOLVENT) & (equity < system.parameter('threshold'))
        status = np.where(defaulting, DEFAULTED, np.where(hit, DEFAULTING, status))
        for bank, value, code in zip(banks, equity, status, strict=True):
            bank.equity, bank.status = float(value), float(code)
        system.report('defaults', float(np.sum(status != SOLVENT)))
        system.report('losses', float(losses.sum()))