From 2e574ec537f70ee5f316b376adff7b7071191182 Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 24 Jul 2026 15:34:37 +0000 Subject: [PATCH] feat(3jane): redefine junior-buffer alert around at-risk credit coverage The 3Jane Junior Buffer Low alert was noisy because it measured sUSD3 against the entire borrow book, which is a structural design constant (~10-15% first-loss tranche) and carries no information about a *change* in risk. This replaces it with a tiered, real-signal coverage check. New monitors (read from existing Envio snapshot list - no double-fetch): * **Junior Tranche Coverage** - sUSD3 backing / severity-weighted at-risk credit. HIGH below 2.0x. The sUSD3 stakers' risk view and the leading indicator that sUSD3 PPS is about to drop. * **Senior Tranche Coverage** - (Insurance Fund + sUSD3) / severity- weighted at-risk credit. HIGH below 1.5x, CRITICAL below 1.0x. The USD3 holders' risk view; CRITICAL also triggers the emergency withdrawal dispatch already wired to 3jane. * **At-risk exposure aggregator** - sum(ending_balance * weight(bucket)) across all Delinquent/Default borrowers. Weights map the existing default_bucket values to an expected-loss proxy: default=1.0, 1d=0.9, 3d=0.7, 7d=0.5, 14d=0.3, delinquent=0.3. Also exposes largest_borrower_exposure for a future concentration check. With no impaired borrowers the coverage alerts are silent regardless of the sUSD3/whole-book ratio, eliminating the original false alarm. When Envio is unreachable the coverage checks are skipped and only the demoted structural check runs; no alerts on missing data. The original whole-book ratio becomes a LOW-severity informational 'Junior Buffer Drifting' check that fires only on a structural floor breach at 8% or a sharp drop of >=3pp versus a cached trailing baseline - keeping the leverage-drift signal without paging on the steady state. Deduplication, type annotations, docs, and monitoring.yaml are all updated. Closes yearn/monitoring#323. --- monitoring.yaml | 10 +- protocols/3jane/README.md | 45 +++- protocols/3jane/main.py | 369 ++++++++++++++++++++++++++--- tests/test_3jane.py | 480 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 849 insertions(+), 55 deletions(-) diff --git a/monitoring.yaml b/monitoring.yaml index 7a26fb6..752243e 100644 --- a/monitoring.yaml +++ b/monitoring.yaml @@ -27,8 +27,12 @@ protocols: description: "USD3 PPS decrease (any drop vs cached prior, CRITICAL) and sUSD3 PPS decrease (HIGH)" - name: "TVL" description: "Absolute TVL change >=15% for USD3 or sUSD3" - - name: "Junior Buffer Ratio" - description: "sUSD3 backing <15% of deployed credit" + - name: "Junior Tranche Coverage" + description: "sUSD3 backing / severity-weighted at-risk credit < 2.0x (HIGH). At-risk credit is aggregated from Envio Delinquent/Default borrowers weighted by bucket." + - name: "Senior Tranche Coverage" + description: "(Insurance Fund + sUSD3) / weighted at-risk < 1.5x (HIGH) or < 1.0x (CRITICAL, dispatches emergency withdrawal)" + - name: "Junior Buffer Drifting" + description: "Demoted LOW informational check on sUSD3 / Deployed credit. Alerts on structural floor (<8%) or drop >=3pp vs cached trailing baseline. Real loss absorption is in the two coverage monitors above." - name: "USD3 Overcollateralization" description: "OC <111% (HIGH) or <106% (CRITICAL)" - name: "Insurance Fund Outflow" @@ -44,7 +48,7 @@ protocols: - name: "Protocol Pause" description: "Alert-once when ProtocolConfig IS_PAUSED flips true" - name: "Borrower Default Watch" - description: "Envio-backed MorphoCredit borrower watch; MEDIUM alerts when unpaid obligations become delinquent after grace or reach default" + description: "Envio-backed MorphoCredit borrower watch; MEDIUM alerts when unpaid obligations become delinquent after grace or reach default. Same snapshot list feeds the coverage checks above." - name: "Timelock" description: "CallScheduled events from 24h and 7-day TimelockControllers via Envio" diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index a312080..3bf409a 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -6,7 +6,9 @@ - **PPS (Price Per Share):** `convertToAssets(1e6)` on USD3 and sUSD3 vs cached prior run. Alerts on any decrease — indicates loan markdowns or defaults (critical since loans are unsecured). - **TVL (Total Value Locked):** `totalAssets()` on both vaults vs cached prior run. Alerts when absolute change is **≥15%**. -- **Junior Buffer Ratio:** USD3 held by sUSD3, valued in USDC, as a percentage of deployed credit (`getMarketLiquidity().totalBorrowAssets` converted from waUSDC to USDC). Alerts below **15%** — thin first-loss coverage puts the senior tranche at risk. Deduped: re-alerts only when the ratio drops below the last alerted value; recovery above 15% re-arms. This matches the 3Jane backing UI's `sUSD3 / Deployed` loss-buffer metric. +- **Junior Tranche Coverage:** sUSD3 backing (in USDC) divided by the **severity-weighted at-risk credit** (`Σ exposure × weight` over all Delinquent/Default borrowers, where weights come from the bucket table below). Alerts when the ratio is below **2.0x** as HIGH — sUSD3 alone cannot absorb the impaired book; sUSD3 stakers are at risk. Deduped: re-alerts only when coverage drops further; recovery above 2.0x re-arms. With no impaired borrowers, this check is silent regardless of the sUSD3/whole-book ratio. +- **Senior Tranche Coverage:** `(Insurance Fund + sUSD3 backing)` divided by the same severity-weighted at-risk credit. Alerts when the ratio is below **1.5x** as HIGH and below **1.0x** as **CRITICAL** (USD3 directly exposed to impaired credit; the CRITICAL alert also dispatches the emergency-withdrawal hook). Deduped: re-alerts only when coverage drops further; recovery above 1.5x re-arms. +- **Junior Buffer Drifting:** sUSD3 backing divided by deployed credit. The historical `sUSD3 / Deployed` ratio is a structural design constant (sUSD3 is a thin ~10–15% first-loss tranche) and is not a useful risk signal on its own; the real loss-absorption checks are the two coverage monitors above. This LOW-severity informational check only fires on either a structural floor breach at **8%** or a sharp drop of **≥3 percentage points** versus a cached trailing baseline. Deduped: re-alerts only when the ratio worsens; recovery above the prior alerted value re-arms. - **USD3 OC:** Deployed credit divided by senior at-risk credit after sUSD3 absorbs first loss: `Deployed / (Deployed - sUSD3)`. Alerts below the **111%** target as HIGH and below **106%** as CRITICAL. Deduped: re-alerts only when OC drops below the last alerted value (e.g. crossing into critical); recovery above 111% re-arms. This excludes indirect enhancement from underlying credit-line assets and warehouse equity slices. - **Insurance Fund:** Tracks the fund's raw waUSDC share balance and alerts when an outflow is worth **≥$50k USDC**. Caching shares instead of asset value prevents waUSDC yield from masking withdrawals. - **Withdraw Liquidity:** `availableWithdrawLimit()` on the USD3 vault. Alerts when it falls below **$4M** — low withdraw liquidity means senior-tranche withdrawals may queue or stall. Deduped: re-alerts only when the limit drops below the last alerted value; recovery above $4M re-arms. @@ -14,7 +16,7 @@ - **Debt Cap:** `ProtocolConfig.getDebtCap()` vs cached prior. Alerts on any change — signals governance scaling the protocol up or down. - **Nominal sUSD3 Backing Floor:** `ProtocolConfig.config(keccak256("SUSD3_NOMINAL_BACKING_FLOOR"))` vs cached prior. Alerts on any change (governance lever). Separate alert-once when the floor exceeds sUSD3's USD3 holdings valued in USDC — sUSD3 redemptions can be blocked while floor > backing. - **Protocol Pause:** `ProtocolConfig.config(keccak256("IS_PAUSED"))`. Alert-once on transition to true. Distinct from per-vault `isShutdown()` — pauses the underlying credit market. -- **Borrower Default Watch:** optional Envio-backed borrower default risk feed. The Envio indexer maintains `ThreeJaneBorrowerMarket` rows from MorphoCredit events, and the monitor computes the current delinquent/default status at runtime. Alerts are **MEDIUM only** and deduped per borrower/cycle/default milestone. +- **Borrower Default Watch:** optional Envio-backed borrower default risk feed. The Envio indexer maintains `ThreeJaneBorrowerMarket` rows from MorphoCredit events, and the monitor computes the current delinquent/default status at runtime. Alerts are **MEDIUM only** and deduped per borrower/cycle/default milestone. The same snapshot list also feeds the Junior/Senior Coverage checks (no double-fetch from Envio). ## Key Contracts @@ -32,7 +34,10 @@ | USD3 PPS decrease | Any decrease vs cached prior | CRITICAL | | sUSD3 PPS decrease | Any decrease vs cached prior | HIGH | | TVL change | ≥15% absolute change vs prior run | LOW | -| Junior buffer ratio | sUSD3 backing < 15% of deployed credit | HIGH | +| Junior tranche coverage | sUSD3 / weighted at-risk < 2.0x | HIGH | +| Senior coverage low | (Insurance + sUSD3) / weighted at-risk < 1.5x | HIGH | +| Senior coverage critical | (Insurance + sUSD3) / weighted at-risk < 1.0x (USD3 directly at risk; dispatches emergency withdrawal) | CRITICAL | +| Junior buffer drifting | sUSD3 / Deployed < 8% (structural floor) or drop ≥3pp vs cached trailing baseline | LOW | | USD3 OC low | OC < 111% | HIGH | | USD3 OC critical | OC < 106% | CRITICAL | | Insurance fund outflow | ≥$50k USDC since prior run | MEDIUM | @@ -45,6 +50,40 @@ | Borrower delinquent/default watch | New milestone: delinquent, ≤14d, ≤7d, ≤3d, ≤1d, default | MEDIUM | | Monitoring run failure | Uncaught exception in `main()` | LOW | +## Loss waterfall and at-risk weighting + +3Jane has no on-chain collateral; loss risk comes entirely from borrowers not repaying. The waterfall in loss order is: + +1. **Insurance Fund** (~$1M waUSDC) — absorbs first +2. **sUSD3 junior tranche** — absorbs next +3. **USD3 senior tranche** — impaired only after 1 and 2 are exhausted + +The two coverage checks map directly to this waterfall: + +- **Junior Tranche Coverage** (`sUSD3 / weighted at-risk`) is the sUSD3 stakers' risk view and the *leading* indicator that sUSD3 PPS is about to drop. It uses the sUSD3 backing alone. +- **Senior Tranche Coverage** (`(Insurance + sUSD3) / weighted at-risk`) is the senior / USD3 holders' risk view. It uses the full first-loss stack. Breaches only after the junior check, so it is the more severe escalation and the CRITICAL alert (below 1.0x) also dispatches the emergency-withdrawal hook. + +`weighted at-risk` is a severity-weighted sum across every borrower the Envio indexer reports as Delinquent or Default: + +``` +weighted_at_risk = Σ exposure_i × weight(default_bucket_i) +``` + +`exposure_i` is the borrower's outstanding balance (`ending_balance`, falling back to `amount_due` if not yet indexed), valued in USDC. The weights are an expected-loss proxy — starting values, tune later against historical cure / charge-off rates: + +| `default_bucket` | Status | Weight | +|------------------|--------|--------| +| `default` | Default (in default) | 1.0 | +| `1d` | Delinquent, ≤1 day to default | 0.9 | +| `3d` | Delinquent, ≤3 days | 0.7 | +| `7d` | Delinquent, ≤7 days | 0.5 | +| `14d` | Delinquent, ≤14 days | 0.3 | +| `delinquent` | Delinquent, >14 days | 0.3 | + +When Envio is unreachable (URL unset, request failed, or GraphQL errors), the coverage checks are skipped and only the demoted structural "Junior Buffer Drifting" check runs — we never emit coverage alerts on missing data. + +The `largest_borrower_exposure` and `largest_borrower_address` fields are also produced by the aggregator so a future concentration check (one whale defaulting can blow the buffer even if the aggregate looks fine) is cheap to add. + ## Borrower default watch Set `ENVIO_GRAPHQL_URL` to the 3Jane Envio GraphQL endpoint to enable proactive borrower monitoring. Without this env var, the borrower default watch is skipped and all other 3Jane checks continue normally. diff --git a/protocols/3jane/main.py b/protocols/3jane/main.py index 3bcd938..bd871ac 100644 --- a/protocols/3jane/main.py +++ b/protocols/3jane/main.py @@ -8,14 +8,22 @@ Monitors: - PPS (Price Per Share) for USD3 and sUSD3 — alerts on any decrease - TVL (Total Value Locked) via totalAssets() — alerts on >15% change -- Junior tranche buffer — alerts when sUSD3 coverage drops below threshold +- Junior tranche buffer — demoted LOW informational check on sUSD3 / Deployed; + only fires on a structural floor breach or a sharp drop from a cached + trailing baseline (not on the steady-state 10–15% design value) +- Junior coverage — sUSD3 backing / weighted at-risk credit. HIGH alert + when the sUSD3 tranche alone cannot absorb the impaired book. +- Senior coverage — (insurance fund + sUSD3 backing) / weighted at-risk + credit. HIGH below 1.5x and CRITICAL below 1.0x (USD3 directly at risk; + also dispatches emergency withdrawal). - USD3 OC — alerts when senior-tranche overcollateralization drops below thresholds - Insurance fund — alerts on waUSDC outflows of at least $50k - Withdraw liquidity — alerts when USD3 availableWithdrawLimit falls below $4M -Threshold alerts (junior buffer, USD3 OC, withdraw liquidity) are deduped via -cache: the alerted value is stored and no new alert is sent until the value -drops below it; recovering above the threshold re-arms the alert. +Threshold alerts (junior buffer, junior coverage, senior coverage, USD3 OC, +withdraw liquidity) are deduped via cache: the alerted value is stored and +no new alert is sent until the value drops below it; recovering above the +threshold re-arms the alert. - Vault shutdown status — alerts once if either vault enters emergency shutdown - Debt cap changes — alerts when ProtocolConfig debt cap is modified - Nominal sUSD3 backing floor — alerts on change and when floor > sUSD3 backing @@ -82,6 +90,9 @@ CACHE_KEY_INSURANCE_FUND_SHARES = "3JANE_INSURANCE_FUND_SHARES" CACHE_KEY_BORROWER_DEFAULT_WATCH_PREFIX = "3JANE_BORROWER_DEFAULT_WATCH" CACHE_KEY_JUNIOR_BUFFER_ALERTED = "3JANE_JUNIOR_BUFFER_ALERTED" +CACHE_KEY_JUNIOR_BUFFER_BASELINE = "3JANE_JUNIOR_BUFFER_BASELINE" +CACHE_KEY_JUNIOR_COVERAGE_ALERTED = "3JANE_JUNIOR_COVERAGE_ALERTED" +CACHE_KEY_SENIOR_COVERAGE_ALERTED = "3JANE_SENIOR_COVERAGE_ALERTED" CACHE_KEY_USD3_OC_ALERTED = "3JANE_USD3_OC_ALERTED" CACHE_KEY_WITHDRAW_LIMIT_ALERTED = "3JANE_WITHDRAW_LIMIT_ALERTED" @@ -91,12 +102,29 @@ # --- Thresholds --- TVL_CHANGE_THRESHOLD = 0.15 # 15% TVL change alert -JUNIOR_BUFFER_THRESHOLD = 0.15 # Alert when sUSD3 backing < 15% of deployed credit +JUNIOR_BUFFER_THRESHOLD = 0.15 # (legacy, kept for tests) sUSD3 backing < 15% of deployed credit +JUNIOR_BUFFER_FLOOR_THRESHOLD = 0.08 # Demoted LOW: structural floor at 8% of deployed credit +JUNIOR_BUFFER_DROP_THRESHOLD = 0.03 # Demoted LOW: alert if ratio drops >=3pp from trailing baseline +JUNIOR_COVERAGE_HIGH_THRESHOLD = 2.0 # sUSD3 / weighted at-risk credit; below 2x = HIGH +SENIOR_COVERAGE_HIGH_THRESHOLD = 1.5 # (insurance + sUSD3) / weighted at-risk credit; below 1.5x = HIGH +SENIOR_COVERAGE_CRITICAL_THRESHOLD = 1.0 # Below 1x = CRITICAL (USD3 directly exposed) USD3_OC_HIGH_THRESHOLD = 1.11 # Alert when USD3 OC drops below the 111% target USD3_OC_CRITICAL_THRESHOLD = 1.06 # Alert when USD3 OC drops below 106% INSURANCE_FUND_OUTFLOW_THRESHOLD = 50_000 # USDC WITHDRAW_LIMIT_THRESHOLD = 4_000_000 # USDC, alert when USD3 availableWithdrawLimit falls below +# --- At-risk severity weights (expected-loss proxy) --- +# Maps a `default_bucket` from `select_default_watch_bucket` to a severity weight. +# Tuned against historical cure/charge-off rates; revisit once data is available. +DEFAULT_BUCKET_WEIGHTS: dict[str, float] = { + "default": 1.0, # borrower is in Default — full expected loss + "1d": 0.9, # Delinquent, <=1 day to default + "3d": 0.7, # Delinquent, <=3 days + "7d": 0.5, # Delinquent, <=7 days + "14d": 0.3, # Delinquent, <=14 days + "delinquent": 0.3, # Delinquent, >14 days +} + THREE_JANE_BORROWER_DEFAULT_WATCH_QUERY = """ query GetThreeJaneBorrowerDefaultWatch($limit: Int!, $offset: Int!) { ThreeJaneBorrowerMarket( @@ -124,6 +152,30 @@ """ +@dataclass(frozen=True) +class AtRiskExposure: + """Aggregate at-risk credit computed from `BorrowerRepaymentSnapshot` rows. + + All monetary fields are valued in USDC (i.e. raw amounts divided by + `ONE_SHARE`). The headline metric is `total_weighted` — the severity-weighted + sum of borrower exposure, used as the denominator of the junior- and + senior-protection coverage ratios. Unweighted totals and per-status totals + are kept so alert messages can show the composition. + + `largest_borrower_exposure` and `largest_borrower_address` expose the + single largest impaired borrower for optional concentration checks (one + whale defaulting can blow the buffer even if the aggregate looks fine). + """ + + total_weighted: float + total_raw: float + default_exposure: float + delinquent_exposure: float + largest_borrower_exposure: float + largest_borrower_address: str + count: int + + @dataclass(frozen=True) class BorrowerRepaymentSnapshot: market_id: str @@ -213,6 +265,26 @@ def clear_alerted_value(cache_key: str) -> None: set_cache_value(cache_key, -1) +def should_alert_on_worsening(cache_key: str, value: float) -> bool: + """Decide whether to alert when the caller has already detected worsening. + + Lower-is-worse dedup variant that does not check an absolute threshold. + Use when the alert condition (floor breach, baseline drop, etc.) is + computed by the caller and only deduplication is needed. The caller must + record the value with `mark_alerted_value()` after `send_alert()` returns, + so a failed Telegram send retries on the next run. + + Args: + cache_key: Cache key holding the last alerted value (-1 = none). + value: Current metric value; lower is worse. + + Returns: + True when a new alert should be sent. + """ + cached = _get_alerted_value(cache_key) + return not 0 <= cached <= value + + def _as_bool(value: Any) -> bool: if isinstance(value, bool): return value @@ -393,8 +465,78 @@ def parse_envio_borrower_default_watch_rows( return parsed -def load_borrower_default_watch_snapshots_from_envio() -> list[BorrowerRepaymentSnapshot]: - """Load Envio 3Jane borrower rows and compute current default watch candidates.""" +def _borrower_exposure_usdc(snapshot: BorrowerRepaymentSnapshot) -> float: + """Return the impaired borrower's outstanding exposure in USDC. + + Prefers `ending_balance_raw` (the full balance at cycle close, capturing + the borrower's total outstanding position), and falls back to + `amount_due_raw` (the current instalment) if the ending balance has not + been indexed yet. Both values are returned in USDC (post decimal scaling). + """ + ending_balance = snapshot.ending_balance_raw + amount_due = snapshot.amount_due_raw + if ending_balance > 0: + return float(ending_balance) / ONE_SHARE # type: ignore[no-any-return] + return float(amount_due) / ONE_SHARE # type: ignore[no-any-return] + + +def compute_at_risk_exposure( + snapshots: list[BorrowerRepaymentSnapshot], +) -> AtRiskExposure: + """Aggregate impaired-borrower exposure into a single `AtRiskExposure`. + + `exposure_i` is the borrower's outstanding balance (see + `_borrower_exposure_usdc`) and `weight(status_i)` comes from + `DEFAULT_BUCKET_WEIGHTS` keyed by `default_bucket`. An empty input + returns a zero-valued `AtRiskExposure`, which the coverage checks treat + as the healthy "no impaired borrowers" case. + """ + if not snapshots: + return AtRiskExposure(0.0, 0.0, 0.0, 0.0, 0.0, ZERO_ADDRESS, 0) + + total_weighted = 0.0 + total_raw = 0.0 + default_exposure = 0.0 + delinquent_exposure = 0.0 + largest_exposure = 0.0 + largest_address = ZERO_ADDRESS + + for snapshot in snapshots: + exposure = _borrower_exposure_usdc(snapshot) + if exposure <= 0: + continue + total_raw += exposure + if snapshot.repayment_status == "Default": + default_exposure += exposure + elif snapshot.repayment_status == "Delinquent": + delinquent_exposure += exposure + + weight = DEFAULT_BUCKET_WEIGHTS.get(snapshot.default_bucket or "", 0.0) + total_weighted += exposure * weight + + if exposure > largest_exposure: + largest_exposure = exposure + largest_address = snapshot.borrower + + return AtRiskExposure( + total_weighted=total_weighted, + total_raw=total_raw, + default_exposure=default_exposure, + delinquent_exposure=delinquent_exposure, + largest_borrower_exposure=largest_exposure, + largest_borrower_address=largest_address, + count=len(snapshots), + ) + + +def load_borrower_default_watch_snapshots_from_envio() -> list[BorrowerRepaymentSnapshot] | None: + """Load Envio 3Jane borrower rows and compute current default watch candidates. + + Returns `None` when Envio is unavailable (URL not set, request failed, or + GraphQL errors) so callers can distinguish "no impaired borrowers" from + "data unavailable" and skip coverage checks accordingly. Returns an + empty list when Envio is reachable but no rows match the filter. + """ snapshots: list[BorrowerRepaymentSnapshot] = [] seen: set[tuple[str, str]] = set() offset = 0 @@ -403,10 +545,10 @@ def load_borrower_default_watch_snapshots_from_envio() -> list[BorrowerRepayment while True: payload = gql_request(THREE_JANE_BORROWER_DEFAULT_WATCH_QUERY, {"limit": ENVIO_PAGE_SIZE, "offset": offset}) if payload is None: - return snapshots + return None if payload.get("errors"): logger.warning("3Jane Envio GraphQL errors; skipping borrower default watch: %s", payload["errors"]) - return snapshots + return None rows = _extract_envio_borrower_default_watch_rows(payload) page = parse_envio_borrower_default_watch_rows(rows, now_timestamp) @@ -491,9 +633,14 @@ def check_borrower_default_watch_snapshot(snapshot: BorrowerRepaymentSnapshot) - _mark_default_watch_bucket_sent(snapshot, bucket) -def check_borrower_default_watch(_client, _protocol_config) -> None: # type: ignore[no-untyped-def] - """Alert on 3Jane borrower default buckets computed from Envio rows.""" - snapshots = load_borrower_default_watch_snapshots_from_envio() +def check_borrower_default_watch(snapshots: list[BorrowerRepaymentSnapshot] | None) -> None: + """Alert on 3Jane borrower default buckets computed from Envio rows. + + The snapshot list is loaded once in `main()` and shared with the coverage + checks (which also need per-borrower impairment data) so we never double- + fetch from Envio. `None` means Envio was unreachable; the caller already + logs that and skips this run. + """ if not snapshots: return @@ -600,41 +747,190 @@ def check_tvl(usd3_tvl: float, susd3_tvl: float) -> None: def check_junior_buffer(susd3_backing: float, deployed_credit: float) -> None: - """Check if sUSD3 junior tranche provides adequate first-loss coverage. + """Demoted informational check on the sUSD3-to-deployed-credit ratio. + + The historical `sUSD3 / Deployed` ratio is a structural design constant + (sUSD3 is a thin ~10–15% first-loss tranche) and is not a useful risk + signal on its own. The real loss-absorption check now lives in + `check_junior_coverage` / `check_senior_coverage`. This function is kept + only to flag two slow-moving concerns: + + 1. The ratio breaches a genuinely low structural floor (~8%). + 2. The ratio has dropped sharply versus a cached trailing baseline, + signalling leverage drift. - The sUSD3 junior tranche absorbs losses before the senior USD3 tranche. - A thin buffer means USD3 holders are closer to bearing losses directly. - This matches the protocol's backing metric: sUSD3 backing value divided by - deployed credit. The caller supplies both values converted to USDC. - Deduped via cache: re-alerts only when the ratio drops further. + Both fire at LOW severity and are deduped so repeat runs stay silent + until the ratio worsens; recovery above the prior alerted value re-arms. Args: susd3_backing: USD3 held by sUSD3, valued in USDC. - deployed_credit: Borrowed waUSDC in the credit market, converted to USDC. + deployed_credit: Borrowed waUSDC in the credit market, in USDC. """ if deployed_credit <= 0: - # No deployed credit means nothing at risk: clear any outstanding alert. + # Book unwound: clear any outstanding alert and the trailing baseline. clear_alerted_value(CACHE_KEY_JUNIOR_BUFFER_ALERTED) + set_cache_value(CACHE_KEY_JUNIOR_BUFFER_BASELINE, 0) return buffer_ratio = susd3_backing / deployed_credit + previous_baseline = get_cache_value(CACHE_KEY_JUNIOR_BUFFER_BASELINE) + logger.info( - "Junior buffer ratio: %.2f%% (sUSD3 backing: %s / deployed credit: %s)", + "Junior buffer ratio: %.2f%% (sUSD3 backing: %s / deployed credit: %s; baseline: %s)", buffer_ratio * 100, format_usd(susd3_backing), format_usd(deployed_credit), + f"{previous_baseline:.2%}" if previous_baseline > 0 else "n/a", ) - if should_alert_value_drop(CACHE_KEY_JUNIOR_BUFFER_ALERTED, buffer_ratio, JUNIOR_BUFFER_THRESHOLD): - message = ( - f"⚠️ *3Jane Junior Buffer Low*\n" - f"📊 sUSD3 buffer: {buffer_ratio:.2%} of deployed credit\n" - f"💰 sUSD3 backing: {format_usd(susd3_backing)} | Deployed: {format_usd(deployed_credit)}\n" - f"⚠️ First-loss coverage is thin — USD3 holders at higher risk\n" - f"🔗 [sUSD3](https://etherscan.io/address/{SUSD3_ADDRESS})" + # Always refresh the trailing baseline so deterioration is measured + # against the most recent observed ratio. + set_cache_value(CACHE_KEY_JUNIOR_BUFFER_BASELINE, buffer_ratio) + + floor_breach = buffer_ratio < JUNIOR_BUFFER_FLOOR_THRESHOLD + deterioration = previous_baseline > 0 and previous_baseline - buffer_ratio >= JUNIOR_BUFFER_DROP_THRESHOLD + if not (floor_breach or deterioration): + return + + if floor_breach: + reason = f"sUSD3 buffer {buffer_ratio:.2%} < {JUNIOR_BUFFER_FLOOR_THRESHOLD:.0%} structural floor" + else: + drop_pp = (previous_baseline - buffer_ratio) * 100 + reason = ( + f"sUSD3 buffer dropped {previous_baseline:.2%} → {buffer_ratio:.2%} (-{drop_pp:.2f}pp) — leverage drift" ) - send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) - mark_alerted_value(CACHE_KEY_JUNIOR_BUFFER_ALERTED, buffer_ratio) + + if not should_alert_on_worsening(CACHE_KEY_JUNIOR_BUFFER_ALERTED, buffer_ratio): + return + + message = ( + f"⚠️ *3Jane Junior Buffer Drifting*\n" + f"📊 sUSD3 / Deployed: {buffer_ratio:.2%}\n" + f"💰 sUSD3 backing: {format_usd(susd3_backing)} | Deployed: {format_usd(deployed_credit)}\n" + f"ℹ️ {reason}\n" + f"🔗 [sUSD3](https://etherscan.io/address/{SUSD3_ADDRESS})" + ) + send_alert(Alert(AlertSeverity.LOW, message, PROTOCOL)) + mark_alerted_value(CACHE_KEY_JUNIOR_BUFFER_ALERTED, buffer_ratio) + + +def _format_at_risk_breakdown(at_risk: AtRiskExposure) -> str: + """Format an at-risk exposure breakdown for inclusion in alert messages.""" + weighted = format_usd(at_risk.total_weighted) + raw = format_usd(at_risk.total_raw) + default_part = format_usd(at_risk.default_exposure) + delinquent_part = format_usd(at_risk.delinquent_exposure) + return ( + f"💥 At-risk (weighted): {weighted} | Unweighted: {raw} ({at_risk.count} borrowers)\n" + f"📂 Default: {default_part} | Delinquent: {delinquent_part}" + ) + + +def check_junior_coverage( + susd3_backing: float, + at_risk: AtRiskExposure, +) -> None: + """Alert when sUSD3 alone cannot absorb the at-risk credit (sUSD3 stakers). + + Coverage = `susd3_backing / at_risk.total_weighted`. Below `2.0x` fires a + HIGH alert. This is the sUSD3 stakers' risk view and the *leading* + indicator that sUSD3 PPS is about to drop. + + Edge cases: + - `at_risk.total_weighted <= 0` (no impaired borrowers) clears any + outstanding alert and returns silently — the healthy state never + alerts. Coverage is only meaningful when there is at-risk credit. + + Dedup mirrors `should_alert_value_drop`: re-alerts only when the + coverage ratio drops below the last alerted value; recovery above the + 2.0x threshold re-arms. + """ + if at_risk.total_weighted <= 0: + clear_alerted_value(CACHE_KEY_JUNIOR_COVERAGE_ALERTED) + logger.info("Junior coverage skipped — no at-risk credit") + return + + coverage = susd3_backing / at_risk.total_weighted + logger.info( + "Junior coverage: %.4fx (sUSD3 backing: %s / at-risk weighted: %s)", + coverage, + format_usd(susd3_backing), + format_usd(at_risk.total_weighted), + ) + + if not should_alert_value_drop(CACHE_KEY_JUNIOR_COVERAGE_ALERTED, coverage, JUNIOR_COVERAGE_HIGH_THRESHOLD): + return + + message = ( + f"⚠️ *3Jane Junior Tranche Coverage Low*\n" + f"📊 sUSD3 / at-risk: {coverage:.2f}x (threshold {JUNIOR_COVERAGE_HIGH_THRESHOLD:.1f}x)\n" + f"🛡️ sUSD3 backing: {format_usd(susd3_backing)}\n" + f"{_format_at_risk_breakdown(at_risk)}\n" + f"⚠️ sUSD3 alone cannot absorb the impaired credit — sUSD3 stakers at risk\n" + f"🔗 [sUSD3](https://etherscan.io/address/{SUSD3_ADDRESS})" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + mark_alerted_value(CACHE_KEY_JUNIOR_COVERAGE_ALERTED, coverage) + + +def check_senior_coverage( + insurance_fund_assets: float, + susd3_backing: float, + at_risk: AtRiskExposure, +) -> None: + """Alert when the full first-loss stack cannot protect USD3 (senior holders). + + Coverage = `(insurance_fund + susd3_backing) / at_risk.total_weighted`, + which is the sUSD3 stakers' coverage plus the Insurance Fund sitting + above sUSD3 in the loss waterfall. Below `1.5x` fires a HIGH alert; + below `1.0x` fires a CRITICAL alert (USD3 directly exposed to loss; + also triggers the emergency-withdrawal dispatch). + + Edge cases mirror `check_junior_coverage`. + + Dedup uses one cache key for both severities so a worsening run can + escalate HIGH → CRITICAL on the same run, while recovery above the + `1.5x` threshold re-arms the alert. + """ + if at_risk.total_weighted <= 0: + clear_alerted_value(CACHE_KEY_SENIOR_COVERAGE_ALERTED) + logger.info("Senior coverage skipped — no at-risk credit") + return + + first_loss_stack = insurance_fund_assets + susd3_backing + coverage = first_loss_stack / at_risk.total_weighted + logger.info( + "Senior coverage: %.4fx (insurance: %s + sUSD3: %s = %s / at-risk weighted: %s)", + coverage, + format_usd(insurance_fund_assets), + format_usd(susd3_backing), + format_usd(first_loss_stack), + format_usd(at_risk.total_weighted), + ) + + if not should_alert_value_drop(CACHE_KEY_SENIOR_COVERAGE_ALERTED, coverage, SENIOR_COVERAGE_HIGH_THRESHOLD): + return + + if coverage < SENIOR_COVERAGE_CRITICAL_THRESHOLD: + severity = AlertSeverity.CRITICAL + title = "3Jane Senior Coverage CRITICAL" + threshold = SENIOR_COVERAGE_CRITICAL_THRESHOLD + else: + severity = AlertSeverity.HIGH + title = "3Jane Senior Coverage Low" + threshold = SENIOR_COVERAGE_HIGH_THRESHOLD + + message = ( + f"🚨 *{title}*\n" + f"📊 (Insurance + sUSD3) / at-risk: {coverage:.2f}x (threshold {threshold:.1f}x)\n" + f"🛡️ Insurance: {format_usd(insurance_fund_assets)} | sUSD3: {format_usd(susd3_backing)}\n" + f"💰 First-loss stack: {format_usd(first_loss_stack)}\n" + f"{_format_at_risk_breakdown(at_risk)}\n" + f"⚠️ USD3 holders directly exposed to impaired credit\n" + f"🔗 [USD3](https://etherscan.io/address/{USD3_ADDRESS})" + ) + send_alert(Alert(severity, message, PROTOCOL)) + mark_alerted_value(CACHE_KEY_SENIOR_COVERAGE_ALERTED, coverage) def check_usd3_oc(susd3_backing: float, deployed_credit: float) -> None: @@ -1011,6 +1307,14 @@ def main() -> None: format_usd(deployed_credit), ) + # Load Envio borrower snapshots once and share with the default-watch + # and coverage checks (they both need the same per-borrower impairment + # data, so we never double-fetch from Envio). + snapshots = load_borrower_default_watch_snapshots_from_envio() + if snapshots is None: + logger.warning("3Jane Envio data unavailable; coverage checks skipped this run") + at_risk = compute_at_risk_exposure(snapshots or []) + # Run all checks check_pps(usd3_pps, susd3_pps) check_tvl(usd3_tvl, susd3_tvl) @@ -1027,7 +1331,10 @@ def main() -> None: check_debt_cap(client) check_nominal_backing_floor(nominal_floor, susd3_backing) check_protocol_paused(is_paused) - check_borrower_default_watch(client, protocol_config) + check_borrower_default_watch(snapshots) + if snapshots is not None: + check_junior_coverage(susd3_backing, at_risk) + check_senior_coverage(insurance_fund_assets, susd3_backing, at_risk) logger.info( "Monitoring complete — USD3 PPS: %.8f, TVL: %s | sUSD3 PPS: %.8f, TVL: %s", diff --git a/tests/test_3jane.py b/tests/test_3jane.py index facd498..6f470d1 100644 --- a/tests/test_3jane.py +++ b/tests/test_3jane.py @@ -1,6 +1,7 @@ import importlib.util from pathlib import Path from types import ModuleType +from typing import Any import pytest @@ -25,29 +26,101 @@ def stub_cache(monkeypatch: pytest.MonkeyPatch, module: ModuleType) -> dict[str, return cache -def test_junior_buffer_uses_backing_over_deployed_credit(monkeypatch: pytest.MonkeyPatch) -> None: +def test_junior_buffer_does_not_alert_in_design_range(monkeypatch: pytest.MonkeyPatch) -> None: module = load_3jane_module() alerts: list = [] stub_cache(monkeypatch, module) monkeypatch.setattr(module, "send_alert", alerts.append) + # 19.86% sits in the steady-state design range; no alert. module.check_junior_buffer(7_504_000, 37_776_000) assert alerts == [] -def test_junior_buffer_alert_describes_deployed_credit(monkeypatch: pytest.MonkeyPatch) -> None: +def test_junior_buffer_does_not_alert_on_steady_state_drift(monkeypatch: pytest.MonkeyPatch) -> None: module = load_3jane_module() alerts: list = [] stub_cache(monkeypatch, module) monkeypatch.setattr(module, "send_alert", alerts.append) - module.check_junior_buffer(5_000_000, 40_000_000) + # Small steady-state drift within the design range does not alert. + module.check_junior_buffer(7_504_000, 37_776_000) # 19.86% baseline + module.check_junior_buffer(7_300_000, 37_776_000) # 19.32% — 0.54pp drop, below 3pp + + assert alerts == [] + + +def test_junior_buffer_alerts_on_structural_floor_breach(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_junior_buffer(3_000_000, 40_000_000) # 7.5% < 8% floor assert len(alerts) == 1 - assert alerts[0].severity == module.AlertSeverity.HIGH - assert "12.50% of deployed credit" in alerts[0].message - assert "sUSD3 backing: $5.00M | Deployed: $40.00M" in alerts[0].message + assert alerts[0].severity == module.AlertSeverity.LOW + assert "Junior Buffer Drifting" in alerts[0].message + assert "7.50%" in alerts[0].message + assert "structural floor" in alerts[0].message + assert "sUSD3 backing: $3.00M | Deployed: $40.00M" in alerts[0].message + + +def test_junior_buffer_alerts_on_deterioration_from_baseline(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_junior_buffer(6_000_000, 40_000_000) # 15% — primes baseline + module.check_junior_buffer(2_500_000, 40_000_000) # 6.25% — both floor breach and 8.75pp drop + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.LOW + assert "Junior Buffer Drifting" in alerts[0].message + # Floor check takes precedence in the reason line. + assert "structural floor" in alerts[0].message + + +def test_junior_buffer_alerts_on_drop_above_floor(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_junior_buffer(8_000_000, 40_000_000) # 20% baseline + module.check_junior_buffer(4_500_000, 40_000_000) # 11.25% — 8.75pp drop, above 8% floor + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.LOW + assert "leverage drift" in alerts[0].message + assert "20.00% → 11.25%" in alerts[0].message + assert "-8.75pp" in alerts[0].message + + +def test_junior_buffer_silent_on_small_drop_below_baseline(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_junior_buffer(8_000_000, 40_000_000) # 20% baseline + module.check_junior_buffer(7_500_000, 40_000_000) # 18.75% — 1.25pp drop, below 3pp threshold + + assert alerts == [] + + +def test_junior_buffer_silent_at_floor_with_no_baseline(monkeypatch: pytest.MonkeyPatch) -> None: + """First-run check at the design value must not alert (no baseline to drop from).""" + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_junior_buffer(4_000_000, 40_000_000) # 10% — design value, no prior baseline + + assert alerts == [] def test_usd3_oc_does_not_alert_above_high_threshold(monkeypatch: pytest.MonkeyPatch) -> None: @@ -222,18 +295,21 @@ def failing_send(_alert) -> None: assert "Available withdraw limit: $3.50M" in alerts[0].message -def test_junior_buffer_zero_deployed_credit_rearms(monkeypatch: pytest.MonkeyPatch) -> None: +def test_junior_buffer_zero_deployed_credit_clears_state(monkeypatch: pytest.MonkeyPatch) -> None: module = load_3jane_module() alerts: list = [] - stub_cache(monkeypatch, module) + cache = stub_cache(monkeypatch, module) monkeypatch.setattr(module, "send_alert", alerts.append) - module.check_junior_buffer(4_000_000, 40_000_000) # 10% → alert - module.check_junior_buffer(0, 0) # book unwound → clears cache - module.check_junior_buffer(4_800_000, 40_000_000) # 12%, above old cached 10% → alert + module.check_junior_buffer(6_000_000, 40_000_000) # 15% primes baseline (no alert, in design range) + module.check_junior_buffer(2_000_000, 40_000_000) # 5% floor breach → alert + module.check_junior_buffer(0, 0) # book unwound → clears alert and baseline + module.check_junior_buffer(7_000_000, 40_000_000) # 17.5% — no baseline, no alert + module.check_junior_buffer(2_000_000, 40_000_000) # 5% again → fresh alert assert len(alerts) == 2 - assert "12.00% of deployed credit" in alerts[1].message + assert cache[module.CACHE_KEY_JUNIOR_BUFFER_ALERTED] == str(2_000_000 / 40_000_000) + assert cache[module.CACHE_KEY_JUNIOR_BUFFER_BASELINE] == str(2_000_000 / 40_000_000) def test_usd3_oc_zero_deployed_credit_rearms(monkeypatch: pytest.MonkeyPatch) -> None: @@ -250,19 +326,20 @@ def test_usd3_oc_zero_deployed_credit_rearms(monkeypatch: pytest.MonkeyPatch) -> assert alerts[1].severity == module.AlertSeverity.HIGH -def test_junior_buffer_dedupes_same_ratio(monkeypatch: pytest.MonkeyPatch) -> None: +def test_junior_buffer_dedupes_same_deterioration(monkeypatch: pytest.MonkeyPatch) -> None: module = load_3jane_module() alerts: list = [] stub_cache(monkeypatch, module) monkeypatch.setattr(module, "send_alert", alerts.append) - module.check_junior_buffer(5_000_000, 40_000_000) # 12.5% → alert - module.check_junior_buffer(5_000_000, 40_000_000) # same → silent - module.check_junior_buffer(4_000_000, 40_000_000) # 10% → alert + module.check_junior_buffer(8_000_000, 40_000_000) # 20% baseline + module.check_junior_buffer(4_500_000, 40_000_000) # 11.25% drop → alert + module.check_junior_buffer(4_500_000, 40_000_000) # same → silent + module.check_junior_buffer(2_500_000, 40_000_000) # 6.25% further drop (floor breach) → alert assert len(alerts) == 2 - assert "12.50% of deployed credit" in alerts[0].message - assert "10.00% of deployed credit" in alerts[1].message + assert "20.00% → 11.25%" in alerts[0].message + assert "structural floor" in alerts[1].message def test_insurance_shares_round_trip_exactly_through_sqlite(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -501,3 +578,370 @@ def test_parse_envio_borrower_default_watch_rows_default_started_forces_default( assert parsed[0].repayment_status == "Default" assert parsed[0].default_bucket == "default" assert parsed[0].seconds_since_default == 0 + + +# ---------------------------------------------------------------------- +# At-risk exposure aggregation +# ---------------------------------------------------------------------- + + +def _borrower( + module: ModuleType, + *, + borrower: str, + ending_balance: int, + amount_due: int, + repayment_status: str, + default_bucket: str, + cycle_id: int = 1, +) -> Any: + return module.BorrowerRepaymentSnapshot( + market_id="0x" + "11" * 32, + borrower=borrower, + cycle_id=cycle_id, + cycle_end=1_700_000_000, + amount_due_raw=amount_due * module.ONE_SHARE, + ending_balance_raw=ending_balance * module.ONE_SHARE, + credit_raw=(ending_balance * 2) * module.ONE_SHARE, + default_started=repayment_status == "Default", + repayment_status=repayment_status, + default_at=1_700_000_000 + 30 * module.SECONDS_PER_DAY, + seconds_to_default=2 * module.SECONDS_PER_DAY, + seconds_since_default=0, + default_bucket=default_bucket, + ) + + +def test_compute_at_risk_exposure_empty_input() -> None: + module = load_3jane_module() + at_risk = module.compute_at_risk_exposure([]) + + assert at_risk == module.AtRiskExposure(0.0, 0.0, 0.0, 0.0, 0.0, module.ZERO_ADDRESS, 0) + + +def test_compute_at_risk_exposure_weights_by_bucket() -> None: + module = load_3jane_module() + borrower_a = "0x00000000000000000000000000000000000000A1" + borrower_b = "0x00000000000000000000000000000000000000A2" + borrower_c = "0x00000000000000000000000000000000000000A3" + snapshots = [ + _borrower( + module, + borrower=borrower_a, + ending_balance=1_000_000, + amount_due=100_000, + repayment_status="Default", + default_bucket="default", # weight 1.0 + ), + _borrower( + module, + borrower=borrower_b, + ending_balance=2_000_000, + amount_due=200_000, + repayment_status="Delinquent", + default_bucket="3d", # weight 0.7 + ), + _borrower( + module, + borrower=borrower_c, + ending_balance=500_000, + amount_due=50_000, + repayment_status="Delinquent", + default_bucket="14d", # weight 0.3 + ), + ] + + at_risk = module.compute_at_risk_exposure(snapshots) + + # Weighted: 1M*1.0 + 2M*0.7 + 0.5M*0.3 = 1M + 1.4M + 0.15M = 2.55M + assert at_risk.total_weighted == pytest.approx(2_550_000) + assert at_risk.total_raw == pytest.approx(3_500_000) + assert at_risk.default_exposure == pytest.approx(1_000_000) + assert at_risk.delinquent_exposure == pytest.approx(2_500_000) + assert at_risk.largest_borrower_exposure == pytest.approx(2_000_000) + assert at_risk.largest_borrower_address == module.Web3.to_checksum_address(borrower_b) + assert at_risk.count == 3 + + +def test_compute_at_risk_exposure_falls_back_to_amount_due() -> None: + module = load_3jane_module() + borrower = "0x00000000000000000000000000000000000000A1" + snapshots = [ + _borrower( + module, + borrower=borrower, + ending_balance=0, # not yet indexed + amount_due=250_000, + repayment_status="Delinquent", + default_bucket="7d", # weight 0.5 + ), + ] + + at_risk = module.compute_at_risk_exposure(snapshots) + + assert at_risk.total_weighted == pytest.approx(125_000) # 250k * 0.5 + assert at_risk.total_raw == pytest.approx(250_000) + assert at_risk.largest_borrower_exposure == pytest.approx(250_000) + + +def test_compute_at_risk_exposure_ignores_zero_exposure() -> None: + module = load_3jane_module() + borrower = "0x00000000000000000000000000000000000000A1" + snapshots = [ + _borrower( + module, + borrower=borrower, + ending_balance=0, + amount_due=0, # no exposure → should be ignored + repayment_status="Default", + default_bucket="default", + ), + ] + + at_risk = module.compute_at_risk_exposure(snapshots) + + # Zero exposure is filtered out of all monetary fields, but count is preserved. + assert at_risk.total_weighted == 0.0 + assert at_risk.total_raw == 0.0 + assert at_risk.default_exposure == 0.0 + assert at_risk.count == 1 + assert at_risk.largest_borrower_address == module.ZERO_ADDRESS + + +# ---------------------------------------------------------------------- +# Junior / senior coverage checks +# ---------------------------------------------------------------------- + + +def _empty_at_risk(module: ModuleType) -> Any: + return module.AtRiskExposure(0.0, 0.0, 0.0, 0.0, 0.0, module.ZERO_ADDRESS, 0) + + +def test_junior_coverage_no_alert_when_no_at_risk(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + cache = stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + # Prime an alert first so we can verify it gets cleared by the at_risk<=0 path. + module.check_junior_coverage(1_000_000, _at_risk(module, weighted=5_000_000)) + assert len(alerts) == 1 + assert cache[module.CACHE_KEY_JUNIOR_COVERAGE_ALERTED] != "-1" + + module.check_junior_coverage(1_000_000, _empty_at_risk(module)) + + assert alerts == [alerts[0]] # no new alert, just the primed one + assert cache[module.CACHE_KEY_JUNIOR_COVERAGE_ALERTED] == "-1" + + +def test_junior_coverage_alerts_high_below_threshold(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + # sUSD3 backing 3M, at-risk 2M weighted -> 1.5x (< 2.0x) -> HIGH + module.check_junior_coverage(3_000_000, _at_risk(module, weighted=2_000_000)) + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.HIGH + assert "Junior Tranche Coverage Low" in alerts[0].message + assert "1.50x" in alerts[0].message + assert "sUSD3 backing: $3.00M" in alerts[0].message + + +def test_junior_coverage_silent_above_threshold(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + # 2.0x exactly is at threshold (not below) -> silent. + module.check_junior_coverage(4_000_000, _at_risk(module, weighted=2_000_000)) + # Well above threshold -> silent. + module.check_junior_coverage(10_000_000, _at_risk(module, weighted=2_000_000)) + + assert alerts == [] + + +def test_junior_coverage_dedupes(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_junior_coverage(3_000_000, _at_risk(module, weighted=2_000_000)) # 1.5x → alert + module.check_junior_coverage(3_000_000, _at_risk(module, weighted=2_000_000)) # same → silent + module.check_junior_coverage(4_000_000, _at_risk(module, weighted=4_000_000)) # 1.0x → alert + # Recovery above threshold should re-arm + module.check_junior_coverage(10_000_000, _at_risk(module, weighted=2_000_000)) # 5x → clears + module.check_junior_coverage(3_500_000, _at_risk(module, weighted=2_000_000)) # 1.75x → alert again + + assert len(alerts) == 3 + assert "1.50x" in alerts[0].message + assert "1.00x" in alerts[1].message + assert "1.75x" in alerts[2].message + + +def test_senior_coverage_no_alert_when_no_at_risk(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + cache = stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + # Prime an alert first + module.check_senior_coverage(1_000_000, 1_000_000, _at_risk(module, weighted=4_000_000)) # 0.5x → CRITICAL + assert len(alerts) == 1 + assert cache[module.CACHE_KEY_SENIOR_COVERAGE_ALERTED] != "-1" + + module.check_senior_coverage(1_000_000, 1_000_000, _empty_at_risk(module)) + + assert alerts == [alerts[0]] # no new alert + assert cache[module.CACHE_KEY_SENIOR_COVERAGE_ALERTED] == "-1" + + +def test_senior_coverage_alerts_high_above_critical(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + # insurance 1M + sUSD3 2M = 3M, at-risk 2.5M -> 1.2x (< 1.5x) -> HIGH (>= 1.0x) + module.check_senior_coverage(1_000_000, 2_000_000, _at_risk(module, weighted=2_500_000)) + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.HIGH + assert "Senior Coverage Low" in alerts[0].message + assert "1.20x" in alerts[0].message + assert "Insurance: $1.00M | sUSD3: $2.00M" in alerts[0].message + + +def test_senior_coverage_alerts_critical_below_one(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + # insurance 1M + sUSD3 1M = 2M, at-risk 5M -> 0.4x (< 1.0x) -> CRITICAL + module.check_senior_coverage(1_000_000, 1_000_000, _at_risk(module, weighted=5_000_000)) + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.CRITICAL + assert "Senior Coverage CRITICAL" in alerts[0].message + assert "0.40x" in alerts[0].message + assert "First-loss stack: $2.00M" in alerts[0].message + + +def test_senior_coverage_silent_above_high(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + # 1.5x exactly is at threshold (not below) -> silent + module.check_senior_coverage(1_500_000, 1_500_000, _at_risk(module, weighted=2_000_000)) + # 2.0x is comfortably above -> silent + module.check_senior_coverage(2_000_000, 2_000_000, _at_risk(module, weighted=2_000_000)) + + assert alerts == [] + + +def test_senior_coverage_escalates_high_to_critical(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_senior_coverage(1_000_000, 2_000_000, _at_risk(module, weighted=2_500_000)) # 1.2x → HIGH + module.check_senior_coverage(1_000_000, 1_000_000, _at_risk(module, weighted=5_000_000)) # 0.4x → CRITICAL + + assert len(alerts) == 2 + assert alerts[0].severity == module.AlertSeverity.HIGH + assert alerts[1].severity == module.AlertSeverity.CRITICAL + + +def test_senior_coverage_rearms_after_recovery(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_senior_coverage(1_000_000, 1_000_000, _at_risk(module, weighted=4_000_000)) # 0.5x → CRITICAL + module.check_senior_coverage(2_000_000, 2_000_000, _at_risk(module, weighted=2_000_000)) # 2.0x → re-arms + module.check_senior_coverage(1_500_000, 1_500_000, _at_risk(module, weighted=2_500_000)) # 1.2x → alert again + + assert len(alerts) == 2 + assert alerts[1].severity == module.AlertSeverity.HIGH + + +def test_junior_and_senior_use_independent_cache_keys(monkeypatch: pytest.MonkeyPatch) -> None: + """A HIGH junior alert must not suppress an independent senior alert.""" + module = load_3jane_module() + alerts: list = [] + cache = stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_junior_coverage(3_000_000, _at_risk(module, weighted=2_000_000)) # 1.5x → HIGH + # Senior coverage is at 0.5x — independent of the junior alert. + module.check_senior_coverage(500_000, 500_000, _at_risk(module, weighted=2_000_000)) # 0.5x → CRITICAL + + assert len(alerts) == 2 + assert cache[module.CACHE_KEY_JUNIOR_COVERAGE_ALERTED] != "-1" + assert cache[module.CACHE_KEY_SENIOR_COVERAGE_ALERTED] != "-1" + + +def test_coverage_skip_when_envio_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + """When `load_borrower_default_watch_snapshots_from_envio` returns None, main() must skip coverage.""" + module = load_3jane_module() + alerts: list = [] + cache = stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + monkeypatch.setattr(module, "load_borrower_default_watch_snapshots_from_envio", lambda: None) + + # Simulate the `main()` wiring: pass `None` through directly. + snapshots = module.load_borrower_default_watch_snapshots_from_envio() + at_risk = module.compute_at_risk_exposure(snapshots or []) + module.check_junior_coverage(3_000_000, at_risk) + module.check_senior_coverage(1_000_000, 2_000_000, at_risk) + # Default-watch should also silently skip on None. + module.check_borrower_default_watch(snapshots) + + assert alerts == [] + assert cache.get(module.CACHE_KEY_JUNIOR_COVERAGE_ALERTED, "-1") == "-1" + assert cache.get(module.CACHE_KEY_SENIOR_COVERAGE_ALERTED, "-1") == "-1" + + +def test_at_risk_breakdown_includes_composition(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + stub_cache(monkeypatch, module) + monkeypatch.setattr(module, "send_alert", alerts.append) + + at_risk = module.AtRiskExposure( + total_weighted=2_550_000, + total_raw=3_500_000, + default_exposure=1_000_000, + delinquent_exposure=2_500_000, + largest_borrower_exposure=2_000_000, + largest_borrower_address="0x00000000000000000000000000000000000000A2", + count=3, + ) + module.check_junior_coverage(3_000_000, at_risk) + + assert len(alerts) == 1 + assert "At-risk (weighted): $2.55M" in alerts[0].message + assert "Unweighted: $3.50M (3 borrowers)" in alerts[0].message + assert "Default: $1.00M" in alerts[0].message + assert "Delinquent: $2.50M" in alerts[0].message + + +def _at_risk(module: ModuleType, *, weighted: float, count: int = 1) -> Any: + return module.AtRiskExposure( + total_weighted=weighted, + total_raw=weighted, + default_exposure=weighted, + delinquent_exposure=0.0, + largest_borrower_exposure=weighted, + largest_borrower_address=module.ZERO_ADDRESS, + count=count, + )