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. Liquidity-Provider Fee APR addresses one narrow question: how to annualize observed LP fee income against time-weighted capital without silently compounding it.
The package freezes this definition:
Fee APR = (Σ volume_t × fee_rate_t × LP_share_t × (1−protocol_share)) / time-weighted average LP capital × annualization_days/elapsed_days.
The scope is simple non-compounded APR from daily gross swap volume, swap-fee rate, LP ownership share, protocol fee share, and time-weighted LP capital. That scope is deliberately smaller than a live protocol. The method excludes APY, incentive-token emissions, token-price P&L, impermanent loss, active-range uptime, reinvestment, gas, taxes, and forecasted volume.
Start with the economic intuition
Count only fees economically attributable to the position, divide by capital actually at work, then scale the observed period linearly rather than pretending the fees compounded.
Build the numerator before annualizing
For each day, gross swap volume times the fee rate gives pool fees. Multiplying by the position’s economic share attributes those fees to the LP. A protocol share, when present, is removed explicitly. Only after summing the actual observation window do we divide by capital and scale linearly to a year.
Time-weighted capital prevents a late deposit or withdrawal from distorting the denominator. The challenge profile deliberately uses ending capital so learners can see how a seemingly small accounting choice changes the published APR. Simple APR does not assume reinvestment; APY requires a reinvestment frequency and a realizable compounding process.
Fee APR is not total LP return. Token inventory changes, divergence loss, price appreciation, incentives, gas, rebalancing, and taxes remain outside this metric. A high annualized result from a short volume spike is mathematically correct and still a weak forecast.
Diagnostic deep dive: an APR is a ledger plus a denominator
The safest way to calculate LP fee APR is to refuse to annualize until the observation ledger balances. For each interval , attributable fee income is:
Capital-days are accumulated independently:
Only then is the simple rate scaled: APR = total fees / K_TW × 365 / elapsed days. This order prevents a changing position size from being hidden behind the final balance.
| Measurement choice | When it can be useful | Why it can mislead |
|---|---|---|
| Time-weighted capital | Capital changes during the window | Needs reliable interval balances |
| Starting capital | Capital is genuinely constant | Ignores later deposits or withdrawals |
| Ending capital | Snapshot reporting | Can overstate or understate the capital that earned fees |
| Compounded APY | Fees are reinvested on a declared schedule | Invents compounding if fees were merely claimable |
The package’s volume-times-share numerator is a teaching estimate, not a universal position-fee extraction method. Uniswap v3 maintains position-specific fee-growth state and range-dependent liquidity accounting in its core pool implementation. A production position calculator should reconcile protocol fee-growth fields or collected-fee events rather than assume one static share of gross pool volume.
Read concentration before the headline APR
top_10_day_fee_share_pct asks whether a small number of days produced most of the observed income. ending_vs_average_capital_pct shows the denominator distortion introduced by using the ending balance. effective_fee_on_volume_bps combines the fee tier, LP share, and protocol cut into the net fee captured per unit of gross volume.
A mathematically high APR can therefore receive a weak decision grade: the window may be short, the income may be concentrated in a temporary spike, capital may have changed materially, or the position may have been out of range. Annualization describes scale; it does not establish persistence.
Practitioner workflow
Reconcile fee income first, reconstruct capital-days second, declare simple APR versus APY third, inspect concentration fourth, and report total LP return separately. Never combine incentive-token emissions with swap fees unless the numerator labels both components and their valuation clocks.
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.
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.
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.
Five fee-APR statements to reject
- APR means fees compound automatically.
- The current TVL is always the correct denominator.
- Gross pool fees equal one position's claimable fees.
- A 365-day annualization proves the fee rate will persist.
- Fee APR is total LP return.
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
- Protocol mismatch: do not paste a threshold, fee share, or reserve rule from one protocol into another.
- Time mismatch: prices, balances, accrued debt, and governance parameters must share an observation and knowledge time.
- Oracle versus execution: an oracle threshold does not guarantee a fill, auction result, or liquidation transaction.
- Annualization illusion: scaling a short exceptional period to one year is arithmetic, not a forecast.
- Missing total return: fee income alone omits inventory and token-price effects; impermanent loss alone omits fees and costs.
- Nominal versus available buffers: a reserve or backstop must be legally and technically available at the event time.
- Smart-contract and chain risk: reorgs, congestion, MEV, oracle faults, and contract failures can dominate the model.
- Synthetic evidence: clean fixture behavior proves implementation correctness only.
Out of scope: APY, incentive-token emissions, token-price P&L, impermanent loss, active-range uptime, reinvestment, gas, taxes, and forecasted volume.
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.
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
- Uniswap v2 Core — Constant-product AMM mechanics, pooled LP funds, swap-fee accounting, and the optional protocol-fee design.
- Uniswap v3 Core — Full-range constant-product comparison, flexible fee tiers, position-specific fee accounting, and concentrated-liquidity exclusions.
- UniswapV3Pool.sol — Position-specific fee-growth state, protocol-fee state, liquidity-range accounting, and fee collection mechanics.
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.
Rendered from the canonical Mermaid sources linked by this article.
Liquidity-Provider Fee APR — Calculation and Decision Flow
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
Reviewed: 2026-07-29
| Source | Title | Role | Used for |
|---|---|---|---|
| UNISWAP_V2 | Uniswap v2 Core | Official protocol whitepaper | Constant-product AMM mechanics, pooled LP funds, swap-fee accounting, and the optional protocol-fee design. |
| UNISWAP_V3 | Uniswap v3 Core | Official protocol whitepaper | Full-range constant-product comparison, flexible fee tiers, position-specific fee accounting, and concentrated-liquidity exclusions. |
| UNISWAP_V3_CORE | UniswapV3Pool.sol | Official maintained reference implementation | Position-specific fee-growth state, protocol-fee state, liquidity-range accounting, and fee collection mechanics. |
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.
UNISWAP_V3_CORE — UniswapV3Pool.sol
- Organization or authors: Uniswap; Uniswap Labs and protocol contributors
- Source type: Official maintained reference implementation
- Publication or effective date: 2026-07-29
- Version: v3-core main branch accessed 2026-07-29
- URL or DOI: https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol
- Accessed: 2026-07-29
- Jurisdiction: Uniswap v3 protocol implementation; not jurisdiction-specific
- Supports: Position-specific fee-growth state, protocol-fee state, liquidity-range accounting, and fee collection mechanics.
- Limitations: The implementation does not define this package's simple APR or justify estimating position fees from static gross-volume share.
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.
Full dependency-light reference implementations in both supported languages.
/** 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);
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.