Fire sales of overlapping portfolios
Banks with overlapping portfolios whose fire sales of common assets spread their losses to one another.
- Name:
overlapping_portfolio_fire_sales - Field:
economics/finance - Formalism: agent-based system
- References: Bardoscia et al. 2021; Caccioli et al. 2014
Select this system
from skcomplex.systems import create_simulation, create_system, describe
system = create_system('overlapping_portfolio_fire_sales')
entry = describe('overlapping_portfolio_fire_sales')
trajectory = create_simulation('overlapping_portfolio_fire_sales').prepare().run().trajectory_
Factory
@register_system(
field='economics/finance',
tags=('systemic risk', 'fire sales', 'overlapping portfolios', 'bipartite network', 'market impact', 'agents'),
references=('Bardoscia et al. 2021', 'Caccioli et al. 2014'),
defaults={'until': 30},
)
def overlapping_portfolio_fire_sales(
n_banks: int = 100,
n_assets: int = 100,
*,
diversification: int = 10,
leverage: float = 20.0,
alpha: float = 1.0,
shocked: int = 0,
shock: float = 0.2,
selling: str = 'threshold',
random_state: int | np.random.Generator | None = 0,
) -> System:
"""Return banks with overlapping portfolios whose fire sales of common assets spread their losses to one another.
Every bank invests equal amounts in `diversification` assets drawn at random, a bipartite network of banks and
assets, with assets `leverage` times its equity. A shock lowers the price of one asset, and banks react to their
losses: under the linear threshold rule a bank stays passive until its losses exhaust its equity and then
liquidates its whole portfolio, and under leverage targeting it sells a share of every holding to restore its
leverage. Sales lower prices through the market impact `p = p0 exp(-alpha x)` of the share `x` of an asset
liquidated, which hits every bank holding the asset, so losses travel between banks that share no contract, the
indirect contagion of Bardoscia et al. (2021, Section 3.2) after Caccioli et al. (2014). Larger impact and leverage
make the cascades larger, and leverage targeting sells more for small shocks.
Args:
n_banks:
The number of banks.
n_assets:
The number of assets.
diversification:
The number of assets in every portfolio.
leverage:
The ratio of assets to equity of every bank, and the target of leverage targeting.
alpha:
The market impact.
shocked:
The index of the asset the shock hits.
shock:
The share of its price the shocked asset loses.
selling:
The rule of the banks, `'threshold'` or `'leverage'`.
random_state:
The seed or generator of the portfolios.
Returns:
system:
The system, with the groups `banks`, of the equity `banks.equity` and the cash `banks.external`, and
`assets`, of the prices `assets.price`, and the fire sales `sales`, whose reporters are the number of
defaulted banks `sales.defaults` and the value sold in the step `sales.sold`.
Examples:
>>> from skcomplex.systems.economics.finance import overlapping_portfolio_fire_sales
>>> [group.prepare().size_ for group in overlapping_portfolio_fire_sales(10, 5, diversification=2).elements]
[10, 5]
"""
rng = np.random.default_rng(random_state)
holdings = np.zeros((n_banks, n_assets))
for bank in range(n_banks):
holdings[bank, rng.choice(n_assets, diversification, replace=False)] = 1.0 / diversification
assets_value = holdings.sum(axis=1)
liabilities = assets_value * (1 - 1 / leverage)
prices = np.ones(n_assets)
prices[shocked] = 1 - shock
equity = holdings @ prices - liabilities
banks = GroupElement('banks', element=BalanceSheetElement, size=n_banks, values={'equity': equity})
assets = GroupElement('assets', element=MarketAssetElement, size=n_assets, values={'price': prices})
sales = FireSaleInteraction(
'sales',
banks='banks',
assets='assets',
holdings=tuple(map(tuple, holdings.tolist())),
liabilities=tuple(liabilities.tolist()),
alpha=alpha,
leverage=leverage,
selling=selling,
)
return System(elements=[banks, assets], interactions=[sales])
Components
Elements:
BalanceSheetElement, fromElementMarketAssetElement, fromElement
Interactions:
FireSaleInteraction, fromFeedbackInteraction
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 MarketAssetElement(Element):
"""Asset held by banks, whose price falls with the share of it liquidated, `p = exp(-alpha x)`.
Args:
label:
The label of the agent or group.
visual:
How the element is drawn, or `None` for `default_visual_`.
price:
The price of the asset, 1 before any sale.
liquidated:
The share of the asset sold so far.
Examples:
>>> from skcomplex.core.components.elements import GroupElement
>>> from skcomplex.systems.economics.finance import MarketAssetElement
>>> GroupElement('assets', element=MarketAssetElement, size=4).size
4
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
price: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
liquidated: Annotated[float, StateSpec(domain=RealSet.closed(0, 1))] = 0.0,
) -> None:
super().__init__(label, visual=visual)
self.price = price
self.liquidated = liquidated
@classmethod
def _get_view(cls) -> type[MarketAssetView]:
"""Return the view of the agents of the element."""
return MarketAssetView
def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
"""Return no laws, since the fire sales update every asset at once.
Args:
s:
The namespace of the symbols the laws can use.
Returns:
laws:
The laws of the component.
"""
return []
class FireSaleInteraction(FeedbackInteraction[SystemView]):
"""Banks that sell common assets on losses, and the market impact that lowers the prices of what they sell.
Every step the banks value their holdings at the current prices. Under the rule `'threshold'` a bank whose losses
exhaust its equity defaults and liquidates its whole portfolio, and otherwise stays passive. Under `'leverage'` a
bank sells the same share of every holding to bring its assets back to `leverage` times its equity, and liquidates
everything when its equity is gone. The proceeds become cash, and the price of every asset falls to
`p0 exp(-alpha x)`, where `x` is the share of the asset liquidated so far and `p0` its price after the shock.
Args:
label:
The label of the fire sales.
rule:
Unused, since the fire sales override `act`.
phase:
The phase the fire sales act in, or `None` for the default phase.
banks:
The path of the group of banks.
assets:
The path of the group of assets.
holdings:
The initial holdings, `holdings[i][a]` the units of asset `a` that bank `i` holds.
liabilities:
The liabilities of every bank.
alpha:
The market impact.
leverage:
The target ratio of assets to equity of the rule `'leverage'`.
selling:
The rule of the banks, `'threshold'` or `'leverage'`.
Raises:
ValidationError:
The rule is unknown, raised by `prepare`.
Examples:
>>> from skcomplex.systems.economics.finance import FireSaleInteraction
>>> FireSaleInteraction('sales', banks='banks', assets='assets').reports
('defaults', 'sold')
"""
reports: ClassVar[tuple[str, ...]] = ('defaults', 'sold')
def __init__(
self,
label: str,
*,
rule: None = None,
phase: str | None = None,
banks: Annotated[str, RoleSpec()],
assets: Annotated[str, RoleSpec()],
holdings: tuple[tuple[float, ...], ...] = (),
liabilities: tuple[float, ...] = (),
alpha: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
leverage: Annotated[float, ParameterSpec(domain=RealSet.open(1, oo))] = 20.0,
selling: str = 'threshold',
) -> None:
super().__init__(label, rule=rule, phase=phase)
self.banks = banks
self.assets = assets
self.holdings = holdings
self.liabilities = liabilities
self.alpha = alpha
self.leverage = leverage
self.selling = selling
def _check(self) -> None:
"""Raise an error for an unknown rule.
Raises:
ValidationError:
The rule is unknown.
"""
super()._check()
if self.selling not in SELLING_RULES:
error_msg = f'The rule of {self.label!r} is {self.selling!r}. Use one of {SELLING_RULES}.'
raise ValidationError(error_msg)
def act(self, system: SystemView) -> None:
"""Value the portfolios, sell, and move the prices by the market impact.
Args:
system:
The system view of the fire sales.
"""
banks = sorted(system.agents('banks', BalanceSheetView), key=lambda bank: bank.id)
assets = sorted(system.agents('assets', MarketAssetView), key=lambda asset: asset.id)
initial = np.asarray(self.holdings, dtype=float)
holdings = np.asarray(system.storage.get('holdings', initial))
prices = np.array([asset.price for asset in assets])
base = np.asarray(system.storage.get('base', prices))
liquidated = np.array([asset.liquidated for asset in assets])
cash = np.array([bank.external for bank in banks])
status = np.array([bank.status for bank in banks])
values = holdings @ prices + cash
equity = values - np.asarray(self.liabilities, dtype=float)
failed = equity <= 0
if self.selling == 'leverage':
excess = values - system.parameter('leverage') * equity
excess = np.where(excess > 1e-9 * values, excess, 0.0)
risky = holdings @ prices
share = np.divide(excess, risky, out=np.zeros_like(excess), where=risky > 0)
share = np.where(failed, 1.0, np.minimum(share, 1.0))
else:
share = np.where(failed & (status == SOLVENT), 1.0, 0.0)
sold = holdings * share[:, None]
cash = cash + sold @ prices
holdings = holdings - sold
totals = initial.sum(axis=0)
liquidated = np.minimum(
1.0,
liquidated + np.divide(sold.sum(axis=0), totals, out=np.zeros_like(totals), where=totals > 0),
)
prices = base * np.exp(-system.parameter('alpha') * liquidated)
status = np.where(failed, DEFAULTED, status)
equity = holdings @ prices + cash - np.asarray(self.liabilities, dtype=float)
system.storage.update(holdings=holdings, base=base)
for bank, value, money, code in zip(banks, equity, cash, status, strict=True):
bank.equity, bank.external, bank.status = float(value), float(money), float(code)
for asset, price, share_sold in zip(assets, prices, liquidated, strict=True):
asset.price, asset.liquidated = float(price), float(share_sold)
system.report('defaults', float(np.sum(status == DEFAULTED)))
system.report('sold', float((sold @ prices).sum()))