494 lines
19 KiB
Python
494 lines
19 KiB
Python
"""Tests for risk-tranched stablecoin issuance (src/primitives/risk_tranching.py)."""
|
|
|
|
import math
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from src.primitives.risk_tranching import (
|
|
TrancheParams,
|
|
TrancheState,
|
|
RiskTrancheSystem,
|
|
mint_capacity,
|
|
deposit_collateral,
|
|
mint_tranche,
|
|
redeem_tranche,
|
|
distribute_yield,
|
|
apply_loss,
|
|
check_liquidation,
|
|
get_tranche_metrics,
|
|
)
|
|
|
|
|
|
# ---------- Helpers ----------
|
|
|
|
def make_system(collateral: float = 0.0) -> RiskTrancheSystem:
|
|
"""Return a fresh RiskTrancheSystem with optional starting collateral."""
|
|
system = RiskTrancheSystem(params=TrancheParams())
|
|
if collateral > 0:
|
|
deposit_collateral(system, collateral)
|
|
return system
|
|
|
|
|
|
def make_funded_system(collateral: float = 1_000_000.0) -> RiskTrancheSystem:
|
|
"""System with collateral, all three tranches minted to a reasonable level."""
|
|
system = make_system(collateral)
|
|
mint_tranche(system, "senior", 200_000)
|
|
mint_tranche(system, "mezzanine", 100_000)
|
|
mint_tranche(system, "junior", 50_000)
|
|
return system
|
|
|
|
|
|
# ---------- TrancheParams ----------
|
|
|
|
class TestTrancheParams:
|
|
def test_defaults(self):
|
|
p = TrancheParams()
|
|
assert p.senior_collateral_ratio == 1.5
|
|
assert p.mezzanine_collateral_ratio == 1.2
|
|
assert p.senior_yield_target == 0.03
|
|
assert p.mezzanine_yield_target == 0.08
|
|
assert p.max_senior_fraction == 0.50
|
|
assert p.max_mezzanine_fraction == 0.30
|
|
assert p.senior_liquidation_ratio == 1.2
|
|
assert p.mezzanine_liquidation_ratio == 1.05
|
|
assert p.rebalance_threshold == 0.05
|
|
|
|
def test_custom_params(self):
|
|
p = TrancheParams(senior_collateral_ratio=2.0, senior_yield_target=0.05)
|
|
assert p.senior_collateral_ratio == 2.0
|
|
assert p.senior_yield_target == 0.05
|
|
|
|
|
|
# ---------- TrancheState ----------
|
|
|
|
class TestTrancheState:
|
|
def test_collateral_ratio_zero_supply_is_inf(self):
|
|
t = TrancheState(name="test", supply=0.0, collateral_backing=100.0)
|
|
assert t.collateral_ratio == float("inf")
|
|
|
|
def test_collateral_ratio_normal(self):
|
|
t = TrancheState(name="test", supply=1000.0, collateral_backing=1500.0)
|
|
assert t.collateral_ratio == pytest.approx(1.5)
|
|
|
|
def test_collateral_ratio_undercollateralized(self):
|
|
t = TrancheState(name="test", supply=1000.0, collateral_backing=800.0)
|
|
assert t.collateral_ratio == pytest.approx(0.8)
|
|
|
|
def test_is_healthy_above_one(self):
|
|
t = TrancheState(name="test", supply=100.0, collateral_backing=150.0)
|
|
assert t.is_healthy is True
|
|
|
|
def test_is_healthy_below_one(self):
|
|
t = TrancheState(name="test", supply=100.0, collateral_backing=90.0)
|
|
assert t.is_healthy is False
|
|
|
|
def test_is_healthy_zero_supply(self):
|
|
# CR is inf when supply is 0, inf > 1 → healthy
|
|
t = TrancheState(name="test", supply=0.0, collateral_backing=0.0)
|
|
assert t.is_healthy is True
|
|
|
|
|
|
# ---------- RiskTrancheSystem creation ----------
|
|
|
|
class TestRiskTrancheSystemCreation:
|
|
def test_default_names(self):
|
|
system = RiskTrancheSystem(params=TrancheParams())
|
|
assert system.senior.name == "myUSD-S"
|
|
assert system.mezzanine.name == "myUSD-M"
|
|
assert system.junior.name == "$MYCO"
|
|
|
|
def test_initial_zero_state(self):
|
|
system = RiskTrancheSystem(params=TrancheParams())
|
|
assert system.total_collateral == 0.0
|
|
assert system.total_supply == 0.0
|
|
assert system.system_collateral_ratio == float("inf")
|
|
|
|
def test_total_supply_aggregates_tranches(self):
|
|
system = RiskTrancheSystem(params=TrancheParams())
|
|
system.senior.supply = 100.0
|
|
system.mezzanine.supply = 50.0
|
|
system.junior.supply = 25.0
|
|
assert system.total_supply == pytest.approx(175.0)
|
|
|
|
def test_system_collateral_ratio(self):
|
|
system = RiskTrancheSystem(params=TrancheParams())
|
|
system.total_collateral = 300.0
|
|
system.senior.supply = 100.0
|
|
system.mezzanine.supply = 50.0
|
|
assert system.system_collateral_ratio == pytest.approx(300.0 / 150.0)
|
|
|
|
|
|
# ---------- deposit_collateral ----------
|
|
|
|
class TestDepositCollateral:
|
|
def test_deposit_increases_total(self):
|
|
system = make_system()
|
|
deposit_collateral(system, 500.0)
|
|
assert system.total_collateral == pytest.approx(500.0)
|
|
|
|
def test_multiple_deposits_accumulate(self):
|
|
system = make_system()
|
|
deposit_collateral(system, 300.0)
|
|
deposit_collateral(system, 200.0)
|
|
assert system.total_collateral == pytest.approx(500.0)
|
|
|
|
def test_deposit_into_existing_system(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
before = system.total_collateral
|
|
deposit_collateral(system, 100_000.0)
|
|
assert system.total_collateral == pytest.approx(before + 100_000.0)
|
|
|
|
def test_returns_system(self):
|
|
system = make_system()
|
|
result = deposit_collateral(system, 100.0)
|
|
assert result is system
|
|
|
|
|
|
# ---------- mint_capacity ----------
|
|
|
|
class TestMintCapacity:
|
|
def test_no_collateral_returns_zeros(self):
|
|
system = make_system(0.0)
|
|
caps = mint_capacity(system)
|
|
assert caps["senior"] == pytest.approx(0.0)
|
|
assert caps["mezzanine"] == pytest.approx(0.0)
|
|
assert caps["junior"] >= 0.0
|
|
|
|
def test_senior_capacity_within_fraction_limit(self):
|
|
system = make_system(1_000_000.0)
|
|
caps = mint_capacity(system)
|
|
# Max senior = (1e6 * 0.50) / 1.5 = 333_333.33
|
|
expected_senior = (1_000_000.0 * 0.50) / 1.5
|
|
assert caps["senior"] == pytest.approx(expected_senior)
|
|
|
|
def test_mezzanine_capacity_within_fraction_limit(self):
|
|
system = make_system(1_000_000.0)
|
|
caps = mint_capacity(system)
|
|
# Max mez = (1e6 * 0.30) / 1.2 = 250_000
|
|
expected_mez = (1_000_000.0 * 0.30) / 1.2
|
|
assert caps["mezzanine"] == pytest.approx(expected_mez)
|
|
|
|
def test_capacity_decreases_after_minting(self):
|
|
system = make_system(1_000_000.0)
|
|
caps_before = mint_capacity(system)
|
|
mint_tranche(system, "senior", 100_000.0)
|
|
caps_after = mint_capacity(system)
|
|
assert caps_after["senior"] < caps_before["senior"]
|
|
|
|
def test_all_keys_present(self):
|
|
system = make_system(500_000.0)
|
|
caps = mint_capacity(system)
|
|
assert "senior" in caps
|
|
assert "mezzanine" in caps
|
|
assert "junior" in caps
|
|
|
|
|
|
# ---------- mint_tranche ----------
|
|
|
|
class TestMintTranche:
|
|
def test_mint_senior_returns_correct_amount(self):
|
|
system = make_system(1_000_000.0)
|
|
system, minted = mint_tranche(system, "senior", 100_000.0)
|
|
assert minted == pytest.approx(100_000.0)
|
|
assert system.senior.supply == pytest.approx(100_000.0)
|
|
|
|
def test_mint_mezzanine(self):
|
|
system = make_system(1_000_000.0)
|
|
system, minted = mint_tranche(system, "mezzanine", 50_000.0)
|
|
assert minted == pytest.approx(50_000.0)
|
|
assert system.mezzanine.supply == pytest.approx(50_000.0)
|
|
|
|
def test_mint_junior(self):
|
|
system = make_system(1_000_000.0)
|
|
system, minted = mint_tranche(system, "junior", 30_000.0)
|
|
assert minted == pytest.approx(30_000.0)
|
|
assert system.junior.supply == pytest.approx(30_000.0)
|
|
|
|
def test_mint_respects_capacity_cap(self):
|
|
system = make_system(100.0)
|
|
# Request far more than capacity
|
|
system, minted = mint_tranche(system, "senior", 1_000_000.0)
|
|
caps = mint_capacity(make_system(100.0))
|
|
assert minted <= caps["senior"] + 1e-9
|
|
|
|
def test_mint_when_no_collateral_returns_zero(self):
|
|
system = make_system(0.0)
|
|
system, minted = mint_tranche(system, "senior", 1_000.0)
|
|
assert minted == pytest.approx(0.0)
|
|
assert system.senior.supply == pytest.approx(0.0)
|
|
|
|
def test_mint_collateral_backing_correct_senior(self):
|
|
system = make_system(1_000_000.0)
|
|
system, minted = mint_tranche(system, "senior", 100_000.0)
|
|
expected_backing = 100_000.0 * system.params.senior_collateral_ratio
|
|
assert system.senior.collateral_backing == pytest.approx(expected_backing)
|
|
|
|
def test_mint_collateral_backing_correct_mezzanine(self):
|
|
system = make_system(1_000_000.0)
|
|
system, minted = mint_tranche(system, "mezzanine", 50_000.0)
|
|
expected_backing = 50_000.0 * system.params.mezzanine_collateral_ratio
|
|
assert system.mezzanine.collateral_backing == pytest.approx(expected_backing)
|
|
|
|
def test_mint_junior_one_to_one(self):
|
|
system = make_system(1_000_000.0)
|
|
system, minted = mint_tranche(system, "junior", 10_000.0)
|
|
assert system.junior.collateral_backing == pytest.approx(minted)
|
|
|
|
|
|
# ---------- redeem_tranche ----------
|
|
|
|
class TestRedeemTranche:
|
|
def test_redeem_returns_proportional_collateral(self):
|
|
system = make_system(1_000_000.0)
|
|
mint_tranche(system, "senior", 100_000.0)
|
|
backing_before = system.senior.collateral_backing
|
|
# Redeem half
|
|
system, collateral = redeem_tranche(system, "senior", 50_000.0)
|
|
assert collateral == pytest.approx(backing_before / 2)
|
|
|
|
def test_redeem_reduces_supply(self):
|
|
system = make_system(1_000_000.0)
|
|
mint_tranche(system, "senior", 100_000.0)
|
|
system, _ = redeem_tranche(system, "senior", 40_000.0)
|
|
assert system.senior.supply == pytest.approx(60_000.0)
|
|
|
|
def test_redeem_reduces_total_collateral(self):
|
|
system = make_system(1_000_000.0)
|
|
mint_tranche(system, "senior", 100_000.0)
|
|
total_before = system.total_collateral
|
|
system, collateral = redeem_tranche(system, "senior", 50_000.0)
|
|
assert system.total_collateral == pytest.approx(total_before - collateral)
|
|
|
|
def test_redeem_capped_at_supply(self):
|
|
system = make_system(1_000_000.0)
|
|
mint_tranche(system, "mezzanine", 50_000.0)
|
|
# Try to redeem more than supply
|
|
system, collateral = redeem_tranche(system, "mezzanine", 999_999.0)
|
|
assert system.mezzanine.supply == pytest.approx(0.0)
|
|
|
|
def test_redeem_zero_supply_returns_nothing(self):
|
|
system = make_system(1_000_000.0)
|
|
system, collateral = redeem_tranche(system, "junior", 100.0)
|
|
assert collateral == pytest.approx(0.0)
|
|
|
|
|
|
# ---------- distribute_yield ----------
|
|
|
|
class TestDistributeYield:
|
|
def test_senior_gets_target_first(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
senior_supply = system.senior.supply
|
|
dt = 1.0 # 1 year
|
|
# Small yield that only covers senior target
|
|
senior_target = senior_supply * system.params.senior_yield_target * dt
|
|
yield_amount = senior_target * 0.5 # Only half of what senior wants
|
|
|
|
senior_before = system.senior.accrued_yield
|
|
mez_before = system.mezzanine.accrued_yield
|
|
distribute_yield(system, yield_amount, dt)
|
|
|
|
assert system.senior.accrued_yield > senior_before
|
|
# Mezzanine should not receive anything when yield is insufficient for senior
|
|
assert system.mezzanine.accrued_yield == pytest.approx(mez_before)
|
|
|
|
def test_mezzanine_gets_yield_after_senior(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
dt = 1.0
|
|
senior_target = system.senior.supply * system.params.senior_yield_target * dt
|
|
mez_target = system.mezzanine.supply * system.params.mezzanine_yield_target * dt
|
|
# Yield that covers senior and some for mezzanine
|
|
yield_amount = senior_target + mez_target * 0.5
|
|
|
|
distribute_yield(system, yield_amount, dt)
|
|
assert system.mezzanine.accrued_yield > 0.0
|
|
|
|
def test_junior_gets_residual(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
dt = 1.0
|
|
senior_target = system.senior.supply * system.params.senior_yield_target * dt
|
|
mez_target = system.mezzanine.supply * system.params.mezzanine_yield_target * dt
|
|
# Large yield that satisfies senior + mez, leaves some for junior
|
|
extra = 50_000.0
|
|
yield_amount = senior_target + mez_target + extra
|
|
|
|
distribute_yield(system, yield_amount, dt)
|
|
assert system.junior.accrued_yield == pytest.approx(extra)
|
|
|
|
def test_cumulative_yield_updates(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
distribute_yield(system, 10_000.0, 1.0 / 365)
|
|
total_distributed = (
|
|
system.senior.cumulative_yield
|
|
+ system.mezzanine.cumulative_yield
|
|
+ system.junior.cumulative_yield
|
|
)
|
|
assert total_distributed == pytest.approx(10_000.0)
|
|
|
|
def test_yield_updates_total_staking_yield(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
distribute_yield(system, 5_000.0, 1.0 / 365)
|
|
assert system.total_staking_yield == pytest.approx(5_000.0)
|
|
|
|
def test_yield_increases_collateral_backing(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
senior_backing_before = system.senior.collateral_backing
|
|
dt = 1.0
|
|
yield_amount = system.senior.supply * system.params.senior_yield_target * dt
|
|
distribute_yield(system, yield_amount, dt)
|
|
assert system.senior.collateral_backing > senior_backing_before
|
|
|
|
|
|
# ---------- apply_loss ----------
|
|
|
|
class TestApplyLoss:
|
|
def test_junior_absorbs_first(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
junior_backing = system.junior.collateral_backing
|
|
mez_backing_before = system.mezzanine.collateral_backing
|
|
# Small loss that junior can fully absorb
|
|
small_loss = junior_backing * 0.5
|
|
apply_loss(system, small_loss)
|
|
assert system.junior.collateral_backing == pytest.approx(junior_backing - small_loss)
|
|
assert system.mezzanine.collateral_backing == pytest.approx(mez_backing_before)
|
|
|
|
def test_mezzanine_absorbs_after_junior_exhausted(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
junior_backing = system.junior.collateral_backing
|
|
mez_backing_before = system.mezzanine.collateral_backing
|
|
# Loss larger than junior can handle
|
|
loss = junior_backing + 10_000.0
|
|
apply_loss(system, loss)
|
|
assert system.junior.collateral_backing == pytest.approx(0.0)
|
|
assert system.mezzanine.collateral_backing < mez_backing_before
|
|
|
|
def test_senior_absorbs_last(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
junior_backing = system.junior.collateral_backing
|
|
mez_backing = system.mezzanine.collateral_backing
|
|
senior_backing_before = system.senior.collateral_backing
|
|
# Wipe out junior + mez, then hit senior
|
|
massive_loss = junior_backing + mez_backing + 5_000.0
|
|
apply_loss(system, massive_loss)
|
|
assert system.junior.collateral_backing == pytest.approx(0.0)
|
|
assert system.mezzanine.collateral_backing == pytest.approx(0.0)
|
|
assert system.senior.collateral_backing < senior_backing_before
|
|
|
|
def test_total_collateral_decreases(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
total_before = system.total_collateral
|
|
apply_loss(system, 50_000.0)
|
|
assert system.total_collateral == pytest.approx(total_before - 50_000.0)
|
|
|
|
def test_cumulative_losses_tracked(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
apply_loss(system, system.junior.collateral_backing)
|
|
assert system.junior.cumulative_losses > 0.0
|
|
|
|
def test_loss_exceeding_all_collateral(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
total_backing = (
|
|
system.junior.collateral_backing
|
|
+ system.mezzanine.collateral_backing
|
|
+ system.senior.collateral_backing
|
|
)
|
|
# Loss beyond all collateral
|
|
apply_loss(system, total_backing * 2)
|
|
# All three should be at or near zero
|
|
assert system.junior.collateral_backing <= 0.0
|
|
assert system.mezzanine.collateral_backing <= 0.0
|
|
|
|
|
|
# ---------- check_liquidation ----------
|
|
|
|
class TestCheckLiquidation:
|
|
def test_no_liquidation_when_healthy(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
result = check_liquidation(system)
|
|
assert result["senior"] is False
|
|
assert result["mezzanine"] is False
|
|
assert result["junior"] is False
|
|
|
|
def test_senior_liquidation_triggered(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
# Force senior CR below liquidation threshold (1.2)
|
|
system.senior.collateral_backing = system.senior.supply * 1.1 # 110% < 120%
|
|
result = check_liquidation(system)
|
|
assert result["senior"] is True
|
|
|
|
def test_mezzanine_liquidation_triggered(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
# Force mez CR below 1.05
|
|
system.mezzanine.collateral_backing = system.mezzanine.supply * 1.0 # 100% < 105%
|
|
result = check_liquidation(system)
|
|
assert result["mezzanine"] is True
|
|
|
|
def test_no_liquidation_when_zero_supply(self):
|
|
# With zero supply, nothing to liquidate
|
|
system = make_system(1_000_000.0)
|
|
result = check_liquidation(system)
|
|
assert result["senior"] is False
|
|
assert result["mezzanine"] is False
|
|
assert result["junior"] is False
|
|
|
|
def test_all_keys_present(self):
|
|
system = make_system()
|
|
result = check_liquidation(system)
|
|
assert "senior" in result
|
|
assert "mezzanine" in result
|
|
assert "junior" in result
|
|
|
|
|
|
# ---------- get_tranche_metrics ----------
|
|
|
|
class TestGetTrancheMetrics:
|
|
def test_metrics_structure(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
m = get_tranche_metrics(system)
|
|
assert "total_collateral" in m
|
|
assert "system_cr" in m
|
|
assert "total_supply" in m
|
|
assert "senior" in m
|
|
assert "mezzanine" in m
|
|
assert "junior" in m
|
|
|
|
def test_metrics_values_consistent(self):
|
|
system = make_funded_system(1_000_000.0)
|
|
m = get_tranche_metrics(system)
|
|
assert m["senior"]["supply"] == system.senior.supply
|
|
assert m["mezzanine"]["supply"] == system.mezzanine.supply
|
|
assert m["junior"]["supply"] == system.junior.supply
|
|
|
|
|
|
# ---------- Full lifecycle ----------
|
|
|
|
class TestFullLifecycle:
|
|
def test_deposit_mint_yield_loss_verify_crs(self):
|
|
system = make_system(2_000_000.0)
|
|
|
|
# Mint all three tranches
|
|
system, s_minted = mint_tranche(system, "senior", 300_000.0)
|
|
system, m_minted = mint_tranche(system, "mezzanine", 150_000.0)
|
|
system, j_minted = mint_tranche(system, "junior", 100_000.0)
|
|
|
|
assert s_minted > 0
|
|
assert m_minted > 0
|
|
assert j_minted > 0
|
|
|
|
# Distribute yield
|
|
distribute_yield(system, 50_000.0, 1.0 / 12) # Monthly yield
|
|
|
|
# Tranches should have gained collateral backing
|
|
assert system.senior.collateral_backing > s_minted * system.params.senior_collateral_ratio
|
|
|
|
# Apply a moderate loss
|
|
junior_backing_before = system.junior.collateral_backing
|
|
apply_loss(system, junior_backing_before * 0.3)
|
|
|
|
# Junior should have absorbed the loss
|
|
assert system.junior.collateral_backing < junior_backing_before
|
|
# Senior should be untouched
|
|
assert system.senior.cumulative_losses == pytest.approx(0.0)
|
|
|
|
# Collateral ratios: senior should be healthy
|
|
assert system.senior.collateral_ratio > system.params.senior_liquidation_ratio
|