D25-F02-A05 / Complete engineering topic

Liquidation Waterfall

A production-minded guide to Liquidation Waterfall.

D25 · DIGITAL ASSETS AND ON-CHAIN F…
D25-F02-A05Canonical / Tested / Open
D25 / D25-F02

The number is only as good as its convention

Decentralized-finance dashboards often compress a complicated state into one percentage, ratio, price, or loss amount. That compression is useful only when the reader can recover the assumptions underneath it. Liquidation Waterfall addresses one narrow question: how to trace a distressed debt balance through collateral recovery and successive loss-absorbing buffers.

The package freezes this definition:

At each layer ℓ, absorbed_ℓ = min(remaining debt, available capacity_ℓ); remaining debt rolls to the next layer.

The scope is an author-derived protocol-risk waterfall: realized collateral proceeds, protocol reserves, insurance fund, optional backstop, then uncovered bad debt. That scope is deliberately smaller than a live protocol. The method excludes any claim of reproducing Aave, Compound, Maker, auction timing, liquidator bidding, MEV, legal seniority, governance recapitalization, token slashing, and cross-market contagion.

The calculation and evidence map

Start with the economic intuition

A waterfall is sequential accounting: every layer absorbs only what remains and never more than its available capacity.

Treat the waterfall as ordered loss accounting

The modeled debt enters at the top. Realized collateral proceeds absorb the first portion. Any remainder moves to protocol reserves, then a named insurance fund, then an optional backstop. Each layer absorbs the smaller of remaining debt and its capacity. The final remainder is explicitly uncovered.

This ordering is an implementation choice for teaching risk capacity. It is not universal protocol law. Compound III can absorb debt into protocol reserves and later sell collateral; Maker moves undercollateralized debt into protocol accounting and runs collateral auctions; Aave-style liquidations repay a borrower’s debt for collateral. Those execution systems are intentionally not collapsed into one fake transaction flow.

The stress curve changes realized collateral proceeds while keeping funded buffers fixed. It exposes the recovery point at which uncovered debt appears and shows that merely naming several buffers does not guarantee enough capacity.

Diagnostic deep dive: capacity, availability, and sequence are different facts

The generic waterfall is an accounting lens. It asks which modeled capacity absorbs the next unit of realized shortfall. It does not assert that the layer is legally claimable, technically callable, liquid, or available at the event time.

For layer \ell:

Absorbed=min(Remaining1,AvailableCapacity)Absorbed_\ell=\min(Remaining_{\ell-1},AvailableCapacity_\ell) Remaining=Remaining1AbsorbedRemaining_\ell=Remaining_{\ell-1}-Absorbed_\ell

The accounting invariant is sum(absorbed layers) + uncovered bad debt = distressed debt. A layer can be fully funded and unused because an earlier layer covered the loss; it can also be nominally large and unavailable when needed.

ArchitectureWhat the official source establishesWhy it is not this generic sequence
Aave-style liquidationLiquidator repays eligible debt for collateral plus protocol-defined economicsBorrower-facing partial/full execution rules precede any protocol-level deficit handling
Compound IIIabsorb transfers debt/collateral to protocol accounting and uses base reserves; collateral can later be soldReserve use and collateral sale are linked by Comet’s own accounting order
Maker Liquidation 2.0Dog transfers undercollateralized debt to protocol accounting and Clipper auctions collateralAuction, debt queue, penalty, and system accounting cannot be reduced to four generic cash buckets

The package therefore labels the layer order author-derived. It is useful for stress capacity, audit reconciliation, and product explanation—not transaction construction.

Read utilization and surplus together

remaining_after_collateral is the amount that must reach funded buffers. buffer_utilization_pct shows how much modeled reserve, insurance, and enabled backstop capacity is consumed. capacity_surplus is positive when declared capacity exceeds debt and negative when the model ends with uncovered bad debt.

Do not count nominal treasury token value as immediately available capacity without liquidity, governance, custody, legal, and timing evidence. Do not count the same asset in collateral proceeds and again in a reserve. Do not change layer order after seeing the result; attribution depends on sequence.

Practitioner workflow

Start with realized sale proceeds rather than an oracle mark; timestamp every capacity; prove ownership and availability; freeze layer order; run the ledger; reconcile the accounting identity; then separately document recapitalization, auction, governance, and contagion mechanisms. The uncovered amount is a model output, not a claim that a named protocol is insolvent.

Layer utilization and uncovered-debt bridge

The durable habit is to attach the convention to the number. Say “simple fee APR over 365 observed days using time-weighted LP capital,” not “the pool yields 5.5%.” Say “break-even WETH oracle price with debt and all other collateral frozen,” not “the account will liquidate at this market price.” Precision in language preserves reproducibility.

Profile sensitivity is part of the answer

Why the canonical data are synthetic

A live historical example was evaluated and classified not useful. Real DeFi positions require a chain, deployment, contract version, block, token-decimal rules, oracle source, governance parameter history, interest index, transaction order, and data license. The identity burden would overshadow the calculation.

Instead, eight deterministic scenarios expose the exact learning boundaries. Equality cases verify comparison operators. Zero-debt and zero-threshold cases verify null semantics. Stress cases reveal nonlinear tails, denominator bias, price sensitivity, and buffer exhaustion. The fee-APR topic retains 365 daily observations, while the other topics publish 241-point curves. Synthetic truth is clearly labeled and is never described as observed protocol behavior.

Scenario suite

A five-part audit

First, identify the protocol convention. Aave’s health factor is not Compound III’s account liquidity, and Compound’s absorb process is not Maker’s Dutch auction. A generic educational model must name the concepts it borrows and the mechanics it does not reproduce.

Second, align valuation time. A collateral price observed after a governance threshold change cannot be combined with a debt balance from before interest accrued unless the calculation explicitly intends that mixed snapshot. Production records should preserve block or event time, availability time, parameter effective time, finality, and source.

Third, check units and denominators. Token quantities become common-numeraire values through prices. Rates remain fractions until presentation. APR divides fee income by capital exposed during the measurement window. A health factor divides threshold-adjusted collateral by debt; swapping numerator and denominator reverses the risk meaning.

Fourth, inspect equality and null states. Exactly one is a meaningful health-factor boundary under the frozen convention. Zero debt should not create an infinite JSON value. A selected collateral with no eligible threshold-weighted capacity cannot produce a finite liquidation price. Explicit states make downstream decisions safer.

Fifth, compare a challenge profile. A result that changes materially under a reasonable fee cut, collateral shock, debt accrual, threshold change, recovery haircut, or unavailable backstop must be reported as sensitive.

Boundary versus execution

Five waterfall statements to reject

  1. Every named buffer is funded and available.
  2. Oracle collateral value equals realized recovery.
  3. Layer order cannot change attribution.
  4. A generic waterfall reproduces Aave, Compound, or Maker.
  5. Uncovered model debt proves protocol insolvency.

Each rejected statement removes a different ambiguity: comparison basis, denominator, protocol convention, timing, or execution. A precise answer keeps those boundaries attached to the displayed number.

Failure modes and misuse boundaries

  1. Protocol mismatch: do not paste a threshold, fee share, or reserve rule from one protocol into another.
  2. Time mismatch: prices, balances, accrued debt, and governance parameters must share an observation and knowledge time.
  3. Oracle versus execution: an oracle threshold does not guarantee a fill, auction result, or liquidation transaction.
  4. Annualization illusion: scaling a short exceptional period to one year is arithmetic, not a forecast.
  5. Missing total return: fee income alone omits inventory and token-price effects; impermanent loss alone omits fees and costs.
  6. Nominal versus available buffers: a reserve or backstop must be legally and technically available at the event time.
  7. Smart-contract and chain risk: reorgs, congestion, MEV, oracle faults, and contract failures can dominate the model.
  8. Synthetic evidence: clean fixture behavior proves implementation correctness only.

Out of scope: any claim of reproducing Aave, Compound, Maker, auction timing, liquidator bidding, MEV, legal seniority, governance recapitalization, token slashing, and cross-market contagion.

What a correct result can support

A correct calculation can support monitoring, scenario design, reconciliations, UI explanations, risk limits, and test fixtures. It can help a user see which input drives a ratio or which buffer absorbs the next dollar of loss. It can also identify when a calculation is unsupported and should return a reason rather than a number.

It cannot by itself certify a pool’s future yield, an account’s safety, a liquidation’s fill price, a protocol’s solvency, or the legal availability of a backstop. Those claims need live point-in-time data, protocol-specific execution logic, empirical validation, and often governance or legal evidence.

The family is sequenced around the decisions a liquidity provider or lending-risk analyst actually faces. Impermanent loss measures inventory rebalancing against holding. Fee APR isolates observed compensation. Health factor aggregates threshold-weighted protection. Liquidation price solves one break-even input. The waterfall explains what absorbs a realized shortfall. The next domain question—oracle integrity—tests whether the prices feeding those calculations deserve trust.

Question-led family handoff

Reproduce before operationalizing

The package supplies Python and TypeScript implementations, canonical and adversarial fixtures, shared-result parity checks, a worked example, five static explanations, and a guided lab. Reproduction is the minimum evidence needed to debate the assumptions productively.

Before connecting any result to a wallet or risk system, replace every synthetic field with a sourced point-in-time record. Add chain and contract identifiers, token decimals, oracle rounds, governance parameter effective times, interest indices, transaction simulation, and fail-closed staleness rules. Then validate the complete decision rule out of sample and under congestion, reorg, and failed-execution scenarios.

Sources

  • Compound III Liquidation — Liquidation eligibility, absorb mechanics, discounted collateral sale, and protocol-reserve role.
  • Liquidation 2.0 Module — Transfer of undercollateralized debt to protocol accounting, collateral auction, liquidation penalty, and bad-debt flow.
  • Health Factor & Liquidations — Health-factor formula, weighted liquidation thresholds, eligibility below one, and liquidation-process concepts.
  • Aave V3 LiquidationLogic.sol — Eligibility below health factor one, close-factor branching, liquidation bonus, and protocol-fee implementation boundaries.

Protocol sources establish only their named mechanics. Every scenario, sensitivity profile, visual, and cross-protocol risk workflow in this article is author-derived educational material reviewed on 2026-07-29.

Liquidation Waterfall — Calculation and Decision Flow

Rendering system map…
ReferencesPrimary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

Reviewed: 2026-07-29

SourceTitleRoleUsed for
COMPOUND_IIICompound III LiquidationOfficial protocol documentationLiquidation eligibility, absorb mechanics, discounted collateral sale, and protocol-reserve role.
MAKER_LIQUIDATIONLiquidation 2.0 ModuleOfficial protocol technical documentationTransfer of undercollateralized debt to protocol accounting, collateral auction, liquidation penalty, and bad-debt flow.
AAVE_LIQUIDATIONSHealth Factor & LiquidationsOfficial protocol help documentationHealth-factor formula, weighted liquidation thresholds, eligibility below one, and liquidation-process concepts.
AAVE_V3_LOGICAave V3 LiquidationLogic.solOfficial maintained reference implementationEligibility below health factor one, close-factor branching, liquidation bonus, and protocol-fee implementation boundaries.

COMPOUND_III — Compound III Liquidation

  • Organization or authors: Compound; Compound documentation team
  • Source type: Official protocol documentation
  • Publication or effective date: 2026-07-29
  • Version: Compound III documentation accessed 2026-07-29
  • URL or DOI: https://docs.compound.finance/liquidation/
  • Accessed: 2026-07-29
  • Jurisdiction: Compound III protocol deployments
  • Supports: Liquidation eligibility, absorb mechanics, discounted collateral sale, and protocol-reserve role.
  • Limitations: Compound III's absorb-and-resell mechanism is not interchangeable with borrower-facing partial-liquidation protocols.

MAKER_LIQUIDATION — Liquidation 2.0 Module

  • Organization or authors: MakerDAO; Maker Protocol documentation contributors
  • Source type: Official protocol technical documentation
  • Publication or effective date: 2026-07-29
  • Version: Dog and Clipper documentation accessed 2026-07-29
  • URL or DOI: https://docs.makerdao.com/smart-contract-modules/dog-and-clipper-detailed-documentation
  • Accessed: 2026-07-29
  • Jurisdiction: Maker Protocol
  • Supports: Transfer of undercollateralized debt to protocol accounting, collateral auction, liquidation penalty, and bad-debt flow.
  • Limitations: Maker's auction contracts, units, and governance parameters are protocol-specific and are not reproduced by the generic waterfall.

AAVE_LIQUIDATIONS — Health Factor & Liquidations

  • Organization or authors: Aave; Aave Labs documentation team
  • Source type: Official protocol help documentation
  • Publication or effective date: 2026-07-29
  • Version: Page accessed 2026-07-29
  • URL or DOI: https://aave.com/help/borrowing/liquidations
  • Accessed: 2026-07-29
  • Jurisdiction: Aave Protocol; market and deployment parameters vary
  • Supports: Health-factor formula, weighted liquidation thresholds, eligibility below one, and liquidation-process concepts.
  • Limitations: Governance parameters and version-specific close-factor or dust rules can change; this package freezes only the health-factor convention.

AAVE_V3_LOGIC — Aave V3 LiquidationLogic.sol

  • Organization or authors: Aave; Aave protocol contributors
  • Source type: Official maintained reference implementation
  • Publication or effective date: 2026-07-29
  • Version: aave-v3-core master accessed 2026-07-29
  • URL or DOI: https://github.com/aave/aave-v3-core/blob/master/contracts/protocol/libraries/logic/LiquidationLogic.sol
  • Accessed: 2026-07-29
  • Jurisdiction: Aave V3 implementation; deployment and release rules can differ
  • Supports: Eligibility below health factor one, close-factor branching, liquidation bonus, and protocol-fee implementation boundaries.
  • Limitations: The repository is an implementation reference, not proof that every deployment uses identical parameters or release logic.

Evidence boundary

Protocol documentation establishes only the named protocol’s mechanics and versioned terminology. The formulas, fixtures, stress curves, comparison profiles, diagnostics, diagrams, and generic financial workflow in this package are author-derived unless attributed above. Synthetic scenarios are not protocol telemetry, investment performance, or evidence that any liquidation would execute at the modeled price.

liquidity-liquidation.ts
/** Dependency-free reference calculations for D25-F02. */
export type JsonObject = Record<string, any>;

function finite(value: unknown, name: string, minimum?: number, maximum?: number): number {
  if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a finite number`);
  if (minimum !== undefined && value < minimum) throw new Error(`${name} must be at least ${minimum}`);
  if (maximum !== undefined && value > maximum) throw new Error(`${name} must be at most ${maximum}`);
  return value;
}

function rounded(value: any, digits = 10): any {
  if (typeof value === "number") return Number(value.toFixed(digits));
  if (Array.isArray(value)) return value.map(item => rounded(item, digits));
  if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rounded(item, digits)]));
  return value;
}

export function impermanentLoss(data: JsonObject, config: JsonObject): JsonObject {
  const initialValue = finite(data.initial_pool_value, "initial_pool_value", 1e-9);
  const ratio = finite(data.price_ratio, "price_ratio", 1e-9);
  const feeFraction = finite(config.fee_return_fraction ?? 0, "fee_return_fraction", 0);
  const holdValue = initialValue * (1 + ratio) / 2;
  const poolValue = initialValue * Math.sqrt(ratio);
  const relativeValue = poolValue / holdValue;
  const fees = initialValue * feeFraction;
  const trace = Array.from({ length: 241 }, (_, index) => {
    const logRatio = Math.log(0.2) + index * (Math.log(5) - Math.log(0.2)) / 240;
    const r = Math.exp(logRatio);
    const hv = initialValue * (1 + r) / 2;
    const pv = initialValue * Math.sqrt(r);
    return { price_ratio: r, impermanent_loss: pv / hv - 1, hold_value: hv, pool_value: pv, net_pool_value: pv + fees };
  });
  return rounded({
    initial_pool_value: initialValue,
    price_ratio: ratio,
    hold_value: holdValue,
    pool_value_excluding_fees: poolValue,
    impermanent_loss_fraction: relativeValue - 1,
    impermanent_loss_amount: poolValue - holdValue,
    fee_income: fees,
    net_pool_value: poolValue + fees,
    net_vs_hold_fraction: (poolValue + fees) / holdValue - 1,
    trace,
  });
}

export function feeApr(data: JsonObject, config: JsonObject): JsonObject {
  if (!Array.isArray(data.rows) || data.rows.length === 0) throw new Error("rows must be a non-empty list");
  const annualization = finite(config.annualization_days ?? 365, "annualization_days", 1e-9);
  const protocolShare = finite(config.protocol_fee_share ?? 0, "protocol_fee_share", 0, 1);
  const basis = config.capital_basis ?? "time_weighted";
  if (!["time_weighted", "ending"].includes(basis)) throw new Error("capital_basis must be time_weighted or ending");
  let elapsed = 0, capitalTime = 0, cumulative = 0, cumulativeVolume = 0;
  const trace: JsonObject[] = [];
  data.rows.forEach((row: JsonObject, index: number) => {
    const duration = finite(row.duration_days ?? 1, `rows[${index}].duration_days`, 1e-9);
    const volume = finite(row.volume, `rows[${index}].volume`, 0);
    const feeRate = finite(row.fee_rate, `rows[${index}].fee_rate`, 0, 1);
    const share = finite(row.lp_share, `rows[${index}].lp_share`, 0, 1);
    const capital = finite(row.lp_capital, `rows[${index}].lp_capital`, 1e-9);
    const fees = volume * feeRate * share * (1 - protocolShare);
    elapsed += duration; capitalTime += capital * duration; cumulative += fees; cumulativeVolume += volume;
    const denominator = basis === "time_weighted" ? capitalTime / elapsed : capital;
    trace.push({
      index, timestamp: row.timestamp, volume, lp_capital: capital, daily_fee_income: fees,
      cumulative_fee_income: cumulative, running_apr: cumulative / denominator * annualization / elapsed,
    });
  });
  const averageCapital = capitalTime / elapsed;
  const denominator = basis === "time_weighted" ? averageCapital : finite(data.rows.at(-1).lp_capital, "ending lp_capital", 1e-9);
  return rounded({
    elapsed_days: elapsed,
    gross_volume: cumulativeVolume,
    lp_fee_income: cumulative,
    time_weighted_average_capital: averageCapital,
    capital_denominator: denominator,
    capital_basis: basis,
    protocol_fee_share: protocolShare,
    simple_fee_apr: cumulative / denominator * annualization / elapsed,
    period_fee_return: cumulative / denominator,
    annualization_days: annualization,
    trace,
  });
}

export function healthFactor(data: JsonObject, config: JsonObject): JsonObject {
  if (!Array.isArray(data.collateral) || data.collateral.length === 0 || !Array.isArray(data.debts))
    throw new Error("collateral must be non-empty and debts must be a list");
  const cm = finite(config.collateral_price_multiplier ?? 1, "collateral_price_multiplier", 0);
  const dm = finite(config.debt_price_multiplier ?? 1, "debt_price_multiplier", 0);
  let collateralValue = 0, adjusted = 0, debtValue = 0;
  const collateralLedger = data.collateral.map((asset: JsonObject, index: number) => {
    const quantity = finite(asset.quantity, `collateral[${index}].quantity`, 0);
    const price = finite(asset.price, `collateral[${index}].price`, 0) * cm;
    const threshold = finite(asset.liquidation_threshold, `collateral[${index}].liquidation_threshold`, 0, 1);
    const eligible = asset.eligible ?? true;
    const value = quantity * price;
    const contribution = eligible ? value * threshold : 0;
    collateralValue += value; adjusted += contribution;
    return { asset: asset.asset ?? `C${index}`, value, threshold, eligible, adjusted_contribution: contribution };
  });
  const debtLedger = data.debts.map((asset: JsonObject, index: number) => {
    const quantity = finite(asset.quantity, `debts[${index}].quantity`, 0);
    const price = finite(asset.price, `debts[${index}].price`, 0) * dm;
    const value = quantity * price; debtValue += value;
    return { asset: asset.asset ?? `D${index}`, value };
  });
  const hf = debtValue === 0 ? null : adjusted / debtValue;
  const state = hf === null ? "no-debt" : hf < 1 ? "liquidatable" : hf === 1 ? "at-threshold" : "above-threshold";
  const trace = Array.from({ length: 241 }, (_, index) => {
    const multiplier = 0.4 + index / 240;
    const stressed = adjusted * multiplier;
    return { collateral_price_multiplier: multiplier, health_factor: debtValue === 0 ? null : stressed / debtValue, adjusted_collateral_value: stressed };
  });
  return rounded({
    total_collateral_value: collateralValue,
    threshold_adjusted_collateral_value: adjusted,
    total_debt_value: debtValue,
    weighted_average_liquidation_threshold: collateralValue === 0 ? 0 : adjusted / collateralValue,
    health_factor: hf,
    buffer_value: hf === null ? null : adjusted - debtValue,
    state, collateral_ledger: collateralLedger, debt_ledger: debtLedger, trace,
  });
}

export function liquidationPrice(data: JsonObject, config: JsonObject): JsonObject {
  if (!Array.isArray(data.collateral) || data.collateral.length === 0 || !Array.isArray(data.debts) || !data.selected_asset)
    throw new Error("collateral, debts, and selected_asset are required");
  const debtMultiplier = finite(config.debt_multiplier ?? 1, "debt_multiplier", 0);
  const shift = finite(config.threshold_shift ?? 0, "threshold_shift", -1, 1);
  let debtValue = 0;
  data.debts.forEach((asset: JsonObject, index: number) => {
    debtValue += finite(asset.quantity, `debts[${index}].quantity`, 0) * finite(asset.price, `debts[${index}].price`, 0);
  });
  debtValue *= debtMultiplier;
  let otherAdjusted = 0;
  let chosen: JsonObject | null = null;
  data.collateral.forEach((asset: JsonObject, index: number) => {
    const quantity = finite(asset.quantity, `collateral[${index}].quantity`, 0);
    const price = finite(asset.price, `collateral[${index}].price`, 0);
    const threshold = finite(asset.liquidation_threshold, `collateral[${index}].liquidation_threshold`, 0, 1);
    if (asset.asset === data.selected_asset) chosen = { quantity, current_price: price, threshold: Math.max(0, Math.min(1, threshold + shift)) };
    else if (asset.eligible ?? true) otherAdjusted += quantity * price * threshold;
  });
  if (chosen === null) throw new Error("selected_asset is not present in collateral");
  const c = chosen as JsonObject;
  const denominator = c.quantity * c.threshold;
  const required = denominator <= 0 ? null : Math.max(0, (debtValue - otherAdjusted) / denominator);
  const reason = denominator <= 0 ? "selected collateral has zero quantity or zero effective liquidation threshold" : required === 0 ? "other collateral alone covers debt" : "finite break-even price";
  const currentAdjusted = c.quantity * c.current_price * c.threshold;
  const currentHf = debtValue === 0 ? null : (otherAdjusted + currentAdjusted) / debtValue;
  const trace = Array.from({ length: 241 }, (_, index) => {
    const price_multiplier = 0.4 + index / 240;
    const selected_price = c.current_price * price_multiplier;
    const health_factor = debtValue === 0 ? null : (otherAdjusted + c.quantity * selected_price * c.threshold) / debtValue;
    return { price_multiplier, selected_price, health_factor };
  });
  return rounded({
    selected_asset: data.selected_asset,
    selected_quantity: c.quantity,
    current_selected_price: c.current_price,
    effective_selected_liquidation_threshold: c.threshold,
    total_debt_value: debtValue,
    other_adjusted_collateral_value: otherAdjusted,
    liquidation_price: required,
    distance_to_liquidation_fraction: required === null || c.current_price === 0 ? null : (c.current_price - required) / c.current_price,
    current_health_factor: currentHf,
    reason, trace,
  });
}

export function liquidationWaterfall(data: JsonObject, config: JsonObject): JsonObject {
  const debt = finite(data.debt_value, "debt_value", 0);
  const collateral = finite(data.collateral_sale_proceeds, "collateral_sale_proceeds", 0);
  const reserve = finite(data.protocol_reserve, "protocol_reserve", 0);
  const insurance = finite(data.insurance_fund, "insurance_fund", 0);
  const backstop = finite(data.backstop_capacity, "backstop_capacity", 0);
  const recoveryRate = finite(config.collateral_recovery_rate ?? 1, "collateral_recovery_rate", 0, 1);
  const enabled = config.backstop_enabled ?? true;
  const capacities: Array<[string, number]> = [
    ["collateral-recovery", collateral * recoveryRate],
    ["protocol-reserve", reserve],
    ["insurance-fund", insurance],
    ["backstop", enabled ? backstop : 0],
  ];
  let remaining = debt, cumulative = 0;
  const ledger = capacities.map(([layer, capacity]) => {
    const absorbed = Math.min(remaining, capacity); remaining -= absorbed; cumulative += absorbed;
    return { layer, available_capacity: capacity, absorbed, remaining_debt: remaining, cumulative_absorbed: cumulative };
  });
  ledger.push({ layer: "uncovered-bad-debt", available_capacity: null as any, absorbed: remaining, remaining_debt: 0, cumulative_absorbed: cumulative + remaining });
  const stressTrace = Array.from({ length: 241 }, (_, index) => {
    const collateral_proceeds_multiplier = index * 1.2 / 240;
    const stressed = collateral * recoveryRate * collateral_proceeds_multiplier;
    const capacity = stressed + reserve + insurance + (enabled ? backstop : 0);
    return { collateral_proceeds_multiplier, covered_debt: Math.min(debt, capacity), uncovered_bad_debt: Math.max(0, debt - capacity) };
  });
  return rounded({
    debt_value: debt,
    effective_collateral_proceeds: collateral * recoveryRate,
    total_buffer_capacity: reserve + insurance + (enabled ? backstop : 0),
    covered_debt: debt - remaining,
    uncovered_bad_debt: remaining,
    coverage_fraction: debt === 0 ? 1 : (debt - remaining) / debt,
    backstop_enabled: enabled,
    ledger,
    trace: stressTrace,
  });
}

export function runTopic(kind: string, data: JsonObject, config: JsonObject): JsonObject {
  if (!data || typeof data !== "object" || !config || typeof config !== "object") throw new Error("data and config must be objects");
  const functions: Record<string, (d: JsonObject, c: JsonObject) => JsonObject> = {
    impermanent_loss: impermanentLoss,
    fee_apr: feeApr,
    health_factor: healthFactor,
    liquidation_price: liquidationPrice,
    waterfall: liquidationWaterfall,
  };
  if (!functions[kind]) throw new Error(`unknown topic kind: ${kind}`);
  return functions[kind](data, config);
}
Full-height labliquidation waterfall labOpen full screen