diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f0688f..4515373 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,11 +1,33 @@ # Contributing +## Good first contribution: add a distressed-credit situation + +The fastest way to help is to add a real restructuring as a worked example — no +infrastructure, just a YAML file: + +```bash +pip install -r requirements-credit.txt +python -m examples.distressed.run new examples/distressed/situations/my_company.yaml +# fill in the cap structure, timeline, operating metrics, and risks +python -m examples.distressed.run run examples/distressed/situations/my_company.yaml +``` + +Use [`examples/distressed/situations/`](examples/distressed/situations/) as your +guide (`ati_2023.yaml`, `serta_2020.yaml`, `hertz_2020.yaml`). Ground every +figure in a public filing or reputable source, mark approximations inline +(`# ~approx`) and say "unknown" rather than guessing, and include a +`DECISION_POINT` event. `tests/test_credit_situation_loader.py` validates every +bundled file automatically. + ## Development Setup ```bash git clone https://github.com/RahulModugula/quantai-dashboard.git cd quantai-dashboard make setup # installs deps + pre-commit hooks + +# Working on just the credit committee? You don't need the full ML stack: +pip install -r requirements-credit.txt ``` ## Running Tests diff --git a/README.md b/README.md index 8b962d4..a58d79f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ -# QuantAI — AI Credit Committee & ML Trading Platform +# QuantAI — An AI Distressed-Credit Committee [![CI/CD Pipeline](https://github.com/RahulModugula/quantai-dashboard/actions/workflows/ci.yml/badge.svg)](https://github.com/RahulModugula/quantai-dashboard/actions/workflows/ci.yml) [![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -[![LiteLLM](https://img.shields.io/badge/LLM-LiteLLM%20%7C%20Claude%20%7C%20GPT--4%20%7C%20Ollama-blueviolet)](https://github.com/BerriAI/litellm) -[![328 tests](https://img.shields.io/badge/tests-328%20passing-brightgreen.svg)](tests/) +[![LiteLLM](https://img.shields.io/badge/LLM-LiteLLM%20%7C%20Claude%20%7C%20GPT%20%7C%20Grok%20%7C%20Ollama-blueviolet)](https://github.com/BerriAI/litellm) +[![tests](https://img.shields.io/badge/tests-passing-brightgreen.svg)](tests/) -> A 4-agent AI investment committee that debates every trade — then writes the memo. -> The same agentic architecture works across equities **and** distressed credit. +> **Four AI agents debate a distressed-credit situation — leverage, recovery waterfall, fulcrum security, tail risks — and write the IC vote memo.** +> Point it at any deal in a YAML file. The same agent architecture also drives an equity-research pipeline; credit is the wedge. + +

+ quantai-credit running a credit committee on a distressed situation +

--- @@ -19,34 +23,59 @@ cd quantai-dashboard python -m examples.distressed.demo ``` -This runs the pre-rendered output of a 4-agent credit committee debate on **ATI Physical Therapy's April 2023 Transaction Support Agreement** — the out-of-court loan-to-own entry that Knighthead Capital and Marathon Asset Management used to build the equity position that closed as a **$523.3M take-private in August 2025 (~11.2x LTM Adj EBITDA)**. The committee's base/bull thesis was confirmed. +This prints a full 4-agent credit committee memo on **ATI Physical Therapy's April 2023 Transaction Support Agreement** — an out-of-court loan-to-own restructuring. It needs no API key, no install, and no data: just the Python standard library. It's the bundled worked example; the next section shows how to run the committee on *your own* situation. + +> The ATI case is real and analyzed at the **April 2023 entry point**, not in hindsight. The position closed as a **$523.3M take-private in August 2025 (~11.2x LTM Adj EBITDA)** — the committee's base/bull thesis was confirmed. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full breakdown and design notes. + +--- + +## Run it on your own deal -For technical evaluators: [TECHNICAL_PORTFOLIO.md](TECHNICAL_PORTFOLIO.md) +The credit committee isn't hardcoded to ATI — point it at any distressed +situation described in a YAML file and it writes the IC memo: -To generate a live run (LLM required): ```bash -export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY or OPENROUTER_API_KEY -python -m examples.distressed.ati_2023 +quantai-credit new my_deal.yaml # scaffold an annotated template +# ...fill in the cap structure, timeline, metrics, and risks... +quantai-credit run my_deal.yaml # 4-agent committee → my_deal_memo.md +quantai-credit list # show bundled example situations ``` +A situation file is just the cap stack, a timeline, operating metrics, and the +risks you already see — no code. The committee computes leverage, coverage, the +recovery waterfall, and the fulcrum security from the numbers you give it, then +debates and votes. Start from [`TEMPLATE.yaml`](examples/distressed/situations/TEMPLATE.yaml) +(annotated blank) or copy one of the bundled examples: + +| Situation | Structure it teaches | +|-----------|---------------------| +| [`ati_2023.yaml`](examples/distressed/situations/ati_2023.yaml) | Loan-to-own via a 2L PIK convertible fulcrum (out-of-court TSA) | +| [`serta_2020.yaml`](examples/distressed/situations/serta_2020.yaml) | Non-pro-rata **uptier** / liability management — inside vs. outside the majority | +| [`hertz_2020.yaml`](examples/distressed/situations/hertz_2020.yaml) | **Asset-coverage** with a bankruptcy-remote fleet-ABS silo (Chapter 11) | + +Each is sourced from public filings, with approximate figures marked inline. Adding another is pure YAML — a great [first contribution](CONTRIBUTING.md). + +> No LLM key? `python -m examples.distressed.demo` shows a complete sample memo +> with zero setup. To run live on your own file, set any LiteLLM-supported key +> (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`) or point +> `QUANTAI_AGENT_MODEL=ollama/llama3` at a local model for zero cost. + --- ## What This Is -Two systems sharing one agentic architecture: +**The core: an AI distressed-credit committee** (`examples/distressed/`). Four agents debate a restructuring situation and write an IC-style vote memo. They call deterministic Python tools — the math doesn't vary by temperature — and hand structured briefs to each other: -**1. Distressed Credit Committee** (`examples/distressed/`) — 4 agents debate a restructuring situation and write an IC-style vote memo: -- **CapStructureAgent** — leverage, coverage, fulcrum security, waterfall recovery (base/bear/bull) -- **SituationAgent** — docket timeline, upcoming catalysts, structural vs. noise events -- **CreditRiskAgent** — devil's advocate: stresses every assumption, enumerates tail risks -- **CreditCommitteeAgent** — writes the vote memo: instrument, sizing, thesis, downside, conditions +- **CapStructureAgent** — leverage, coverage, fulcrum security, recovery waterfall (base/bear/bull) +- **SituationAgent** — docket/timeline events, upcoming catalysts, structural vs. noise, information gaps +- **CreditRiskAgent** — devil's advocate: stresses every assumption, enumerates tail and process risks +- **CreditCommitteeAgent** — writes the vote memo: instrument, sizing, target, catalyst, downside, conditions -**2. Equity Trading System** (`src/`) — live signals, walk-forward ML backtesting, paper trading: -- Ensemble model (RF + XGB + LightGBM + LSTM), retrained every 63 trading days, no lookahead bias -- SHAP explainability on every prediction -- 328 tests, production Docker stack, Prometheus metrics, async FastAPI + Plotly Dash +You describe a situation in a YAML file — cap stack, timeline, operating metrics, known risks — and run `quantai-credit run`. No code. There is no comparable open-source tool for AI-assisted distressed-credit / restructuring analysis; everything else in this space targets equities. -Both systems share a single `BaseAgent` class. Swap the system prompts and tool modules to move between asset classes. +**The same architecture also drives an equity-research pipeline** (`src/`) — `QuantAgent → NewsAgent → RiskAgent → PortfolioManager`, backed by a walk-forward ML ensemble (RF + XGB + LightGBM + LSTM, no lookahead bias), SHAP explainability, backtesting, a FastAPI service, and a Plotly Dash dashboard. It's a second proof that the agent loop is asset-class-agnostic, and a fuller "batteries-included" trading playground if you want it. + +Both share a single `BaseAgent` class (`src/agents/base_agent.py`). Moving to a new asset class means writing a subclass and a tool module — not touching shared infrastructure. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). --- @@ -125,21 +154,40 @@ Half-Kelly sizing └──────────────── --- -## Quick Start +## Install + +### Just the credit committee (lightweight) + +The committee runs on **two dependencies** — no ML stack, no torch, no dashboard: + +```bash +pip install -r requirements-credit.txt # litellm + pyyaml only +python -m examples.distressed.run list # see bundled situations +python -m examples.distressed.run run examples/distressed/situations/ati_2023.yaml +``` + +Install the package to get the `quantai-credit` command on your PATH: + +```bash +pip install -e . # adds the `quantai-credit` entry point +quantai-credit new my_deal.yaml +``` + +### The full system (equity pipeline + API + dashboard) + +This pulls the ML/data stack (torch, scikit-learn, xgboost, ...) and is only needed for the equity trading playground: ```bash -# Docker (recommended — no local setup) +# Docker (no local setup) docker compose up --build ``` ```bash -# Local development -uv venv .venv --python 3.11 -source .venv/bin/activate -make setup # install dependencies +# Local +uv venv .venv --python 3.11 && source .venv/bin/activate +make setup # install all dependencies make seed # download 5y of OHLCV + build features make train # walk-forward ensemble training (~5 min) -make backtest # run backtest, save report make run # start at http://localhost:8000 ``` @@ -373,7 +421,7 @@ GET /api/diagnostics/data-freshness make test ``` -328 tests across: feature engineering, backtest engine, risk metrics, SIP calculator, portfolio operations, signal generation, model drift detection, storage, portfolio optimization, slippage models, SHAP explainability, regime detection, ablation study, live feed, stress testing, multi-agent loop, tool dispatch, agent prompts, orchestrator, agent storage, **and distressed credit tools** (leverage, coverage, recovery waterfall, covenant headroom, fulcrum detection — verified against ATI FY2022 numbers). +340+ tests across: **distressed credit tools** (leverage, coverage, recovery waterfall, covenant headroom, fulcrum detection — verified against ATI FY2022 numbers), the **situation loader** (YAML/JSON round-trip, the bundled examples), feature engineering, backtest engine, risk metrics, SIP calculator, portfolio operations, signal generation, model drift detection, storage, portfolio optimization, slippage models, SHAP explainability, regime detection, ablation study, live feed, stress testing, multi-agent loop, tool dispatch, agent prompts, orchestrator, and agent storage. --- diff --git a/TECHNICAL_PORTFOLIO.md b/TECHNICAL_PORTFOLIO.md deleted file mode 100644 index dc65207..0000000 --- a/TECHNICAL_PORTFOLIO.md +++ /dev/null @@ -1,305 +0,0 @@ -# QuantAI — Technical Portfolio -### AI Research Lead · Knighthead Capital Management - -**Code:** [github.com/RahulModugula/quantai-dashboard](https://github.com/RahulModugula/quantai-dashboard) · **Demo:** `python -m examples.distressed.demo` (no API key) - ---- - -## Five Things in 60 Seconds - -**1. AI credit committee on a Knighthead trade.** -`examples/distressed/` runs a 4-agent debate on ATI Physical Therapy's April 2023 TSA — the loan-to-own entry Knighthead and Marathon used to build the equity position that closed as the August 2025 take-private ($523.3M TEV, ~11.2x EBITDA). The system recommended BUY on the 2L PIK Convertible. The outcome confirmed the base/bull thesis. - -**2. Asset-class-agnostic architecture.** -The equity agents (`QuantAgent → NewsAgent → RiskAgent → PortfolioManager`) and the credit committee (`CapStructureAgent + SituationAgent → CreditRiskAgent → CreditCommitteeAgent`) share a single `BaseAgent` class. The pattern is substrate — not equities or credit. Pointing it at a new asset class means writing a subclass and a tool module, not modifying shared infrastructure. - -**3. Quantitative tools, not just prompts.** -Credit agents call deterministic Python: `calculate_leverage()`, `calculate_coverage()`, `calculate_recovery_waterfall()`, `analyze_recovery_scenarios()`, `check_covenant_headroom()`. The LLM decides which tool to call and interprets the output. The math never varies by temperature. - -**4. Walk-forward ML, no lookahead bias.** -Equity ensemble (RF 0.30 / XGB 0.30 / LGBM 0.25 / LSTM 0.15) retrains every 63 trading days using only data before prediction time. Features are joined strictly by date at the DataFrame merge — not enforced by convention. SHAP explainability on every signal. - -**5. Production-grade and tested.** -Docker, Redis, Prometheus, async FastAPI, SQLite + Alembic, 328 tests across 23 modules, ruff + pre-commit, structured logging with correlation IDs. CI/CD green on every push. - ---- - -## Architecture: Multi-Agent Credit Committee - -```mermaid -graph TD - S[Situation: ATI Physical Therapy
April 2023 TSA] --> A - S --> B - A[CapStructureAgent
leverage · coverage · waterfall · fulcrum] --> C - B[SituationAgent
timeline · catalysts · information gaps] --> C - C[CreditRiskAgent
devil's advocate · tail risks · covenant headroom] --> D - D[CreditCommitteeAgent
IC memo: instrument · sizing · thesis · vote] - D --> M[ati_2023_memo.md] -``` - -All four agents subclass `BaseAgent` (`src/agents/base_agent.py`) — same retry logic, same 10-round tool-call loop, same `AgentBrief` output contract. - -**Phase 1** (`asyncio.gather`): `CapStructureAgent` computes leverage, coverage, recovery waterfall, and identifies the fulcrum security. `SituationAgent` extracts key structural events, upcoming catalysts, and information gaps from the timeline. Both run in parallel. - -**Phase 2**: `CreditRiskAgent` receives both Phase 1 briefs as context, plays devil's advocate — challenges recovery assumptions, surfaces tail risks, stress-tests covenant headroom with specific numbers. - -**Phase 3**: `CreditCommitteeAgent` receives all three briefs and writes the IC vote memo: instrument, sizing range, target price, catalyst, conditions. The output is structured markdown with parseable `KEY: value` lines — `_parse_structured()` extracts them for downstream agents. Tool dispatch is async: `_dispatch_tool()` routes named functions to deterministic Python. - ---- - -## ATI Physical Therapy Case Study (April 2023 TSA) - -**Situation:** FY2022 EBITDA collapsed 83% ($39.8M → $6.7M) on PT wage inflation — a supply-side shock in a growing $53B outpatient market. HPS-led lenders signed a Transaction Support Agreement on April 11, 2023. - -**Thesis:** Loan-to-own via 2L PIK convertible. Supply-side shocks resolve faster than demand-side. Enter at peak stress; PIK coupon eliminates near-term cash burn; fulcrum conversion gives majority equity control on the other side. - -### Capital Structure at Decision Point - -| Tranche | Face ($MM) | Rate | Maturity | Holder | -|---------|-----------|------|----------|--------| -| Super-priority Revolver | $50 | SOFR + ~500 | Feb 2027 | HPS Investment Partners | -| 1L Senior Secured Term Loan | $500 | SOFR + 725 | Feb 2028 | HPS Investment Partners | -| **NEW 2L PIK Convertible (TSA)** | **$125** | **8% PIK** | **Aug 2028** | **TSA participants** | -| Series A Senior Preferred | $165 | 8% cash / 10% PIK | Perpetual | Advent International | - -*$25M new money + $100M exchanged from 1L.* - -### Recovery Analysis - -| Metric | Pre-TSA | Post-TSA | -|--------|---------|---------| -| LTM EBITDA | $6.7M | $6.7M (guided $25–35M FY2024) | -| Gross Debt | $550M | $840M incl. preferred | -| **Leverage** | **82.1x** | **85.8x** | -| Cash Interest | ~$61M | ~$49M (PIK eliminates 2L cash coupon) | -| **Coverage** | **0.11x** | **0.5–0.7x** | - -**Recovery scenarios — 2L PIK Convertible:** - -| Scenario | EBITDA | EV Multiple | Recovery | -|----------|--------|-------------|---------| -| Bear | $10–15M | 5.0x | 55–70c par | -| Base | $30M FY2024 | 7.0x | ~105c par | -| Bull | $50M+ FY2025 | **11.0x** | **250–320c par** | - -> **August 1, 2025:** Knighthead Capital and Marathon Asset Management completed the take-private at $2.85/share, $523.3M TEV, ~11.2x LTM Adj EBITDA. The committee's base/bull thesis was confirmed. The system analyzed this at the April 2023 entry decision point — not with the benefit of hindsight. - -**Committee vote:** APPROVE WITH CONDITIONS — 1.0–1.5% of AUM initial (~$12–18M on $1.2B fund), scale to 2.0% on Q3'23 EBITDA confirmation above $25M run-rate. - ---- - -## Credit Analysis Tools - -All tools return typed Python dataclasses that the LLM receives as JSON. Every calculation is deterministic and independently unit-tested (32 tests in `tests/test_distressed_credit.py` using ATI FY2022 numbers as ground truth). - -```python -# Leverage ratio — optionally capitalizes lease obligations -calculate_leverage( - total_debt_mm: float, - ebitda_mm: float, - include_lease_obligations: float = 0.0, -) -> float - -# Interest coverage — optionally includes preferred dividends -calculate_coverage( - ebitda_mm: float, - cash_interest_mm: float, - preferred_dividends_mm: float = 0.0, -) -> float - -# Per-tranche recovery (%) at a given enterprise value -calculate_recovery_waterfall( - capital_structure: list[CapitalStructureTranche], - enterprise_value_mm: float, - include_piK_accrual: bool = True, -) -> dict[str, float] - -# Bear / base / bull recovery table across EBITDA and multiple assumptions -analyze_recovery_scenarios( - capital_structure: list[CapitalStructureTranche], - base_ebitda_mm: float, - bear_ebitda_mm: float, - bull_ebitda_mm: float, - base_multiple: float = 7.0, - bear_multiple: float = 5.0, - bull_multiple: float = 11.0, -) -> list[RecoveryScenario] - -# Covenant headroom — leverage and coverage breach detection -check_covenant_headroom( - ebitda_mm: float, - total_debt_mm: float, - max_leverage_x: float = 5.0, - min_coverage_x: float = 2.0, - cash_interest_mm: float | None = None, -) -> list[CovenantStatus] - -# Tranche where enterprise value is exhausted -calculate_fulcrum_security( - capital_structure: list[CapitalStructureTranche], - enterprise_value_mm: float, -) -> tuple[str | None, float | None] -``` - ---- - -## What I'd Build at Knighthead - -The ATI case study is the demo. These are the tools I'd build for live deal workflow: - -**Situation Monitor.** A daemon that watches SEC EDGAR filings (8-K, 10-Q, credit agreements) and trading levels for existing positions. When covenant headroom narrows below a threshold, an LTM EBITDA print misses, or a trading level crosses a key level, it runs the credit committee and pushes a delta brief — not a full re-analysis, just what changed and why it matters. - -**Capital Structure Normalizer.** Ingests SEC credit agreement exhibits → normalizes into `CapitalStructureTranche` objects → instant waterfall analysis. Eliminates the manual data entry step before every IC meeting. - -**Portfolio Stress Engine.** Given a set of current positions, runs simultaneous recovery scenarios across all of them. Flags correlated tail risk — e.g., "if the macro scenario that impairs Hertz also impairs Wheels Up, here's the portfolio-level loss." - -**Docket Tracker.** Monitors bankruptcy court dockets for in-process Chapter 11 positions. Agents summarize key filings (plan of reorganization, claims objections, DIP hearings) and flag items that materially change the recovery thesis. - -**Cross-Asset Signal Bridge.** When the equity or CDS on a credit position moves materially, auto-triggers a refreshed credit risk assessment with updated market-implied recovery assumptions — so the credit team has context before the PM asks for it. - -All of these are extensions of the architecture that already exists. The agent loop, the tool dispatch pattern, and the structured output format are already built. The work is domain tools and data plumbing, not foundational infrastructure. - ---- - -## Equity ML Pipeline - -### Ensemble - -| Model | Weight | What It Captures | -|-------|--------|-----------------| -| Random Forest | 0.30 | Non-linear interactions via bootstrap aggregation | -| XGBoost | 0.30 | Sequential error correction on tabular patterns | -| LightGBM | 0.25 | Leaf-wise growth; fast quarterly retraining | -| LSTM | 0.15 | Temporal sequence modeling: momentum and mean reversion | - -Combined: `p = 0.30·p_rf + 0.30·p_xgb + 0.25·p_lgbm + 0.15·p_lstm` - -Classification target: next-day price direction. Calibrated probabilities → Half-Kelly sizing: `f* = (p·b − q) / b`. - -### Walk-Forward Validation - -``` -┌────────────────────────────────────────────────────────────┐ -│ Fold 1: Train [0, 252) → Predict [252, 315) │ -│ Fold 2: Train [0, 315) → Predict [315, 378) │ -│ Fold 3: Train [0, 378) → Predict [378, 441) │ -│ ...expanding window, retrain every 63 trading days... │ -└────────────────────────────────────────────────────────────┘ -``` - -Features at prediction time `t` use only data timestamped before `t`, enforced at the DataFrame merge step, not by convention. Expanding windows keep tree models stable — earlier signal doesn't decay. - -### Feature Engineering (39 Features) - -**Momentum / Trend (8):** `rsi_14`, `macd`, `macd_signal`, `macd_hist`, `adx_14`, `stoch_k`, `stoch_d`, `momentum_5/20` - -**Volatility / Bands (5):** `atr_14`, `bb_upper`, `bb_lower`, `bb_pct_b`, `bb_bandwidth` - -**Mean Reversion (5):** `close_to_sma50`, `close_to_sma200`, `sma50_to_sma200`, `mean_reversion_5`, `mean_reversion_20` - -**Rolling Statistics (4):** `volatility_5`, `volatility_20`, `momentum_5`, `momentum_20` - -**Lagged Returns (4):** `return_lag_1`, `return_lag_2`, `return_lag_3`, `return_lag_5` - -**Volume (3):** `volume_ratio`, `obv`, `obv_zscore` - -**Macro (2, optional):** `vix_close`, `vix_regime` - -### SHAP Explainability - -Per-model SHAP values on every prediction. Ensemble importance = weighted average across models (matching prediction weights). Top-10 features surfaced in `QuantAgent` brief and a dedicated dashboard tab. Time-varying importance tracked across walk-forward folds — detects when signal sources rotate across regimes. - ---- - -## BaseAgent: The Shared Foundation - -`src/agents/base_agent.py` — 214 lines. Both the equity pipeline and the credit committee inherit from it without modification. - -```python -@dataclass -class AgentBrief: - agent_name: str - ticker: str - content: str # Markdown-formatted analysis - structured_data: dict # Parsed KEY: value fields - tool_calls_made: list[str] - tokens_used: int - error: str | None = None -``` - -**Agentic loop:** -``` -For up to 10 tool-call rounds: - 1. litellm.acompletion(model, messages, tools) - 2. If no tool_calls → return AgentBrief(content) - 3. Append assistant message with tool calls - 4. For each tool call: _dispatch_tool(name, args) → append result - 5. Loop -If 10 rounds exceeded → AgentBrief(error="tool_call_limit_exceeded") -On timeout or exception → retry up to max_retries with 1s backoff -``` - -`_dispatch_tool()` is abstract — credit agents route to the deterministic Python tools above; equity agents route to yfinance, SEC EDGAR, and ML prediction calls. - -`_parse_structured()` scans non-indented lines for `RECOMMENDATION`, `INSTRUMENT`, `SIZING`, `TARGET PRICE`, `CATALYST`, `VERDICT`, `RISK RATING`, `SIGNAL`, `CONFIDENCE` — parsed values pass between agents as typed hand-offs. - -**LiteLLM backend:** same code runs Claude, GPT-4, or Ollama. Swap `QUANTAI_AGENT_MODEL` env var. For demo/development: `ollama/llama3` runs locally at zero cost. For production: `anthropic/claude-opus-4-7` or `openrouter/x-ai/grok-4.20`. - ---- - -## Engineering Stack - -| Layer | Technology | Notes | -|-------|------------|-------| -| ML | scikit-learn, XGBoost, LightGBM, PyTorch (LSTM), Optuna | Walk-forward + SHAP | -| AI Agents | LiteLLM, asyncio, tool-call loop | Model-agnostic; swap via env var | -| Credit Tools | Pure Python dataclasses | Deterministic, 32 unit tests | -| Portfolio | PyPortfolioOpt | Efficient frontier, HRP, min-vol | -| API | FastAPI, uvicorn, WebSocket | Async-first | -| Dashboard | Plotly Dash, WSGIMiddleware | 8 tabs | -| Data | yfinance, SQLite, SQLAlchemy, Alembic | Free data only | -| Cache | Redis | Optional, graceful degradation | -| Observability | structlog, Prometheus, correlation IDs | Production-grade | -| Testing | pytest, 328 tests, asyncio_mode=auto | 23 modules | -| CI/CD | GitHub Actions, ruff, pre-commit | Green on every push | -| Infra | Docker Compose (dev + prod multi-stage) | Nginx reverse proxy | - ---- - -## Running It - -```bash -# Instant demo — no API key, no dependencies beyond stdlib -python -m examples.distressed.demo -``` - -```bash -# Live credit committee — generates fresh IC memo -export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, or OPENROUTER_API_KEY -python -m examples.distressed.ati_2023 # writes ati_2023_live_memo.md -``` - -```bash -# Full equity trading system -docker compose up --build -# Dashboard: http://localhost:8000/dashboard -# API docs: http://localhost:8000/api/docs -``` - ---- - -## Design Decisions - -**Why the same BaseAgent works for equities and credit.** The loop cares only about `context: dict`, a list of tool schemas, and abstract `_dispatch_tool()`. Neither the retry logic, token accounting, nor structured-output parsing knows which asset class is running. Adding a new asset class means writing a subclass and a tool module — not modifying shared infrastructure. - -**Why expanding windows over rolling windows.** A feature that mattered two years ago genuinely informed the model that generated today's signal. Rolling windows discard that history and overstate how poorly the model would have performed with less data. The hard constraint is the date-join at the DataFrame merge step — earlier history is included in training, but future data never touches predictions. - -**Why classification over regression.** Calibrated probabilities map directly to Half-Kelly sizing: `f* = (p·b − q) / b`. A SHAP value of "RSI_14 pushed BUY probability up 4.2 percentage points" is actionable; "RSI_14 added 0.003 to predicted return" is not. - -**Why LiteLLM.** The tool-call protocol (OpenAI function-calling schema) is an industry standard. LiteLLM normalizes all provider variants into one interface. Swapping from Claude to Grok to local Ollama is one environment variable — no code changes. - -**Why deterministic Python tools alongside LLMs.** Leverage is `total_debt / ebitda`. That should not vary by temperature, prompt phrasing, or model version. Wrapping it in a typed function with a unit test makes the recovery waterfall auditable — wrong in the same way every time if inputs are wrong. The LLM brings judgment; the tools bring correctness. - ---- - -_Source: [github.com/RahulModugula/quantai-dashboard](https://github.com/RahulModugula/quantai-dashboard) — April 2026_ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..85aca49 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,238 @@ +# Architecture & Design + +How the AI credit committee works, why it's built this way, and where it extends. + +**Code:** [github.com/RahulModugula/quantai-dashboard](https://github.com/RahulModugula/quantai-dashboard) · **Zero-setup demo:** `python -m examples.distressed.demo` · **Run your own deal:** `quantai-credit run my_deal.yaml` + +--- + +## In 60 Seconds + +**1. An AI credit committee you can point at any deal.** +Describe a distressed situation in a YAML file — cap structure, timeline, operating metrics, risks — and four agents debate it and write an IC-style vote memo. The bundled worked example is ATI Physical Therapy's April 2023 Transaction Support Agreement (the loan-to-own entry that closed as the August 2025 take-private at $523.3M TEV, ~11.2x LTM Adj EBITDA). The committee, analyzing at the April 2023 *entry* point, recommended BUY on the 2L PIK Convertible; the outcome confirmed the base/bull thesis. + +**2. Asset-class-agnostic architecture.** +The credit committee (`CapStructureAgent + SituationAgent → CreditRiskAgent → CreditCommitteeAgent`) and an equity-research pipeline (`QuantAgent → NewsAgent → RiskAgent → PortfolioManager`) share a single `BaseAgent` class. The pattern is substrate — not credit or equities. Pointing it at a new asset class means writing a subclass and a tool module, not modifying shared infrastructure. + +**3. Quantitative tools, not just prompts.** +Credit agents call deterministic Python: `calculate_leverage()`, `calculate_coverage()`, `calculate_recovery_waterfall()`, `analyze_recovery_scenarios()`, `check_covenant_headroom()`, `calculate_fulcrum_security()`. The LLM decides which tool to call and interprets the output. The math never varies by temperature. + +**4. Walk-forward ML on the equity side, no lookahead bias.** +The equity ensemble (RF 0.30 / XGB 0.30 / LGBM 0.25 / LSTM 0.15) retrains every 63 trading days using only data before prediction time. Features are joined strictly by date at the DataFrame merge — not enforced by convention. SHAP explainability on every signal. + +**5. Production-grade and tested.** +Docker, Redis, Prometheus, async FastAPI, SQLite + Alembic, ruff + pre-commit, structured logging with correlation IDs, and a full test suite covering both the credit tools and the ML pipeline. CI green on every push. + +--- + +## Architecture: Multi-Agent Credit Committee + +```mermaid +graph TD + S[Situation: any distressed company
cap structure · timeline · metrics] --> A + S --> B + A[CapStructureAgent
leverage · coverage · waterfall · fulcrum] --> C + B[SituationAgent
timeline · catalysts · information gaps] --> C + C[CreditRiskAgent
devil's advocate · tail risks · covenant headroom] --> D + D[CreditCommitteeAgent
IC memo: instrument · sizing · thesis · vote] + D --> M[memo.md] +``` + +All four agents subclass `BaseAgent` (`src/agents/base_agent.py`) — same retry logic, same 10-round tool-call loop, same `AgentBrief` output contract. + +**Phase 1** (`asyncio.gather`): `CapStructureAgent` computes leverage, coverage, the recovery waterfall, and identifies the fulcrum security. `SituationAgent` extracts key structural events, upcoming catalysts, and information gaps from the timeline. Both run in parallel. + +**Phase 2**: `CreditRiskAgent` receives both Phase 1 briefs as context, plays devil's advocate — challenges recovery assumptions, surfaces tail risks, stress-tests covenant headroom with specific numbers. + +**Phase 3**: `CreditCommitteeAgent` receives all three briefs and writes the IC vote memo: instrument, sizing range, target price, catalyst, conditions. The output is structured markdown with parseable `KEY: value` lines — `_parse_structured()` extracts them for downstream agents. Tool dispatch is async: `_dispatch_tool()` routes named functions to deterministic Python. + +The input is a `Situation` (`examples/distressed/models.py`) loaded from a YAML/JSON file via `Situation.from_file()`. Nothing is hardcoded to any one company — see [`examples/distressed/situations/`](../examples/distressed/situations/) for the bundled examples and the annotated template. + +--- + +## Worked Example: ATI Physical Therapy (April 2023 TSA) + +**Situation:** FY2022 EBITDA collapsed 83% ($39.8M → $6.7M) on PT wage inflation — a supply-side shock in a growing $53B outpatient market. HPS-led lenders signed a Transaction Support Agreement on April 11, 2023. + +**Thesis:** Loan-to-own via 2L PIK convertible. Supply-side shocks resolve faster than demand-side. Enter at peak stress; the PIK coupon eliminates near-term cash burn; fulcrum conversion gives majority equity control on the other side. + +### Capital Structure at Decision Point + +| Tranche | Face ($MM) | Rate | Maturity | Holder | +|---------|-----------|------|----------|--------| +| Super-priority Revolver | $50 | SOFR + ~500 | Feb 2027 | HPS Investment Partners | +| 1L Senior Secured Term Loan | $500 | SOFR + 725 | Feb 2028 | HPS Investment Partners | +| **NEW 2L PIK Convertible (TSA)** | **$125** | **8% PIK** | **Aug 2028** | **TSA participants** | +| Series A Senior Preferred | $165 | 8% cash / 10% PIK | Perpetual | Advent International | + +*$25M new money + $100M exchanged from 1L.* + +### Recovery Analysis + +| Metric | Pre-TSA | Post-TSA | +|--------|---------|---------| +| LTM EBITDA | $6.7M | $6.7M (guided $25–35M FY2024) | +| Gross Debt | $550M | $840M incl. preferred | +| **Leverage** | **82.1x** | **85.8x** | +| Cash Interest | ~$61M | ~$49M (PIK eliminates 2L cash coupon) | +| **Coverage** | **0.11x** | **0.5–0.7x** | + +**Recovery scenarios — 2L PIK Convertible:** + +| Scenario | EBITDA | EV Multiple | Recovery | +|----------|--------|-------------|---------| +| Bear | $10–15M | 5.0x | 55–70c par | +| Base | $30M FY2024 | 7.0x | ~105c par | +| Bull | $50M+ FY2025 | **11.0x** | **250–320c par** | + +> **August 1, 2025:** Knighthead Capital and Marathon Asset Management completed the take-private at $2.85/share, $523.3M TEV, ~11.2x LTM Adj EBITDA. The committee's base/bull thesis was confirmed. The system analyzed this at the April 2023 entry decision point — not with the benefit of hindsight. + +**Committee vote:** APPROVE WITH CONDITIONS — 1.0–1.5% of AUM initial, scale to 2.0% on Q3'23 EBITDA confirmation above $25M run-rate. + +All figures are sourced from public filings (ATI 10-K FY2022, 10-Q Q1 2023, 8-K 04/21/2023, 8-K 06/15/2023, DEF 14A 05/01/2023). The full situation lives in [`examples/distressed/situations/ati_2023.yaml`](../examples/distressed/situations/ati_2023.yaml). + +--- + +## Credit Analysis Tools + +All tools return typed Python dataclasses that the LLM receives as JSON. Every calculation is deterministic and independently unit-tested against the ATI FY2022 numbers as ground truth (`tests/test_distressed_credit.py`). + +```python +# Leverage ratio — optionally capitalizes lease obligations +calculate_leverage(total_debt_mm, ebitda_mm, include_lease_obligations=0.0) -> float + +# Interest coverage — optionally includes preferred dividends +calculate_coverage(ebitda_mm, cash_interest_mm, preferred_dividends_mm=0.0) -> float + +# Per-tranche recovery (%) at a given enterprise value +calculate_recovery_waterfall(capital_structure, enterprise_value_mm, include_piK_accrual=True) -> dict + +# Bear / base / bull recovery table across EBITDA and multiple assumptions +analyze_recovery_scenarios(capital_structure, base_ebitda_mm, bear_ebitda_mm, bull_ebitda_mm, + base_multiple=7.0, bear_multiple=5.0, bull_multiple=11.0) -> list + +# Covenant headroom — leverage and coverage breach detection +check_covenant_headroom(ebitda_mm, total_debt_mm, max_leverage_x=5.0, min_coverage_x=2.0, + cash_interest_mm=None) -> list + +# Tranche where enterprise value is exhausted +calculate_fulcrum_security(capital_structure, enterprise_value_mm) -> tuple +``` + +--- + +## Roadmap & Extension Points + +The committee is the substrate. These are natural extensions, most of which are domain tools and data plumbing on top of the existing agent loop rather than new infrastructure — good contribution targets: + +**More situation libraries.** The biggest near-term win is breadth: more real situations in `examples/distressed/situations/`, each a self-contained YAML. Adding one is pure data entry — an ideal first contribution. + +**Situation Monitor.** A daemon that watches SEC EDGAR filings (8-K, 10-Q, credit agreements) and trading levels for tracked positions. When covenant headroom narrows below a threshold, an LTM EBITDA print misses, or a trading level crosses a key level, it re-runs the committee and pushes a delta brief — what changed and why it matters — not a full re-analysis. + +**Capital Structure Normalizer.** Ingests SEC credit-agreement exhibits → normalizes into `CapitalStructureTranche` objects → instant waterfall analysis. Eliminates manual data entry before a committee run. + +**Portfolio Stress Engine.** Given a set of positions, runs simultaneous recovery scenarios across all of them and flags correlated tail risk — e.g. a macro scenario that impairs two holdings at once, with the portfolio-level loss. + +**Docket Tracker.** Monitors bankruptcy-court dockets for in-process Chapter 11 positions. Agents summarize key filings (plan of reorganization, claims objections, DIP hearings) and flag items that materially change the recovery thesis. + +**Cross-Asset Signal Bridge.** When the equity or CDS on a credit position moves materially, auto-triggers a refreshed credit-risk assessment with updated market-implied recovery assumptions. + +--- + +## Equity ML Pipeline + +The equity side is a second proof of the same agent architecture, with a full walk-forward ML stack behind it. + +### Ensemble + +| Model | Weight | What It Captures | +|-------|--------|-----------------| +| Random Forest | 0.30 | Non-linear interactions via bootstrap aggregation | +| XGBoost | 0.30 | Sequential error correction on tabular patterns | +| LightGBM | 0.25 | Leaf-wise growth; fast quarterly retraining | +| LSTM | 0.15 | Temporal sequence modeling: momentum and mean reversion | + +Combined: `p = 0.30·p_rf + 0.30·p_xgb + 0.25·p_lgbm + 0.15·p_lstm` + +Classification target: next-day price direction. Calibrated probabilities → Half-Kelly sizing: `f* = (p·b − q) / b`. + +### Walk-Forward Validation + +``` +┌────────────────────────────────────────────────────────────┐ +│ Fold 1: Train [0, 252) → Predict [252, 315) │ +│ Fold 2: Train [0, 315) → Predict [315, 378) │ +│ Fold 3: Train [0, 378) → Predict [378, 441) │ +│ ...expanding window, retrain every 63 trading days... │ +└────────────────────────────────────────────────────────────┘ +``` + +Features at prediction time `t` use only data timestamped before `t`, enforced at the DataFrame merge step. Expanding windows keep tree models stable — earlier signal doesn't decay. + +### Feature Engineering (39 Features) + +**Momentum / Trend (8):** `rsi_14`, `macd`, `macd_signal`, `macd_hist`, `adx_14`, `stoch_k`, `stoch_d`, `momentum_5/20` + +**Volatility / Bands (5):** `atr_14`, `bb_upper`, `bb_lower`, `bb_pct_b`, `bb_bandwidth` + +**Mean Reversion (5):** `close_to_sma50`, `close_to_sma200`, `sma50_to_sma200`, `mean_reversion_5`, `mean_reversion_20` + +**Rolling Statistics (4):** `volatility_5`, `volatility_20`, `momentum_5`, `momentum_20` + +**Lagged Returns (4):** `return_lag_1`, `return_lag_2`, `return_lag_3`, `return_lag_5` + +**Volume (3):** `volume_ratio`, `obv`, `obv_zscore` + +**Macro (2, optional):** `vix_close`, `vix_regime` + +### SHAP Explainability + +Per-model SHAP values on every prediction. Ensemble importance = weighted average across models (matching prediction weights). Top-10 features surfaced in the `QuantAgent` brief and a dedicated dashboard tab. Time-varying importance tracked across walk-forward folds — detects when signal sources rotate across regimes. + +--- + +## BaseAgent: The Shared Foundation + +`src/agents/base_agent.py` — both the equity pipeline and the credit committee inherit from it without modification. + +```python +@dataclass +class AgentBrief: + agent_name: str + ticker: str + content: str # Markdown-formatted analysis + structured_data: dict # Parsed KEY: value fields + tool_calls_made: list[str] + tokens_used: int + error: str | None = None +``` + +**Agentic loop:** +``` +For up to 10 tool-call rounds: + 1. litellm.acompletion(model, messages, tools) + 2. If no tool_calls → return AgentBrief(content) + 3. Append assistant message with tool calls + 4. For each tool call: _dispatch_tool(name, args) → append result + 5. Loop +If 10 rounds exceeded → AgentBrief(error="tool_call_limit_exceeded") +On timeout or exception → retry up to max_retries with 1s backoff +``` + +`_dispatch_tool()` is abstract — credit agents route to the deterministic Python tools above; equity agents route to yfinance, SEC EDGAR, and ML prediction calls. + +**LiteLLM backend:** the same code runs Claude, GPT, Grok, or local Ollama. Swap `QUANTAI_AGENT_MODEL`. For demo/development, `ollama/llama3` runs locally at zero cost. + +--- + +## Design Decisions + +**Why the same BaseAgent works for credit and equities.** The loop cares only about `context: dict`, a list of tool schemas, and an abstract `_dispatch_tool()`. Neither the retry logic, token accounting, nor structured-output parsing knows which asset class is running. Adding a new asset class means writing a subclass and a tool module — not modifying shared infrastructure. + +**Why deterministic Python tools alongside LLMs.** Leverage is `total_debt / ebitda`. That should not vary by temperature, prompt phrasing, or model version. Wrapping it in a typed function with a unit test makes the recovery waterfall auditable — wrong in the same way every time if inputs are wrong. The LLM brings judgment; the tools bring correctness. + +**Why expanding windows over rolling windows (equity).** A feature that mattered two years ago genuinely informed the model that generated today's signal. Rolling windows discard that history and overstate how poorly the model would have performed with less data. The hard constraint is the date-join at the DataFrame merge step — earlier history is included in training, but future data never touches predictions. + +**Why classification over regression (equity).** Calibrated probabilities map directly to Half-Kelly sizing. A SHAP value of "RSI_14 pushed BUY probability up 4.2 points" is actionable; "RSI_14 added 0.003 to predicted return" is not. + +**Why LiteLLM.** The tool-call protocol (OpenAI function-calling schema) is an industry standard. LiteLLM normalizes provider variants into one interface. Swapping from Claude to Grok to local Ollama is one environment variable — no code changes. diff --git a/docs/assets/credit_committee_demo.svg b/docs/assets/credit_committee_demo.svg new file mode 100644 index 0000000..b6e506d --- /dev/null +++ b/docs/assets/credit_committee_demo.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + quantai-credit + + + + + + + + + + $quantai-credit run examples/distressed/situations/ati_2023.yaml +Running 4-agent credit committee onATI Physical Therapy... + +CapStructureAgent     leverage 82.1x · coverage 0.11x · fulcrum: 2L PIK Convertible +SituationAgent        supply-side EBITDA shock · catalyst: Q3'23 print +CreditRiskAgent       VERDICT: PROCEED WITH CAUTION · RISK 4/5 +CreditCommitteeAgent  memo → ati_physical_therapy_memo.md + +────────────────────────────────────────────────────────────────────────── +IC MEMO — ATI Physical Therapy(Out-of-court restructuring / TSA) +────────────────────────────────────────────────────────────────────────── +RECOMMENDATIONBUY +INSTRUMENT      2L PIK Convertible Notes (8% PIK, Aug 2028) — fulcrum security +SIZING1.01.5% AUM, scale to 2.0% on Q3'23 EBITDA > $25M run-rate +TARGET PRICE140–180c par base · 250–320c bull · 55–70c bear +CATALYST        PT wage normalization → EBITDA recovery (BLS data turning) +VOTE            APPROVE WITH CONDITIONS + +Outcome (Aug 2025):$523.3M take-private @ ~11.2x EBITDA — base/bull thesis confirmed + + + + diff --git a/examples/distressed/ati_2023.py b/examples/distressed/ati_2023.py index 03001f9..3cc3adb 100644 --- a/examples/distressed/ati_2023.py +++ b/examples/distressed/ati_2023.py @@ -34,130 +34,25 @@ from pathlib import Path from examples.distressed.agents import run_credit_committee -from examples.distressed.models import CapitalStructureTranche, Situation +from examples.distressed.models import Situation logger = logging.getLogger(__name__) +# The ATI situation now lives in a YAML file so it can be edited without touching +# Python and serves as the worked example for the `quantai-credit` CLI. This +# function loads it, keeping a single source of truth. +ATI_SITUATION_PATH = Path(__file__).resolve().parent / "situations" / "ati_2023.yaml" + def build_ati_situation() -> Situation: """Real-money situation as of the April 11, 2023 TSA signing. All numbers sourced from ATI 10-K FY2022, 10-Q Q1 2023, 8-K 04/21/2023, - 8-K 06/15/2023, DEF 14A 05/01/2023, and company press releases. See - ``ati_2023_memo.md`` for the full citation list. + 8-K 06/15/2023, DEF 14A 05/01/2023, and company press releases. The data + lives in ``situations/ati_2023.yaml``; see ``ati_2023_memo.md`` for the + full citation list. """ - - cap_structure = [ - CapitalStructureTranche( - name="Super-priority Revolver", - face_amount_mm=50.0, - coupon="SOFR + ~500 drawn", - maturity="Feb 2027", - seniority=1, - holder="HPS Investment Partners (Lender Rep)", - ), - CapitalStructureTranche( - name="1L Senior Secured Term Loan", - face_amount_mm=500.0, - coupon="SOFR + 725", - maturity="Feb 2028", - seniority=2, - holder="HPS Investment Partners (Lender Rep)", - ), - CapitalStructureTranche( - name="Series A Senior Preferred Stock (2022)", - face_amount_mm=165.0, - coupon="8% cash / 10% PIK at issuer option", - maturity="Perpetual", - seniority=3, - holder="Advent International + other SPAC-era investors", - ), - ] - - # Post-TSA, the new 2L PIK convertible layer will insert between 1L Term - # Loan (reduced by $100M) and the Series A Preferred. We include the - # prospective tranche here for clarity — it is the instrument we would - # underwrite. - cap_structure.append( - CapitalStructureTranche( - name="NEW — 2L PIK Convertible Notes (TSA)", - face_amount_mm=125.0, # $25M new money + $100M exchanged from 1L - coupon="8% PIK", - maturity="Aug 2028", - seniority=2, # senior to Preferred, subordinate to 1L Term Loan - holder="TSA participants (prospective)", - ) - ) - - timeline = [ - { - "date": "2021-06", - "event": "FVAC II SPAC merger with ATI closes; $1.9B enterprise value at deal. Over-leveraged for the PT macro that follows.", - }, - { - "date": "2022-02-24", - "event": "Refinances into $550M credit facility led by HPS Investment Partners ($500M 1L TL + $50M SP Revolver). Issues $165M Series A Senior Preferred concurrently.", - }, - { - "date": "2022-FY", - "event": "Revenue $635.7M (+1% YoY). Adj EBITDA collapses to $6.7M (margin 1.1%) from $39.8M / 6.3% in 2021. Primary driver: PT wage inflation and therapist attrition (can't staff clinics to meet demand).", - }, - { - "date": "2023-Q1", - "event": "Going-concern language in 10-Q. Stockholders' equity down to ~$20.5M at 3/31/2023. Covenants under 2022 Credit Agreement under pressure.", - }, - { - "date": "2023-04-11", - "event": "Transaction Support Agreement signed. $25M new-money 2L PIK convertible + $100M of 1L exchanged into 2L PIK convertibles. 1-for-50 reverse stock split proposed.", - }, - {"date": "2023-04-21", "event": "8-K filed describing TSA mechanics."}, - { - "date": "2023-05-01", - "event": "DEF 14A proxy filed for shareholder vote on TSA and reverse split.", - }, - { - "date": "DECISION_POINT", - "event": "THIS IS THE COMMITTEE MEETING — should we participate in the TSA as a 2L PIK convertible holder?", - }, - ] - - operating_metrics = { - "FY2022 Revenue": "$635.7M (+1% YoY)", - "FY2022 Adj EBITDA": "$6.7M (margin 1.1%; down from $39.8M / 6.3% in 2021)", - "Q1 2023 Stockholders' equity": "~$20.5M", - "Clinic count (end-2022)": "~923", - "States covered": "~24", - "Payor mix (highlight)": "Workers' comp franchise (legacy since 1996) — margin-accretive differentiator vs pure-play PT peers", - "Market size (US outpatient PT)": "~$53B fragmented; top 6 chains ~9.7% of clinics", - "Going-concern disclosure": "YES — material doubt flagged by auditors", - "SPAC litigation": "Pending (settled Sep 2024 for $31M — not known at TSA time)", - } - - known_risks = [ - "PT wage inflation could persist or accelerate, keeping EBITDA sub-$15M", - "1L Term Loan remains ahead of us at $400M post-exchange; we are structurally junior to $450M of secured debt + revolver", - "Workers' comp reimbursement policy is state-by-state — any adverse change in IL/TX/PA would hit disproportionately", - "NYSE listing rules — reverse stock split buys time; a second compliance issue would delist", - "SPAC-era securities class action is unresolved; damages could impair junior capital", - "Sector consolidation thesis requires access to capital we may not have after a second distress leg", - "Cramdown / Chapter 11 re-engagement if Q2/Q3 2023 does not show EBITDA recovery", - ] - - return Situation( - company="ATI Physical Therapy", - ticker="ATIP", - sector="Outpatient physical therapy / healthcare services", - situation_type="Out-of-court restructuring (Transaction Support Agreement)", - thesis_one_liner=( - "Loan-to-own via 2L PIK convertible in a margin-compressed but recoverable outpatient PT platform, " - "entering at a structurally distressed moment with asymmetric equity upside on PT wage normalization." - ), - timeline=timeline, - capital_structure=cap_structure, - operating_metrics=operating_metrics, - current_position="No existing exposure — new underwrite", - key_risks=known_risks, - ) + return Situation.from_file(ATI_SITUATION_PATH) async def main() -> None: diff --git a/examples/distressed/mock_mode.py b/examples/distressed/mock_mode.py index f0be05b..9bd8f53 100644 --- a/examples/distressed/mock_mode.py +++ b/examples/distressed/mock_mode.py @@ -1,7 +1,8 @@ """Mock/cached mode for distressed credit agent demonstrations. This module provides pre-cached agent responses for instant demonstrations -without requiring LLM API keys. This is essential for smooth interview demos. +without requiring LLM API keys — useful for offline demos, reproducible +output, and testing without token costs. Usage: from examples.distressed.mock_mode import MockCreditCommittee @@ -39,7 +40,7 @@ class MockCreditCommittee: """Mock credit committee with pre-cached responses. This enables instant demonstrations without API calls, ideal for: - - Interview presentations + - Offline demos and walkthroughs - Code reviews - Testing without token costs - Reproducible outputs diff --git a/examples/distressed/models.py b/examples/distressed/models.py index 77c4209..86105ab 100644 --- a/examples/distressed/models.py +++ b/examples/distressed/models.py @@ -1,7 +1,9 @@ """Shared data models for distressed credit analysis.""" + from __future__ import annotations from dataclasses import dataclass, field +from pathlib import Path from typing import Any @@ -17,6 +19,48 @@ class CapitalStructureTranche: current_price: float | None = None # trade price, as % of par, or None holder: str | None = None # known holder if public + @classmethod + def from_dict(cls, d: dict[str, Any]) -> CapitalStructureTranche: + """Build a tranche from a plain dict (e.g. parsed YAML/JSON). + + Accepts either the canonical field names (``face_amount_mm``, + ``current_price``) or the friendlier aliases used in the YAML + templates (``face_mm``, ``price_pct_par``). + """ + if "name" not in d: + raise ValueError("each capital_structure tranche needs a 'name'") + face = d.get("face_amount_mm", d.get("face_mm")) + if face is None: + raise ValueError(f"tranche '{d['name']}' is missing 'face_mm' / 'face_amount_mm'") + if "seniority" not in d: + raise ValueError(f"tranche '{d['name']}' is missing 'seniority' (1 = most senior)") + return cls( + name=str(d["name"]), + face_amount_mm=float(face), + coupon=str(d.get("coupon", "")), + maturity=str(d.get("maturity", "N/A")), + seniority=int(d["seniority"]), + current_price=_opt_float(d.get("current_price", d.get("price_pct_par"))), + holder=d.get("holder"), + ) + + def to_dict(self) -> dict[str, Any]: + """Inverse of :meth:`from_dict`, using the canonical field names.""" + return { + "name": self.name, + "face_amount_mm": self.face_amount_mm, + "coupon": self.coupon, + "maturity": self.maturity, + "seniority": self.seniority, + "current_price": self.current_price, + "holder": self.holder, + } + + +def _opt_float(v: Any) -> float | None: + """Coerce to float, but pass None through (used for optional prices).""" + return None if v is None else float(v) + @dataclass class Situation: @@ -33,6 +77,76 @@ class Situation: current_position: str = "No existing position" key_risks: list[str] = field(default_factory=list) + @classmethod + def from_dict(cls, d: dict[str, Any]) -> Situation: + """Build a Situation from a plain dict (parsed YAML or JSON). + + This is the entry point that turns a user-authored ``situation.yaml`` + into something the credit committee can run on. Only ``company`` is + strictly required; everything else degrades gracefully so an analyst + can start with a sparse file and fill it in. + """ + if not isinstance(d, dict): + raise ValueError("situation file must contain a mapping at the top level") + company = d.get("company") or d.get("company_name") + if not company: + raise ValueError("situation is missing the required 'company' field") + + raw_tranches = d.get("capital_structure") or [] + if not isinstance(raw_tranches, list): + raise ValueError("'capital_structure' must be a list of tranches") + cap_structure = [CapitalStructureTranche.from_dict(t) for t in raw_tranches] + + timeline = _normalize_timeline(d.get("timeline") or []) + + return cls( + company=str(company), + ticker=d.get("ticker"), + sector=str(d.get("sector", "")), + situation_type=str(d.get("situation_type", "")), + thesis_one_liner=str(d.get("thesis_one_liner", "")), + timeline=timeline, + capital_structure=cap_structure, + operating_metrics=dict(d.get("operating_metrics") or {}), + current_position=str(d.get("current_position", "No existing position")), + key_risks=list(d.get("key_risks") or []), + ) + + @classmethod + def from_file(cls, path: str | Path) -> Situation: + """Load a Situation from a ``.yaml``/``.yml`` or ``.json`` file.""" + p = Path(path) + if not p.exists(): + raise FileNotFoundError(f"situation file not found: {p}") + text = p.read_text() + suffix = p.suffix.lower() + if suffix in (".yaml", ".yml"): + data = _load_yaml(text, source=str(p)) + elif suffix == ".json": + import json + + data = json.loads(text) + else: + raise ValueError( + f"unsupported situation file type '{suffix}' — use .yaml, .yml or .json" + ) + return cls.from_dict(data) + + def to_dict(self) -> dict[str, Any]: + """Serialize back to a plain dict (round-trips with :meth:`from_dict`).""" + return { + "company": self.company, + "ticker": self.ticker, + "sector": self.sector, + "situation_type": self.situation_type, + "thesis_one_liner": self.thesis_one_liner, + "timeline": self.timeline, + "capital_structure": [t.to_dict() for t in self.capital_structure], + "operating_metrics": self.operating_metrics, + "current_position": self.current_position, + "key_risks": self.key_risks, + } + def as_context(self) -> dict[str, Any]: """Flatten into the context dict the agents consume.""" return { @@ -49,6 +163,40 @@ def as_context(self) -> dict[str, Any]: } +def _normalize_timeline(events: list[Any]) -> list[dict[str, str]]: + """Coerce timeline entries into the ``{date, event}`` shape agents expect. + + Accepts the canonical ``{date: ..., event: ...}`` mapping, or a shorthand + single-key mapping ``{2023-04-11: "TSA signed"}`` that is natural to type + in YAML. + """ + out: list[dict[str, str]] = [] + for e in events: + if isinstance(e, dict) and ("date" in e or "event" in e): + out.append({"date": str(e.get("date", "")), "event": str(e.get("event", ""))}) + elif isinstance(e, dict) and len(e) == 1: + ((date, event),) = e.items() + out.append({"date": str(date), "event": str(event)}) + else: + out.append({"date": "", "event": str(e)}) + return out + + +def _load_yaml(text: str, source: str = "") -> dict[str, Any]: + """Parse YAML, with a friendly error if PyYAML isn't installed.""" + try: + import yaml + except ImportError as exc: # pragma: no cover - environment dependent + raise ImportError( + "PyYAML is required to read .yaml situation files. " + "Install it with `pip install pyyaml`, or use a .json file instead." + ) from exc + data = yaml.safe_load(text) + if data is None: + raise ValueError(f"situation file is empty: {source}") + return data + + def _tranche_as_row(t: CapitalStructureTranche) -> dict[str, Any]: return { "name": t.name, diff --git a/examples/distressed/run.py b/examples/distressed/run.py new file mode 100644 index 0000000..b2c19e2 --- /dev/null +++ b/examples/distressed/run.py @@ -0,0 +1,148 @@ +"""``quantai-credit`` — run an AI distressed-credit committee on any situation. + +The credit committee (CapStructure + Situation -> CreditRisk -> Committee) is +data-agnostic: point it at a YAML/JSON file describing *any* distressed company +and it writes an IC-style vote memo. + + quantai-credit new my_deal.yaml # scaffold a blank situation file + quantai-credit run my_deal.yaml # run the committee, write a memo + quantai-credit list # show bundled example situations + +`run` needs an LLM API key (any LiteLLM-supported provider). Set one of +ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY, and optionally +QUANTAI_AGENT_MODEL to pick the model. No key? Try the zero-setup demo first: + + python -m examples.distressed.demo +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import shutil +import sys +from pathlib import Path + +from examples.distressed.models import Situation + +SITUATIONS_DIR = Path(__file__).resolve().parent / "situations" +TEMPLATE_PATH = SITUATIONS_DIR / "TEMPLATE.yaml" + +_KEY_ENV_VARS = ( + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "GEMINI_API_KEY", + "GROQ_API_KEY", +) + + +def _has_api_key() -> bool: + # Ollama runs locally with no key; treat an Ollama model as "keyed". + model = os.environ.get("QUANTAI_AGENT_MODEL", "") + if model.startswith("ollama/"): + return True + return any(os.environ.get(v) for v in _KEY_ENV_VARS) + + +def _slug(name: str) -> str: + return "".join(c if c.isalnum() else "_" for c in name.lower()).strip("_") or "memo" + + +def cmd_new(args: argparse.Namespace) -> int: + dest = Path(args.path) + if dest.exists() and not args.force: + print(f"refusing to overwrite existing file: {dest} (use --force)", file=sys.stderr) + return 1 + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(TEMPLATE_PATH, dest) + print(f"Wrote situation template to {dest}") + print("Edit it, then run: quantai-credit run", dest) + return 0 + + +def cmd_list(_args: argparse.Namespace) -> int: + files = sorted(p for p in SITUATIONS_DIR.glob("*.y*ml") if p.name != "TEMPLATE.yaml") + if not files: + print("No bundled situations found.") + return 0 + print("Bundled example situations:\n") + for p in files: + try: + s = Situation.from_file(p) + label = f"{s.company} — {s.situation_type or 'situation'}" + except Exception as exc: # noqa: BLE001 - listing must never hard-fail + label = f"(could not parse: {exc})" + print(f" {p.relative_to(SITUATIONS_DIR.parent.parent)}") + print(f" {label}\n") + print("Run one with: quantai-credit run ") + return 0 + + +def cmd_run(args: argparse.Namespace) -> int: + try: + situation = Situation.from_file(args.path) + except (FileNotFoundError, ValueError, ImportError) as exc: + print(f"Could not load situation: {exc}", file=sys.stderr) + return 1 + + if not _has_api_key(): + print( + "No LLM API key found. The committee calls an LLM to write the memo.\n" + "Set one of: " + ", ".join(_KEY_ENV_VARS) + "\n" + " (or QUANTAI_AGENT_MODEL=ollama/llama3 to run locally with no key)\n\n" + "To see a full sample memo with zero setup, run:\n" + " python -m examples.distressed.demo", + file=sys.stderr, + ) + return 2 + + # Imported lazily so `new`/`list` work without litellm or a network stack. + from examples.distressed.agents import run_credit_committee + + print(f"Running 4-agent credit committee on {situation.company} ...", file=sys.stderr) + result = asyncio.run(run_credit_committee(situation)) + + memo = result.rendered_memo() + out_path = Path(args.out) if args.out else Path(f"{_slug(situation.company)}_memo.md") + out_path.write_text(memo) + print(f"\nMemo written to {out_path}", file=sys.stderr) + print(f"Total LLM tokens used: {result.total_tokens:,}", file=sys.stderr) + if args.stdout: + print(memo) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="quantai-credit", + description="Run an AI distressed-credit committee on any situation file.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + p_run = sub.add_parser("run", help="run the committee on a situation YAML/JSON file") + p_run.add_argument("path", help="path to a situation .yaml/.yml/.json file") + p_run.add_argument("-o", "--out", help="output memo path (default: _memo.md)") + p_run.add_argument("--stdout", action="store_true", help="also print the memo to stdout") + p_run.set_defaults(func=cmd_run) + + p_new = sub.add_parser("new", help="scaffold a blank situation file from the template") + p_new.add_argument("path", help="where to write the new situation file") + p_new.add_argument("-f", "--force", action="store_true", help="overwrite if it exists") + p_new.set_defaults(func=cmd_new) + + p_list = sub.add_parser("list", help="list bundled example situations") + p_list.set_defaults(func=cmd_list) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/distressed/situations/TEMPLATE.yaml b/examples/distressed/situations/TEMPLATE.yaml new file mode 100644 index 0000000..0c4ee45 --- /dev/null +++ b/examples/distressed/situations/TEMPLATE.yaml @@ -0,0 +1,70 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Credit committee situation file — fill this in for your own deal. +# +# quantai-credit new my_deal.yaml # copies this template +# quantai-credit run my_deal.yaml # runs the 4-agent committee on it +# +# Only `company` is strictly required. Everything else is optional but the more +# you provide, the sharper the memo. Numbers should be in $MM unless noted. +# See ati_2023.yaml in this folder for a fully worked real example. +# ───────────────────────────────────────────────────────────────────────────── + +company: ACME Corp # required +ticker: ACME # optional — null if private +sector: "" # e.g. "Retail / specialty" +situation_type: "" # e.g. "Chapter 11", "Out-of-court exchange", "Liability management" + +# One sentence: what is the opportunity and why does it exist? +thesis_one_liner: >- + e.g. Fulcrum 2L term loan trading at 45c; over-levered from a 2021 LBO but + free-cash-flow positive at trough — par recovery on a refinancing or amend-and-extend. + +current_position: No existing position # or "Hold $20MM of the 1L at 78c", etc. + +# Capital structure, most senior first. seniority: 1 = most senior; ties are fine +# (e.g. two pari passu tranches both seniority 2). Drop `price_pct_par`/`holder` +# if unknown. +capital_structure: + - name: Senior Secured Revolver + face_mm: 100.0 + coupon: SOFR + 350 + maturity: 2027 + seniority: 1 + price_pct_par: null # % of par, e.g. 98.5 + holder: null # e.g. "Bank syndicate" + + - name: 1L Term Loan B + face_mm: 600.0 + coupon: SOFR + 450 + maturity: 2028 + seniority: 2 + price_pct_par: 72 + + - name: Unsecured Notes + face_mm: 400.0 + coupon: 6.5% cash + maturity: 2029 + seniority: 3 + price_pct_par: 38 + +# Chronological events. {date, event} pairs. Use the DECISION_POINT marker for +# the moment the committee is meeting. +timeline: + - date: "2024-Q4" + event: Covenant amendment; revolver springing leverage test waived through 2025. + - date: "2025-03" + event: Hired restructuring advisors; explored liability-management transaction. + - date: DECISION_POINT + event: Committee meeting — do we underwrite the fulcrum? + +# Free-form metrics — keys/values go straight into the memo, so phrase them well. +operating_metrics: + LTM Revenue: $900M + LTM EBITDA: $85M + Cash interest: ~$70M + Liquidity: $40M revolver availability + $25M cash + +# Risks you already see. The CreditRiskAgent will stress-test these and add more. +key_risks: + - Trough EBITDA may not be the trough — secular demand question, not just cyclical. + - Unsecured notes could organize and litigate any non-pro-rata uptier. diff --git a/examples/distressed/situations/ati_2023.yaml b/examples/distressed/situations/ati_2023.yaml new file mode 100644 index 0000000..fdc80dc --- /dev/null +++ b/examples/distressed/situations/ati_2023.yaml @@ -0,0 +1,121 @@ +# ATI Physical Therapy (ATIP) — April 2023 Transaction Support Agreement +# +# The bundled worked example. Decision point: should a distressed-credit fund +# participate in the April 2023 TSA, taking part of the $25M new-money 2L +# PIK-convertible facility plus some of the $100M 1L-to-2L exchange? +# +# All figures sourced from public filings: ATI 10-K FY2022, 10-Q Q1 2023, +# 8-K 04/21/2023, 8-K 06/15/2023, DEF 14A 05/01/2023. +# +# Run it: quantai-credit run examples/distressed/situations/ati_2023.yaml +# (Or copy this file and edit it for your own deal — see TEMPLATE.yaml.) + +company: ATI Physical Therapy +ticker: ATIP +sector: Outpatient physical therapy / healthcare services +situation_type: Out-of-court restructuring (Transaction Support Agreement) + +thesis_one_liner: >- + Loan-to-own via 2L PIK convertible in a margin-compressed but recoverable + outpatient PT platform, entering at a structurally distressed moment with + asymmetric equity upside on PT wage normalization. + +current_position: No existing exposure — new underwrite + +# Capital structure at the decision point. seniority: 1 = most senior. +# face_mm = outstanding principal in $MM. price_pct_par is the trade price as a +# percent of par (omit if the tranche doesn't trade / isn't marked). +capital_structure: + - name: Super-priority Revolver + face_mm: 50.0 + coupon: SOFR + ~500 drawn + maturity: Feb 2027 + seniority: 1 + holder: HPS Investment Partners (Lender Rep) + + - name: 1L Senior Secured Term Loan + face_mm: 500.0 + coupon: SOFR + 725 + maturity: Feb 2028 + seniority: 2 + holder: HPS Investment Partners (Lender Rep) + + # The prospective instrument we would underwrite: $25M new money + $100M + # exchanged out of the 1L. Inserts above the Preferred, below the 1L TL. + - name: NEW — 2L PIK Convertible Notes (TSA) + face_mm: 125.0 + coupon: 8% PIK + maturity: Aug 2028 + seniority: 2 + holder: TSA participants (prospective) + + - name: Series A Senior Preferred Stock (2022) + face_mm: 165.0 + coupon: 8% cash / 10% PIK at issuer option + maturity: Perpetual + seniority: 3 + holder: Advent International + other SPAC-era investors + +# Chronological events. Each entry is a {date, event} pair. Mark the committee +# meeting date with the event text — the agents key off "DECISION_POINT". +timeline: + - date: "2021-06" + event: >- + FVAC II SPAC merger with ATI closes; $1.9B enterprise value at deal. + Over-leveraged for the PT macro that follows. + - date: "2022-02-24" + event: >- + Refinances into $550M credit facility led by HPS ($500M 1L TL + $50M SP + Revolver). Issues $165M Series A Senior Preferred concurrently. + - date: "2022-FY" + event: >- + Revenue $635.7M (+1% YoY). Adj EBITDA collapses to $6.7M (margin 1.1%) + from $39.8M / 6.3% in 2021. Driver: PT wage inflation and therapist + attrition — can't staff clinics to meet demand. + - date: "2023-Q1" + event: >- + Going-concern language in 10-Q. Stockholders' equity down to ~$20.5M at + 3/31/2023. Covenants under the 2022 Credit Agreement under pressure. + - date: "2023-04-11" + event: >- + Transaction Support Agreement signed. $25M new-money 2L PIK convertible + + $100M of 1L exchanged into 2L PIK convertibles. 1-for-50 reverse split + proposed. + - date: "2023-04-21" + event: 8-K filed describing TSA mechanics. + - date: "2023-05-01" + event: DEF 14A proxy filed for shareholder vote on TSA and reverse split. + - date: DECISION_POINT + event: >- + THIS IS THE COMMITTEE MEETING — should we participate in the TSA as a 2L + PIK convertible holder? + +# Free-form operating metrics. Keys and values are passed verbatim to the +# agents, so write them the way you'd put them in a memo. +operating_metrics: + FY2022 Revenue: $635.7M (+1% YoY) + FY2022 Adj EBITDA: $6.7M (margin 1.1%; down from $39.8M / 6.3% in 2021) + Q1 2023 Stockholders' equity: ~$20.5M + Clinic count (end-2022): ~923 + States covered: ~24 + Payor mix (highlight): >- + Workers' comp franchise (legacy since 1996) — margin-accretive + differentiator vs pure-play PT peers + Market size (US outpatient PT): ~$53B fragmented; top 6 chains ~9.7% of clinics + Going-concern disclosure: YES — material doubt flagged by auditors + SPAC litigation: Pending (settled Sep 2024 for $31M — not known at TSA time) + +# Risks you already know about. The CreditRiskAgent stress-tests these and adds +# its own. +key_risks: + - PT wage inflation could persist or accelerate, keeping EBITDA sub-$15M + - >- + 1L Term Loan remains ahead of us at $400M post-exchange; we are + structurally junior to $450M of secured debt + revolver + - >- + Workers' comp reimbursement is state-by-state — adverse change in IL/TX/PA + would hit disproportionately + - NYSE listing rules — a second compliance issue after the reverse split would delist + - SPAC-era securities class action unresolved; damages could impair junior capital + - Sector consolidation thesis requires capital we may not have after a second distress leg + - Cramdown / Chapter 11 re-engagement if Q2/Q3 2023 does not show EBITDA recovery diff --git a/examples/distressed/situations/hertz_2020.yaml b/examples/distressed/situations/hertz_2020.yaml new file mode 100644 index 0000000..d7a0229 --- /dev/null +++ b/examples/distressed/situations/hertz_2020.yaml @@ -0,0 +1,168 @@ +# Hertz Global Holdings (HTZ) — May 2020 Chapter 11 (D. Delaware) +# +# A bundled worked example. Decision point: at/just after the May 22, 2020 +# filing, where is the fulcrum security, and is there value below the fleet ABS +# for corporate creditors (and conceivably old equity), given a collapse — then +# possible recovery — in used-car values? +# +# This is the canonical ASSET-COVERAGE case: a structurally separate, non-Debtor +# fleet-ABS silo (HVF II) sits senior against the cars, while corporate +# creditors lend to the opco. Used-car prices surged in 2021, the estate proved +# SOLVENT, creditors were paid in full, and — rare in Chapter 11 — old equity +# recovered >$1B of value. +# +# Figures grounded in: Hertz FY2019 & FY2020 10-Ks (Note 5 Debt); issuance +# 8-Ks; plan/emergence 8-Ks (May-June 2021); 3d Cir. In re Hertz (2024). +# Approximate figures marked "~approx"; gaps marked "unknown." +# +# Run it: quantai-credit run examples/distressed/situations/hertz_2020.yaml + +company: Hertz Global Holdings +ticker: HTZ +sector: Vehicle rental (Hertz, Dollar, Thrifty) +situation_type: Chapter 11 reorganization (asset-coverage / fleet-ABS structure) + +thesis_one_liner: >- + Asset-coverage case — the fleet is financed in a bankruptcy-remote ABS silo + (HVF II) senior against the cars, so the entire estate's value turns on + used-vehicle residuals, which collapsed in spring 2020 and then surged in 2021. + +current_position: No existing exposure — new underwrite at/after the filing + +# Capital structure at/around the May 22, 2020 filing. +# Anchor = audited Dec 31, 2019 debt footnote (revolver ~undrawn at YE2019 but +# ~$615M drawn just before filing). seniority: 1 = most senior. Two silos. +capital_structure: + # --- Corporate (Non-Vehicle) debt, issued by The Hertz Corporation --- + - name: Senior Term Loan B + face_mm: 660.0 + coupon: Floating (~3.5-4.45%) + maturity: Jun 2023 + seniority: 1 + holder: First-lien senior secured (bank/institutional) + + - name: Senior Revolving Credit Facility + face_mm: 615.0 # ~undrawn at YE2019; ~$615M drawn just before filing ~approx + coupon: Floating (~3.41%) + maturity: Jun 2021 + seniority: 1 + holder: First-lien senior secured (pari with TLB) + + - name: 7.625% Senior Second Priority Secured Notes due 2022 + face_mm: 350.0 + coupon: 7.625% + maturity: Jun 2022 + seniority: 2 + holder: Second-lien secured (trustee Wells Fargo) + + - name: 6.250% Senior Notes due 2022 + face_mm: 500.0 # ~approx — derived as residual of the audited $2,700M unsecured aggregate + coupon: 6.250% + maturity: Oct 2022 + seniority: 3 + holder: Senior unsecured + + - name: 5.500% Senior Notes due 2024 + face_mm: 800.0 # ~approx — derived as residual of the audited $2,700M unsecured aggregate + coupon: 5.500% + maturity: Oct 2024 + seniority: 3 + holder: Senior unsecured + + - name: 7.125% Senior Notes due 2026 + face_mm: 500.0 # confirmed in Aug 1, 2019 8-K + coupon: 7.125% + maturity: Aug 2026 + seniority: 3 + holder: Senior unsecured + + - name: 6.000% Senior Notes due 2028 + face_mm: 900.0 # confirmed in Nov 2019 pricing 8-K (upsized from $750M) + coupon: 6.000% + maturity: Jan 2028 + seniority: 3 + holder: Senior unsecured + + # --- Fleet ABS silo: bankruptcy-remote, NON-Debtor; did NOT file Ch.11 --- + # Structurally senior against the VEHICLES only; non-recourse to the opco. + # Shown as a separate silo, not directly rankable against corporate debt. + - name: HVF II U.S. Fleet ABS (VFNs + medium-term notes) + face_mm: 9264.0 # ~approx, YE2019; ~$13.4B incl. Donlen HFLF + intl vehicle debt + coupon: Floating/fixed ~2.9-4.2% + maturity: 2020-2024 (rolling series) + seniority: 1 # senior against the fleet within the SPE; separate silo + holder: Hertz Vehicle Financing II LP (non-Debtor SPE); ABS investors + +# Chronological events. Mark the committee meeting with DECISION_POINT. +timeline: + - date: "2019-FY" + event: >- + Revenue $9.78B; Adjusted Corporate EBITDA $649M. Total debt ~$17.1B + ($13.4B vehicle ABS / $3.7B corporate). Average U.S. RAC fleet ~535k. + - date: "2020-03" + event: >- + COVID craters travel; April revenue down ~73% YoY. Used-car auction values + briefly collapse, eroding fleet asset coverage under the HVF master lease. + - date: "2020-04" + event: >- + Hertz misses ABS lease payments; HVF noteholders demand depreciation + top-ups (~$135M cash) the company cannot meet — the ABS squeeze, not the + corporate bonds, forces the filing. + - date: "2020-05-22" + event: >- + Hertz files Chapter 11 (D. Delaware, Judge Walrath). International units NOT + in the U.S. cases. ~$6.0B of corporate debt accelerated. + - date: DECISION_POINT + event: >- + THIS IS THE COMMITTEE MEETING — at/after the filing, where is the fulcrum? + Is there value below the fleet ABS for the unsecured notes (and possibly + old equity), betting on a used-car recovery and a solvent-debtor outcome? + - date: "2020-06-15" + event: >- + Hertz signs a $500M ATM equity program with Jefferies during bankruptcy + (meme rally); SEC review halts it after ~$29M sold — offering scrapped. + - date: "2020-07" + event: >- + Hertz settles the HVF master-lease fight (had moved to reject ~144k + vehicles), agreeing to ~$650M of base rent over six months. + - date: "2021-05" + event: >- + Used-vehicle prices surge (record Manheim index). KHCA group (Knighthead + + Certares + Apollo) wins a ~30-hour auction over Centerbridge/Warburg/Dundon + with a "$6B bid." + - date: "2021-06-30" + event: >- + Hertz emerges. >$5.9B new equity capital; creditors paid cash in full; + ~$5.0B debt eliminated. OLD equity recovers >$1B (cash + 3% common + 30-yr + warrants for 18% struck off a $6.5B equity value). + +operating_metrics: + FY2019 Revenue: $9.78B + FY2020 Revenue: $5.26B (-46%) + FY2019 Adj Corporate EBITDA: $649M + FY2020 Adj Corporate EBITDA: -$995M + FY2020 Net loss: -$1.85B + Total debt at filing: ~$19B framing (~$10B U.S. ABS / ~$5B U.S. corporate / ~$4B other); audited YE2019 anchor $17.1B + Avg U.S. RAC fleet: ~535k (2019) -> ~424k (2020, -21%) + Going-concern: YES — substantial doubt disclosed in FY2020 10-K + Key driver: used-vehicle residual values (Manheim index) — collapsed spring 2020, surged 2021 + Brands: Hertz, Dollar, Thrifty (+ Firefly, Donlen fleet leasing — sold Sep 2021 for $891M) + +key_risks: + - >- + Fleet ABS (HVF II) is structurally senior against the cars and non-recourse + to the opco — corporate creditors only recover on residual fleet value after + the ABS is satisfied. + - >- + Used-vehicle residuals are the whole thesis — a prolonged depression in + Manheim values would have wiped out junior corporate claims and equity. + - COVID travel-demand recovery timing was deeply uncertain at the May 2020 decision point. + - >- + Master-lease rejection/renegotiation risk — the ABS lease fight could have + shifted billions of value between silos. + - >- + Equity recovered here (rare) only because the estate proved SOLVENT; do not + generalize the meme-stock outcome to other Chapter 11 cases. + - >- + Solvent-debtor / make-whole risk (3d Cir. 2024) — post-petition interest and + "Applicable Premiums" reshaped recoveries across the stack. diff --git a/examples/distressed/situations/serta_2020.yaml b/examples/distressed/situations/serta_2020.yaml new file mode 100644 index 0000000..656fdca --- /dev/null +++ b/examples/distressed/situations/serta_2020.yaml @@ -0,0 +1,140 @@ +# Serta Simmons Bedding (private) — June 2020 Uptier ("Position Enhancement Transaction") +# +# A bundled worked example. Decision point: a ~51% majority lender group is +# offering us a seat in a super-priority "uptier" that primes the rest of the +# first lien. Do we join the majority and roll up — or stay out and risk being +# subordinated below ~$1.05B of new super-priority debt? +# +# This is the canonical liability-management / "lender-on-lender violence" case. +# It later went to Chapter 11 (Jan 2023, S.D. Tex.) and the Fifth Circuit +# (Dec 31, 2024) ultimately held the uptier was NOT a permitted "open market +# purchase" and violated the pro rata "sacred right." +# +# Figures grounded in: 5th Cir. opinion In re Serta Simmons Bedding (2024); +# Moody's & S&P rating actions; Serta press release 06/22/2020 (PRNewswire); +# Ch. 11 docket (S.D. Tex., filed 01/23/2023). Approximate figures marked +# "~approx"; gaps marked "unknown" rather than guessed. +# +# Run it: quantai-credit run examples/distressed/situations/serta_2020.yaml + +company: Serta Simmons Bedding +ticker: private +sector: Consumer durables / mattress manufacturing +situation_type: Out-of-court liability management (non-pro-rata uptier exchange) + +thesis_one_liner: >- + Classic uptier "lender-on-lender violence" — a ~51% majority can prime the + minority via new super-priority debt, so the real question is not credit + quality but whether you are inside or outside the controlling group. + +current_position: Existing 1L term loan holder — must decide whether to join the uptier + +# Capital structure at the decision point (just BEFORE the June 2020 uptier). +# seniority: 1 = most senior. face_mm = outstanding principal in $MM. +capital_structure: + - name: ABL Revolving Credit Facility + face_mm: 225.0 + coupon: Floating (ABL grid; spread unknown) + maturity: 2021 (unknown exact) + seniority: 1 + holder: Bank group + + - name: First-Lien Term Loan (2016) + face_mm: 1900.0 # ~$1.95B original face; ~$1.84-1.89B outstanding by 2020 ~approx + coupon: LIBOR + 350 + maturity: Nov 2023 + seniority: 2 + holder: >- + Broadly syndicated across CLOs/credit funds — Eaton Vance, Invesco, + Credit Suisse AM, Barings, Boston Mgmt & Research (later "Prevailing" + group) vs. Apollo, Angelo Gordon, LCM, North Star (later excluded) + + - name: Second-Lien Term Loan (2016) + face_mm: 450.0 # ~$428M outstanding by 2020 ~approx; traded ~30c in late 2019 + coupon: LIBOR + spread (unknown; market reports materially wider than 1L) + maturity: Nov 2024 + seniority: 3 + holder: Same syndicated CLO/credit-fund universe + + # The PROPOSED uptier instruments. If we join, ~$1.2B of old 1L/2L is + # exchanged into ~$850M FLSO at a discount; $200M new money sits on top. + - name: NEW — Super-Priority First-Out Term Loan (new money) + face_mm: 200.0 + coupon: Floating (Moody's-rated B2) + maturity: Aug 2023 + seniority: 1 # leapfrogs the entire pre-existing stack + holder: Participating ("Prevailing") majority group + + - name: NEW — Super-Priority Second-Out Term Loan (FLSO roll-up) + face_mm: 850.0 # Moody's $851MM; built from ~$734M (ex-1L @ ~74c) + ~$116M (ex-2L @ ~39c) + coupon: Floating (Moody's-rated Caa2) + maturity: Aug 2023 + seniority: 2 + holder: Participating ("Prevailing") majority group + +# Chronological events. Mark the committee meeting with DECISION_POINT. +timeline: + - date: "2016-11" + event: >- + Serta (Advent International-owned) raises $1.95B 1L TL (L+350, due 2023) + + $450M 2L TL (due 2024) + $225M ABL. Credit agreement allows buybacks + via "open market purchases" or pro-rata Dutch auctions — the language + later litigated. + - date: "2019-12-04" + event: >- + S&P downgrades 1L to CCC; 1L trading below 60, 2L around 30 cents. + Leverage building above 8x. + - date: "2020-03" + event: >- + COVID shutters mattress retail distribution; sharp budget shortfall and + going-concern pressure trigger a scramble for liquidity. + - date: "2020-Q2" + event: >- + Two competing rescue proposals emerge — an Apollo/Angelo Gordon "drop-down" + plan vs. the ~51% majority "uptier." Serta picks the uptier. + - date: DECISION_POINT + event: >- + THIS IS THE COMMITTEE MEETING — a ~51% majority group offers us a seat in + the super-priority uptier (join and roll up at ~74c, or stay out and be + primed below ~$1.05B of new super-priority debt). Decide. + - date: "2020-06-22" + event: >- + Uptier closes. $200M new-money first-out + ~$850M second-out (FLSO) prime + the non-participating lenders. Company claims >$400M debt reduction and + >$300M cash. Excluded lenders (LCM, North Star) sue in NY. + - date: "2023-01-23" + event: >- + Serta files prearranged Chapter 11 (S.D. Texas) with ~$1.9B funded debt; + plan equitizes ~$1.59B, leaving ~$315M. + - date: "2024-12-31" + event: >- + Fifth Circuit reverses — the uptier was NOT a permitted "open market + purchase," violated the pro rata sacred right, and the plan indemnity for + participants is excised; remanded for damages to excluded lenders. + +operating_metrics: + Revenue: ~$2.2B annually (Moody's, 2020) ~approx + EBITDA: Not cleanly disclosed publicly — unknown + Leverage: ">8x (Apr 2020); ~10.5x pro forma at uptier; projected ~13x within 12-18mo" + Liquidity post-uptier: ">$300M cash (company) ~approx" + Going-concern: YES — COVID demand/distribution shock drove the June 2020 deal + Market position: One of the largest mattress makers in North America; ~27 plants + Brands: Serta, Beautyrest, Simmons, Tuft & Needle, Tomorrow Sleep + PE owner: Advent International (lost equity in the 2023 reorganization) + Credit covenant at issue: pro rata "sacred right"; buyback carve-outs for "open market purchases" / Dutch auctions + +key_risks: + - >- + If we stay OUT, we are primed below ~$1.05B of new super-priority debt and + pushed to effective third/fourth lien — recovery could collapse. + - >- + If we join, we accept a ~74c exchange haircut and litigation exposure as a + "Prevailing Lender" (the indemnity later struck down by the 5th Circuit). + - >- + Legal risk both ways — "open market purchase" interpretation was unsettled + (Serta lost at the 5th Circuit in 2024; contrast Mitel in NY). + - >- + Pro forma leverage ~10.5x rising to ~13x — Moody's called the structure + "not sustainable"; a follow-on restructuring was likely (and came in 2023). + - COVID demand whipsaw — a 2020-21 stimulus rebound, then a 2022 downturn that precipitated Chapter 11. + - Reputational / relationship risk of participating in non-pro-rata "lender-on-lender violence." diff --git a/pyproject.toml b/pyproject.toml index 2d7940d..953b291 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "litellm>=1.40.0", "aiohttp>=3.9.0", "feedparser>=6.0.0", + "pyyaml>=6.0", ] [project.optional-dependencies] @@ -52,8 +53,11 @@ dev = [ "ruff>=0.2.0", ] +[project.scripts] +quantai-credit = "examples.distressed.run:main" + [tool.hatch.build.targets.wheel] -packages = ["src"] +packages = ["src", "examples"] [tool.ruff] target-version = "py311" diff --git a/requirements-credit.txt b/requirements-credit.txt new file mode 100644 index 0000000..670373d --- /dev/null +++ b/requirements-credit.txt @@ -0,0 +1,13 @@ +# Minimal dependencies for the AI distressed-credit committee only. +# This skips the entire equity ML/dashboard stack (torch, pandas, scikit-learn, +# xgboost, lightgbm, shap, fastapi, dash, ...) — install it when you just want +# to run `quantai-credit` / `python -m examples.distressed.run`. +# +# pip install -r requirements-credit.txt +# python -m examples.distressed.run run examples/distressed/situations/ati_2023.yaml +# +# (The full project — equity pipeline, API, dashboard — installs via +# `pip install -e .` or `make setup`.) + +litellm>=1.40.0 # model-agnostic LLM calls (Claude / GPT / Grok / Ollama / ...) +pyyaml>=6.0 # parse situation .yaml files diff --git a/scripts/render_demo_svg.py b/scripts/render_demo_svg.py new file mode 100644 index 0000000..cba68d2 --- /dev/null +++ b/scripts/render_demo_svg.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Render a `quantai-credit run` terminal session to a committable SVG. + +The SVG renders inline on GitHub (no browser or dashboard run needed) and is the +hero image for the README. Regenerate it with: + + python scripts/render_demo_svg.py + +Requires `rich` (pip install rich). Content is sourced from the committed ATI +memo (examples/distressed/ati_2023_memo.md) so the asset is stable. +""" + +from __future__ import annotations + +from pathlib import Path + +from rich.console import Console + +OUT = Path(__file__).resolve().parent.parent / "docs" / "assets" / "credit_committee_demo.svg" + + +def render() -> None: + console = Console(record=True, width=92) + + console.print( + "[bright_black]$[/] [bold]quantai-credit run[/] examples/distressed/situations/ati_2023.yaml" + ) + console.print( + "[bright_black]Running 4-agent credit committee on[/] [bold]ATI Physical Therapy[/] [bright_black]...[/]" + ) + console.print() + console.print( + " [green]✓[/] [cyan]CapStructureAgent[/] leverage [bold]82.1x[/] · coverage [bold]0.11x[/] · fulcrum: [yellow]2L PIK Convertible[/]" + ) + console.print( + " [green]✓[/] [cyan]SituationAgent[/] supply-side EBITDA shock · catalyst: [yellow]Q3'23 print[/]" + ) + console.print( + " [green]✓[/] [cyan]CreditRiskAgent[/] VERDICT: [yellow]PROCEED WITH CAUTION[/] · RISK [bold]4/5[/]" + ) + console.print( + " [green]✓[/] [cyan]CreditCommitteeAgent[/] memo → [bright_black]ati_physical_therapy_memo.md[/]" + ) + console.print() + console.print( + "[bright_black]──────────────────────────────────────────────────────────────────────────[/]" + ) + console.print( + " [bold white]IC MEMO — ATI Physical Therapy[/] [bright_black](Out-of-court restructuring / TSA)[/]" + ) + console.print( + "[bright_black]──────────────────────────────────────────────────────────────────────────[/]" + ) + console.print(" [bold green]RECOMMENDATION[/] [bold green]BUY[/]") + console.print( + " [bold green]INSTRUMENT[/] 2L PIK Convertible Notes (8% PIK, Aug 2028) — [yellow]fulcrum security[/]" + ) + console.print( + " [bold green]SIZING[/] 1.0–1.5% AUM, scale to 2.0% on Q3'23 EBITDA > $25M run-rate" + ) + console.print( + " [bold green]TARGET PRICE[/] 140–180c par base · [bold]250–320c bull[/] · 55–70c bear" + ) + console.print( + " [bold green]CATALYST[/] PT wage normalization → EBITDA recovery (BLS data turning)" + ) + console.print(" [bold yellow]VOTE[/] APPROVE WITH CONDITIONS") + console.print() + console.print( + " [bright_black]Outcome (Aug 2025):[/] [green]$523.3M take-private @ ~11.2x EBITDA — base/bull thesis confirmed[/]" + ) + + OUT.parent.mkdir(parents=True, exist_ok=True) + console.save_svg(str(OUT), title="quantai-credit") + print(f"Wrote {OUT}") + + +if __name__ == "__main__": + render() diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 33ee13b..3f38ac4 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -13,6 +13,26 @@ result = await run_full_analysis("AAPL") """ -from src.agents.orchestrator import run_full_analysis +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - import only for type checkers + from src.agents.orchestrator import run_full_analysis __all__ = ["run_full_analysis"] + + +def __getattr__(name: str): + """Lazily expose the equity orchestrator. + + The orchestrator pulls in the full ML/data stack (pandas, torch, yfinance, + shap, ...). Importing it eagerly here would force every consumer of the + lightweight `BaseAgent` — notably the distressed-credit committee, which + only needs litellm + stdlib — to install all of it. PEP 562 lets us keep + `from src.agents import run_full_analysis` working while deferring that + heavy import until it's actually used. + """ + if name == "run_full_analysis": + from src.agents.orchestrator import run_full_analysis + + return run_full_analysis + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tests/test_credit_situation_loader.py b/tests/test_credit_situation_loader.py new file mode 100644 index 0000000..b0962bc --- /dev/null +++ b/tests/test_credit_situation_loader.py @@ -0,0 +1,211 @@ +"""Tests for the situation loader and CLI scaffolding. + +These cover the layer that turns a user-authored YAML/JSON file into a +``Situation`` the credit committee can run on — the thing that makes the +credit committee a reusable tool rather than a single hardcoded example. +""" + +from __future__ import annotations + +import json + +import pytest + +from examples.distressed.models import CapitalStructureTranche, Situation +from examples.distressed.run import SITUATIONS_DIR, TEMPLATE_PATH + + +# --------------------------------------------------------------------------- +# CapitalStructureTranche.from_dict +# --------------------------------------------------------------------------- + + +def test_tranche_from_dict_canonical_fields(): + t = CapitalStructureTranche.from_dict( + {"name": "1L TL", "face_amount_mm": 500, "seniority": 1, "current_price": 72} + ) + assert t.name == "1L TL" + assert t.face_amount_mm == 500.0 + assert t.seniority == 1 + assert t.current_price == 72.0 + + +def test_tranche_from_dict_friendly_aliases(): + # The YAML templates use face_mm / price_pct_par. + t = CapitalStructureTranche.from_dict( + {"name": "Notes", "face_mm": 400, "seniority": 3, "price_pct_par": 38} + ) + assert t.face_amount_mm == 400.0 + assert t.current_price == 38.0 + + +def test_tranche_missing_required_fields_raise(): + with pytest.raises(ValueError, match="name"): + CapitalStructureTranche.from_dict({"face_mm": 100, "seniority": 1}) + with pytest.raises(ValueError, match="face"): + CapitalStructureTranche.from_dict({"name": "X", "seniority": 1}) + with pytest.raises(ValueError, match="seniority"): + CapitalStructureTranche.from_dict({"name": "X", "face_mm": 100}) + + +def test_tranche_optional_price_defaults_none(): + t = CapitalStructureTranche.from_dict({"name": "Rev", "face_mm": 50, "seniority": 1}) + assert t.current_price is None + assert t.holder is None + + +# --------------------------------------------------------------------------- +# Situation.from_dict +# --------------------------------------------------------------------------- + + +def test_situation_requires_company(): + with pytest.raises(ValueError, match="company"): + Situation.from_dict({"sector": "Retail"}) + + +def test_situation_minimal_dict(): + s = Situation.from_dict({"company": "ACME"}) + assert s.company == "ACME" + assert s.capital_structure == [] + assert s.current_position == "No existing position" + + +def test_situation_company_name_alias(): + s = Situation.from_dict({"company_name": "Legacy Key Co"}) + assert s.company == "Legacy Key Co" + + +def test_timeline_normalization_variants(): + s = Situation.from_dict( + { + "company": "X", + "timeline": [ + {"date": "2024-01", "event": "filed"}, # canonical + {"2025-02": "amended"}, # single-key shorthand + "bare string event", # bare string + ], + } + ) + assert s.timeline[0] == {"date": "2024-01", "event": "filed"} + assert s.timeline[1] == {"date": "2025-02", "event": "amended"} + assert s.timeline[2] == {"date": "", "event": "bare string event"} + + +def test_bad_capital_structure_type_raises(): + with pytest.raises(ValueError, match="capital_structure"): + Situation.from_dict({"company": "X", "capital_structure": {"not": "a list"}}) + + +# --------------------------------------------------------------------------- +# Round-trip + file loading +# --------------------------------------------------------------------------- + + +def test_round_trip_dict(): + original = Situation.from_dict( + { + "company": "RoundTrip Inc", + "ticker": "RT", + "capital_structure": [ + {"name": "1L", "face_mm": 300, "seniority": 1, "price_pct_par": 90}, + {"name": "2L", "face_mm": 150, "seniority": 2}, + ], + "operating_metrics": {"EBITDA": "$40M"}, + "key_risks": ["risk one"], + } + ) + rebuilt = Situation.from_dict(original.to_dict()) + assert rebuilt.company == original.company + assert rebuilt.ticker == original.ticker + assert len(rebuilt.capital_structure) == 2 + assert rebuilt.capital_structure[0].current_price == 90.0 + assert rebuilt.capital_structure[1].current_price is None + assert rebuilt.operating_metrics == original.operating_metrics + assert rebuilt.key_risks == original.key_risks + + +def test_load_json_file(tmp_path): + payload = { + "company": "JSON Co", + "capital_structure": [{"name": "TL", "face_mm": 100, "seniority": 1}], + } + p = tmp_path / "deal.json" + p.write_text(json.dumps(payload)) + s = Situation.from_file(p) + assert s.company == "JSON Co" + assert len(s.capital_structure) == 1 + + +def test_unsupported_file_type_raises(tmp_path): + p = tmp_path / "deal.txt" + p.write_text("company: X") + with pytest.raises(ValueError, match="unsupported"): + Situation.from_file(p) + + +def test_missing_file_raises(): + with pytest.raises(FileNotFoundError): + Situation.from_file("does/not/exist.yaml") + + +# --------------------------------------------------------------------------- +# Bundled situation files (the shipped examples must always be valid) +# --------------------------------------------------------------------------- + + +def test_template_file_is_valid(): + s = Situation.from_file(TEMPLATE_PATH) + assert s.company # template has a placeholder company + assert len(s.capital_structure) >= 1 + + +def test_ati_yaml_loads_and_is_faithful(): + s = Situation.from_file(SITUATIONS_DIR / "ati_2023.yaml") + assert s.company == "ATI Physical Therapy" + assert s.ticker == "ATIP" + assert len(s.capital_structure) == 4 + # The four tranches, by face amount (sourced from FY2022 filings). + faces = {t.name: t.face_amount_mm for t in s.capital_structure} + assert any("1L Senior Secured" in n for n in faces) + assert 500.0 in faces.values() # 1L term loan + assert 125.0 in faces.values() # new 2L PIK convertible + # Decision-point marker must survive normalization (agents key off it). + assert any(e["date"] == "DECISION_POINT" for e in s.timeline) + + +def test_build_ati_situation_matches_yaml(): + # build_ati_situation now loads the YAML — guard against the loader and the + # bundled file drifting apart. + from examples.distressed.ati_2023 import build_ati_situation + + s = build_ati_situation() + assert s.company == "ATI Physical Therapy" + assert len(s.capital_structure) == 4 + assert len(s.key_risks) == 7 + + +def _bundled_situation_files(): + return sorted(p for p in SITUATIONS_DIR.glob("*.y*ml") if p.name != "TEMPLATE.yaml") + + +def test_there_are_multiple_bundled_situations(): + # Breadth matters: the tool should ship more than one worked example. + names = {p.name for p in _bundled_situation_files()} + assert {"ati_2023.yaml", "serta_2020.yaml", "hertz_2020.yaml"} <= names + + +@pytest.mark.parametrize("path", _bundled_situation_files(), ids=lambda p: p.name) +def test_every_bundled_situation_is_valid(path): + # Every shipped situation must load, have a cap structure, and mark the + # committee meeting with DECISION_POINT (the agents key off it). This also + # guards future contributed situations. + s = Situation.from_file(path) + assert s.company + assert len(s.capital_structure) >= 1 + for t in s.capital_structure: + assert t.face_amount_mm > 0 + assert t.seniority >= 1 + assert any(e["date"] == "DECISION_POINT" for e in s.timeline), ( + f"{path.name} must mark the committee meeting with a DECISION_POINT event" + ) diff --git a/uv.lock b/uv.lock index c860746..9f200f9 100644 --- a/uv.lock +++ b/uv.lock @@ -2677,6 +2677,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyportfolioopt" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "redis" }, { name = "scikit-learn" }, { name = "shap" }, @@ -2726,6 +2727,7 @@ requires-dist = [ { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.2.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "redis", specifier = ">=5.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.2.0" }, { name = "scikit-learn", specifier = ">=1.4.0" },