D25-F02-A01 / Complete engineering topic

Impermanent-Loss Calculation

A production-minded guide to Impermanent-Loss Calculation.

D25 · DIGITAL ASSETS AND ON-CHAIN F…
D25-F02-A01Canonical / 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. Impermanent-Loss Calculation addresses one narrow question: how to calculate a full-range 50/50 constant-product LP position against simply holding the initial tokens.

The package freezes this definition:

IL(r) = 2√r/(1+r) − 1, where r is the ending external token-price ratio divided by its initial ratio.

The scope is a frictionless, full-range, 50/50 constant-product pool after arbitrage restores the reserve ratio to an external price ratio; fees are shown separately. That scope is deliberately smaller than a live protocol. The method excludes concentrated liquidity, weighted pools, stable-swap curves, dynamic fees, gas, taxes, rebalancing, liquidity changes, and path-dependent fee accrual.

The calculation and evidence map

Start with the economic intuition

Arbitrage makes the pool sell part of the outperforming token and buy the underperforming token, so its inventory differs from a passive hold.

Derive the comparison

Begin with equal value in two tokens. Let token 1 remain the numeraire and let token 0’s relative price move by r. Passive holding is worth half the initial capital times 1+r. A constant-product pool rebalances to reserves whose values remain equal after arbitrage, making the position worth initial capital times √r. Dividing those values produces the familiar symmetric ratio.

The symmetry is important: a doubling and a halving produce the same percentage divergence loss because the formula compares relative price ratios. The dollar loss differs only when initial capital differs. At r=1, both strategies are identical. As the ratio moves far from one, the pool increasingly underweights the winning token relative to the original hold.

Fees do not belong inside the pure impermanent-loss formula. This package adds them as a separate author-selected amount so learners can see the fee income needed to offset divergence. Real fee accrual is path-, volume-, range-, and protocol-dependent.

Diagnostic deep dive: inventory is the mechanism

The formula is short because the inventory path has already been solved. Start with initial capital V0V_0, split equally by value. If token 0 moves by the relative-price ratio rr, the untouched hold becomes Vhold=V0(1+r)/2V_{hold}=V_0(1+r)/2. Arbitrage restores equal reserve values inside the constant-product pool, so its fee-free value becomes VLP=V0rV_{LP}=V_0\sqrt{r}. The ratio of those two values—not the token return itself—creates the divergence result.

CheckExpressionWhat it proves
No mover = 1Pool and hold are identical before fees
Reciprocal symmetryIL(r) = IL(1/r)Quote direction does not change percentage divergence
Fee break-evenfees = V_hold − V_LPRequired compensation is separate from pure IL
Tail behaviorr → 0 or r → ∞The LP increasingly surrenders exposure to the winning side

Do not call every LP underperformance “impermanent loss.” This package compares the same initial token quantities under one fee-free full-range 50/50 constant-product rule. A deposit made with unequal values, an active range, a weighted invariant, a stable-swap curve, or an external hedge answers a different question. The generalized treatment in Tangri et al. is useful precisely because market-maker design and fee structure change the comparison.

Read three numbers together

  1. impermanent_loss_pct measures pool value relative to the original-token hold.
  2. break_even_fee_pct_initial converts the dollar shortfall into the minimum fee amount as a share of starting capital.
  3. fee_coverage_pct shows how much of that shortfall the declared fee profile covers.

A fee-coverage value above 100% means the selected fee amount offsets the fee-free divergence at the endpoint. It does not prove the position was profitable, because gas, incentives, token-price P&L, range management, and the path needed to earn those fees remain outside the contract.

Practitioner workflow

Freeze the pair orientation and initial holdings; calculate the fee-free comparison; verify reciprocal symmetry; compute the endpoint fee break-even; then assess actual fee evidence separately. If a dashboard begins with “fees earned” but cannot reconstruct the counterfactual hold, it is reporting income rather than impermanent loss.

Symmetry, inventory, and fee break-even

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 impermanent-loss statements to reject

  1. Impermanent loss is the token pair's price return.
  2. Fees are already included in the pure IL formula.
  3. A 2× move causes a 50% divergence loss.
  4. Full-range and concentrated-liquidity positions use the same endpoint formula.
  5. If price later returns, no economic loss could have been realized in between.

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: concentrated liquidity, weighted pools, stable-swap curves, dynamic fees, gas, taxes, rebalancing, liquidity changes, and path-dependent fee accrual.

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

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.

Impermanent-Loss Calculation — 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
UNISWAP_V2Uniswap v2 CoreOfficial protocol whitepaperConstant-product AMM mechanics, pooled LP funds, swap-fee accounting, and the optional protocol-fee design.
UNISWAP_V3Uniswap v3 CoreOfficial protocol whitepaperFull-range constant-product comparison, flexible fee tiers, position-specific fee accounting, and concentrated-liquidity exclusions.
CFMM_ANALYSISAn Analysis of Uniswap MarketsOriginal research paperConstant-product market price, arbitrage, and reserve-value mechanics used to derive the full-range comparison.
IL_PAPERUNISWAP: Impermanent Loss and Risk Profile of a Liquidity ProviderResearch paperRisk framing and mathematical treatment of Uniswap v2 impermanent loss.
GENERAL_ILGeneralizing Impermanent Loss on Decentralized Exchanges with Constant Function Market MakersResearch paperWhy impermanent-loss definitions depend on the market-maker function, fee structure, and concentrated-liquidity design.

UNISWAP_V2 — Uniswap v2 Core

  • Organization or authors: Uniswap; Hayden Adams, Noah Zinsmeister, and Dan Robinson
  • Source type: Official protocol whitepaper
  • Publication or effective date: 2020-03
  • Version: Uniswap v2
  • URL or DOI: https://app.uniswap.org/whitepaper.pdf
  • Accessed: 2026-07-29
  • Jurisdiction: Protocol technical specification; not jurisdiction-specific
  • Supports: Constant-product AMM mechanics, pooled LP funds, swap-fee accounting, and the optional protocol-fee design.
  • Limitations: Does not define a universal fee APR or prove LP profitability.

UNISWAP_V3 — Uniswap v3 Core

  • Organization or authors: Uniswap; Hayden Adams, Noah Zinsmeister, Moody Salem, River Keefer, and Dan Robinson
  • Source type: Official protocol whitepaper
  • Publication or effective date: 2021-03
  • Version: Uniswap v3
  • URL or DOI: https://blog.uniswap.org/whitepaper-v3.pdf
  • Accessed: 2026-07-29
  • Jurisdiction: Protocol technical specification; not jurisdiction-specific
  • Supports: Full-range constant-product comparison, flexible fee tiers, position-specific fee accounting, and concentrated-liquidity exclusions.
  • Limitations: Concentrated positions require range-specific inventory mathematics outside A01's frozen full-range scope.

CFMM_ANALYSIS — An Analysis of Uniswap Markets

  • Organization or authors: Authors via arXiv; Guillermo Angeris, Hsien-Tang Kao, Rei Chiang, Charlie Noyes, and Tarun Chitra
  • Source type: Original research paper
  • Publication or effective date: 2019-11
  • Version: arXiv:1911.03380
  • URL or DOI: https://arxiv.org/abs/1911.03380
  • Accessed: 2026-07-29
  • Jurisdiction: Research; not jurisdiction-specific
  • Supports: Constant-product market price, arbitrage, and reserve-value mechanics used to derive the full-range comparison.
  • Limitations: The theoretical market model does not reproduce fees, transaction ordering, gas, or a specific deployed pool.

IL_PAPER — UNISWAP: Impermanent Loss and Risk Profile of a Liquidity Provider

  • Organization or authors: Authors via arXiv; Andreas Aigner and Gurvinder Dhaliwal
  • Source type: Research paper
  • Publication or effective date: 2021-06
  • Version: arXiv:2106.14404
  • URL or DOI: https://arxiv.org/abs/2106.14404
  • Accessed: 2026-07-29
  • Jurisdiction: Research; not jurisdiction-specific
  • Supports: Risk framing and mathematical treatment of Uniswap v2 impermanent loss.
  • Limitations: A model result is not a realized return estimate for a particular pool.

GENERAL_IL — Generalizing Impermanent Loss on Decentralized Exchanges with Constant Function Market Makers

  • Organization or authors: Authors via arXiv; Rohan Tangri, Peter Yatsyshin, Elisabeth A. Duijnstee, and Danilo Mandic
  • Source type: Research paper
  • Publication or effective date: 2023-01
  • Version: arXiv:2301.06831
  • URL or DOI: https://arxiv.org/abs/2301.06831
  • Accessed: 2026-07-29
  • Jurisdiction: Research; not jurisdiction-specific
  • Supports: Why impermanent-loss definitions depend on the market-maker function, fee structure, and concentrated-liquidity design.
  • Limitations: Generalized results are comparison evidence; A01 still freezes only a full-range 50/50 constant-product position.

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 labimpermanent loss calculation labOpen full screen