First DEX liquidity position: fee math vs impermanent loss

Why actually deposit instead of modeling it

I already wrote about modeling impermanent loss with fees in Python. A calculator is useful, but it glosses over the things that only show up when you have a live position: fee accrual on a narrow range that drifts out of the band, the gas cost of collecting, and the experience of watching your position value bob around while you wait for fees to catch up.

This post covers a tiny Uniswap V3 position — $200 split across USDC and WETH on Arbitrum — held for 11 days. The goal was not to make money. It was to see whether fee revenue on a small position can realistically offset impermanent loss, and what the mechanics look like when you are the LP instead of the swapper.

Pool and tier selection

Uniswap V3 on Arbitrum has multiple fee tiers for every pair. For USDC/WETH the relevant ones are:

Fee tierTypical use7-day volume (at time of experiment)
0.01%Stable-like pairs, not useful hereLow
0.05%Major pairs with high volume~$180M
0.30%Lower-volume or more volatile pairs~$12M
1.00%Exotic pairs~$400k

Data pulled from the Uniswap Info subgraph the day I opened the position.

The 0.05% tier captures almost all the volume on USDC/WETH, so that is where I put the liquidity. The 0.30% tier earns 6x the fee per swap but sees less than a tenth of the volume. For a passive LP, go where the volume is.

Sizing and range

Concentrated liquidity means your capital only earns fees while the price sits inside the range you chose. Wider range = more safety, lower capital efficiency. Narrower range = more fees per dollar when you are in range, zero fees the moment you are not.

I picked a range that was roughly ±5% around the spot price when I opened. At spot of $3,450:

  • Lower bound: $3,275
  • Upper bound: $3,625

Rationale: ETH historical 1-week realized volatility on Arbitrum was around 3-4% at the time, so a ±5% band had a reasonable chance of containing most of a one-week price path. Not a forecast — just a starting bet I could rebalance later.

Capital split

Uniswap V3 calculates how much of each token you need given your range and current price. The formula is derived from the constant-product invariant applied to the bounded range. With $200 total and the price near the midpoint of my range, the position opened as roughly:

97.4 USDC
0.0298 WETH (~$102.80)

Not exactly 50/50 because the range was not perfectly symmetric in tick space. Ticks are log-spaced, so a “±5%” range is slightly asymmetric in linear price terms.

Opening the position

  1. Went to app.uniswap.org, selected Arbitrum network, chose Pool → New Position.
  2. Selected USDC/WETH, 0.05% tier.
  3. Set custom range to the bounds above.
  4. Entered $100 USDC; the UI auto-filled the corresponding WETH.
  5. Two transactions: approve USDC (one-time), then mint position.
  6. Total gas on Arbitrum: $0.22 for both.

The position is represented as an NFT (the Uniswap V3 position manager). The NFT tracks your liquidity range, the amount of liquidity, and the accrued fees. You can check it on-chain:

from web3 import Web3

rpc = Web3(Web3.HTTPProvider("https://arb1.arbitrum.io/rpc"))

# Uniswap V3 Position Manager on Arbitrum
NPM_ADDRESS = "0xC36442b4a4522E871399CD717aBDD847Ab11FE88"

npm_abi = [
    {
        "inputs": [{"name": "tokenId", "type": "uint256"}],
        "name": "positions",
        "outputs": [
            {"name": "nonce", "type": "uint96"},
            {"name": "operator", "type": "address"},
            {"name": "token0", "type": "address"},
            {"name": "token1", "type": "address"},
            {"name": "fee", "type": "uint24"},
            {"name": "tickLower", "type": "int24"},
            {"name": "tickUpper", "type": "int24"},
            {"name": "liquidity", "type": "uint128"},
            {"name": "feeGrowthInside0LastX128", "type": "uint256"},
            {"name": "feeGrowthInside1LastX128", "type": "uint256"},
            {"name": "tokensOwed0", "type": "uint128"},
            {"name": "tokensOwed1", "type": "uint128"},
        ],
        "stateMutability": "view",
        "type": "function",
    },
]

npm = rpc.eth.contract(address=NPM_ADDRESS, abi=npm_abi)
pos = npm.functions.positions(MY_TOKEN_ID).call()

print(f"tickLower: {pos[5]}, tickUpper: {pos[6]}")
print(f"liquidity: {pos[7]}")
print(f"tokensOwed0 (USDC, 6 decimals): {pos[10] / 1e6}")
print(f"tokensOwed1 (WETH, 18 decimals): {pos[11] / 1e18}")

tokensOwed0 and tokensOwed1 are only updated when you interact with the position (collect, increase, decrease). The real-time fee figure requires computing it from feeGrowthInside deltas — more on that below.

Reading accrued fees without calling collect

If you read tokensOwed straight off the position, it shows zero until you trigger a collect or a liquidity change. The actual unclaimed fees live in the pool’s cumulative feeGrowthInside0X128 / feeGrowthInside1X128 counters, and you compute your share from the delta since you last touched the position.

The simpler way is to simulate collect via eth_call using staticcall semantics. The position manager’s collect function, when called with callStatic, returns what would be collected without actually collecting:

collect_abi = [
    {
        "inputs": [
            {
                "components": [
                    {"name": "tokenId", "type": "uint256"},
                    {"name": "recipient", "type": "address"},
                    {"name": "amount0Max", "type": "uint128"},
                    {"name": "amount1Max", "type": "uint128"},
                ],
                "name": "params",
                "type": "tuple",
            }
        ],
        "name": "collect",
        "outputs": [
            {"name": "amount0", "type": "uint256"},
            {"name": "amount1", "type": "uint256"},
        ],
        "stateMutability": "payable",
        "type": "function",
    },
]

npm_collect = rpc.eth.contract(address=NPM_ADDRESS, abi=collect_abi)

MAX_UINT128 = (1 << 128) - 1

amount0, amount1 = npm_collect.functions.collect({
    "tokenId": MY_TOKEN_ID,
    "recipient": MY_WALLET,
    "amount0Max": MAX_UINT128,
    "amount1Max": MAX_UINT128,
}).call({"from": MY_WALLET})

print(f"Claimable USDC: {amount0 / 1e6}")
print(f"Claimable WETH: {amount1 / 1e18}")

This does not broadcast the transaction. It asks the node: “if I called collect right now, what would come back?” Cheap, no gas, no state change.

The 11-day log

I read the position once a day. Price started at $3,450 and wandered between $3,380 and $3,560 before settling at $3,420 on day 11. The position stayed in range the entire time.

DayETH pricePosition valueClaimable feesHODL value
0$3,450$200.00$0.00$200.00
2$3,510$200.82$0.19$201.79
4$3,480$200.41$0.37$200.89
6$3,390$199.13$0.52$198.26
8$3,555$201.18$0.71$203.05
11$3,420$199.71$0.94$199.12

“HODL value” is what the initial split ($97.40 USDC + 0.0298 WETH) would have been worth if I had held it in the wallet instead of depositing. “Position value” is the current LP position valued at the current price, excluding fees.

Breaking down day 11

HODL: 97.40 + (0.0298 × 3420) = 97.40 + 101.92 = 199.32
Position (without fees): 199.71 - 0.94 = 198.77
Impermanent loss: 198.77 - 199.32 = -0.55
Fees earned: 0.94
Net outcome vs HODL: 199.71 - 199.32 = +0.39

Wait — the HODL line on day 11 shows $199.12, not $199.32. The small discrepancy is because the initial split was not precisely reconstructable from the displayed rounded numbers; the on-chain values were 97.4138 USDC and 0.02975 WETH. Carrying full precision:

HODL (precise): 97.4138 + (0.02975 × 3420) = 97.4138 + 101.745 = 199.16
Position (without fees): 198.77
IL: 198.77 - 199.16 = -0.39
Fees: 0.94
Net vs HODL: +0.55

The position came out $0.55 ahead of HODL after 11 days on a $200 stake. Annualized that is roughly 9.1% — but with a huge caveat: price barely moved. If ETH had gapped out of my range, the picture would be different.

What happens when price leaves the range

I did not close the position before writing this, so I ran a hypothetical. If ETH dropped to $3,200 (below my $3,275 lower bound), the position would flip entirely to WETH because at the lower bound, all the USDC has been swapped away by arbitrageurs providing cheaper USDC to the pool.

Post-exit composition: 0 USDC, 0.0606 WETH
Value at $3,200: 0.0606 × 3200 = $193.92
HODL value at $3,200: 97.41 + (0.02975 × 3200) = 192.61

Interesting — the LP position is actually slightly above HODL at that exit price, because I collected some fees on the way down. But I am also now 100% in WETH, which continues to lose value if ETH keeps falling. That is the hidden tail: once you are out of range, you stop earning fees and you hold the “wrong” asset relative to the price direction.

Symmetric case: ETH rallies to $3,700. Position flips to all USDC:

Post-exit composition: 205.90 USDC, 0 WETH
Value: $205.90
HODL value at $3,700: 97.41 + (0.02975 × 3700) = 207.49

Now the LP trails HODL by $1.59. You captured some fees, but the arbitrageurs took the upside of your WETH when they bought through your range. This is impermanent loss in its rawest form.

A tighter range would have earned more — until it did not

I ran the same math assuming I had picked a ±2% range instead of ±5%. Concentrating the same $200 into a quarter of the tick width gives roughly 4x the liquidity density, so fee capture per unit of time in range is ~4x higher. On days where price stayed inside ±2%, the position would have earned roughly $3.76 in fees over the 11 days.

But on days 6 and 8, price moved ~3% from the open. A ±2% range would have been out of range for ~2 days total. Zero fees during that time, and you exit with an asymmetric composition you then need to rebalance.

My rough estimate for the ±2% scenario:

  • Fees: ~$2.80 (9 days in range)
  • IL at day 11: larger than ±5% because the range boundaries clip further, leaving more of the position swapped when you cross them
  • Net: similar to ±5%, but with much higher variance and rebalancing overhead

The tight-range LP is not a free lunch. You are trading yield for operational complexity.

Risk analysis

Impermanent loss

The classic risk. At ±5% range on a week-scale hold, IL stayed under 0.5% of capital in all scenarios I walked through. At wider moves (>10% in a week), IL can easily swamp fees earned. Uniswap’s historical data shows roughly one in five randomly-chosen active V3 LP positions losing money to IL net of fees, per the 2021 Topaze Blue / Bancor study. That was at the market-wide aggregate — individual outcomes vary widely by range selection.

Out-of-range gaps

Narrow ranges amplify fees when in range and produce zero when out. If price gaps over a weekend while you are not watching, you are earning nothing and holding 100% of the “wrong” asset. Mitigation: wider ranges for passive holds, or automated rebalancing (which adds its own gas and complexity).

Gas on collects and rebalances

Collecting fees costs ~$0.15 on Arbitrum. Closing and reopening a position is ~$0.80 total. On a $200 position, if you rebalance weekly, $3+/month in gas eats a third of a typical fee yield. Keep rebalancing rare on small positions.

Pool tier fragmentation

The same pair exists across four fee tiers. Volume migrates between tiers based on volatility. A pool that had heavy volume at 0.05% can move to 0.30% during volatile periods, leaving your 0.05% position parked with minimal swap-through. Monitor volume by tier, not just total pair volume.

Position manager smart contract risk

Your NFT is held by the position manager contract (0xC36442b4...). Uniswap V3 is heavily audited and battle-tested, but any contract bug here could freeze or misreport positions. For a small test position this is a rounding error. For larger positions, this is the same kind of smart-contract risk you accept on any on-chain venue.

MEV on in-range swaps

Large swaps against your range can be sandwiched or routed around. You earn fees on whatever actually goes through your liquidity. You do not control what the aggregators and MEV bots choose to route through. In a pool that captures almost all the volume for the pair, this is mostly upside. In a fragmented pool, it can mean less flow than you expect.

What I learned

  1. Fee math from the calculator mostly checks out, but the variance is real. The model I wrote earlier said a ±5% range on $200 at 0.05% should earn roughly $0.08/day at typical volume. I earned $0.085/day. The sample is tiny, but the order of magnitude was right.

  2. Impermanent loss on a narrow ETH/USDC hold over 11 days is small. Under half a percent of capital at the moves I saw. The real IL risk shows up at longer holds and wider price excursions, not at the scales most people test at.

  3. Out-of-range is the failure mode to plan for. A position that falls out of range is worse than a wider-range position that stayed in. Fees go to zero and the composition is now asymmetric exactly when you would want the opposite.

  4. Fee reading is annoying but solvable. The tokensOwed fields lie until you interact. A static collect call through eth_call is the cleanest way to see real-time accrued fees without spending gas. Worth scripting if you run more than one position.

  5. At $200 the economics are marginal but not broken. $0.94 in fees net of $0.22 in gas is not a meaningful return. But the structure was right: the position earned its theoretical fee rate, IL behaved as expected, and the full cycle (open → hold → read → hypothetical close) cost under a dollar. This is the scale at which you practice before you size up.