Position Analysis
Position Analysis primitives decompose a live LP position into its drivers — impermanent loss, fee income, and net PnL — so an LLM (or a human) can answer the question why is this position making or losing money? The category contains three primitives, one per protocol family: Uniswap V2/V3, Balancer (2-asset weighted), and Stableswap (2-asset).
All primitives in the Agentic Primitives section follow the same contract: stateless construction, computation at .apply(), typed dataclass return.
Setup
Build three twins from the canonical MockProvider recipes — one per protocol — and apply a small swap to push the stableswap pool slightly off peg so its analysis isn’t trivially zero.
[1]:
from defipy.twin import MockProvider, StateTwinBuilder
from stableswappy.process.swap import Swap as StableswapSwap
provider = MockProvider()
builder = StateTwinBuilder()
lp_v2 = builder.build(provider.snapshot("eth_dai_v2"))
lp_bal = builder.build(provider.snapshot("eth_dai_balancer_50_50"))
lp_sts = builder.build(provider.snapshot("usdc_dai_stableswap_A10"))
# Push the stableswap pool slightly off peg with a 5,000 USDC -> DAI swap.
sts_tokens = lp_sts.factory.token_from_exchange[lp_sts.name]
StableswapSwap().apply(lp_sts, sts_tokens["USDC"], sts_tokens["DAI"], "agent", 5000.0)
[1]:
{'tkn_out_amt': 4976.831425047311,
'tkn_in_nm': 'USDC',
'tkn_in_fee': 0.49773291579631074}
AnalyzePosition
Purpose. Decompose an LP position into impermanent loss, fee income, and net PnL for Uniswap V2 and V3 pools.
Signature.
AnalyzePosition().apply(
lp, lp_init_amt, entry_x_amt, entry_y_amt,
lwr_tick=None, upr_tick=None, holding_period_days=None,
) -> PositionAnalysis
The caller supplies entry token amounts explicitly — the natural framing for an LP (“I deposited X ETH and Y DAI”). The primitive combines entry amounts with the current pool state to compute current value, hold value, and the IL/fee decomposition. lwr_tick / upr_tick are V3-only; omit for V2. holding_period_days enables real_apr annualization.
The example below models a position that entered at 80 DAI/ETH and is being analyzed at the current spot of 100 DAI/ETH — i.e. ETH appreciated 25% since entry.
[2]:
from defipy import AnalyzePosition
result = AnalyzePosition().apply(
lp_v2,
lp_init_amt = 10000.0,
entry_x_amt = 1000.0,
entry_y_amt = 80000.0,
holding_period_days = 30.0,
)
[3]:
print(f"current_value: {result.current_value:.4f}")
print(f"hold_value: {result.hold_value:.4f}")
print(f"il_percentage: {result.il_percentage:.6f}")
print(f"fee_income: {result.fee_income:.4f}")
print(f"net_pnl: {result.net_pnl:.4f}")
print(f"diagnosis: {result.diagnosis}")
current_value: 2000.0000
hold_value: 1800.0000
il_percentage: -0.006192
fee_income: 211.1456
net_pnl: 200.0000
diagnosis: net_positive
AnalyzeBalancerPosition
Purpose. Decompose a 2-asset Balancer LP position into IL, fees, and net PnL. Sibling to AnalyzePosition adapted to the weighted-AMM IL formula where the weight (not just the price ratio) drives IL.
Signature.
AnalyzeBalancerPosition().apply(
lp, lp_init_amt, entry_base_amt, entry_opp_amt,
holding_period_days=None,
) -> BalancerPositionAnalysis
Base / opp follow the pool’s token insertion order. v1 has no fee attribution — fee_income is always 0.0 and il_with_fees == il_percentage. All values are denominated in opp-token units (Balancer’s natural numeraire). N>2 pools raise ValueError.
Same scenario as the V2 example: the position entered when ETH was priced at 80 DAI.
[4]:
from defipy import AnalyzeBalancerPosition
result = AnalyzeBalancerPosition().apply(
lp_bal,
lp_init_amt = 100.0,
entry_base_amt = 1000.0,
entry_opp_amt = 80000.0,
holding_period_days = 30.0,
)
[5]:
print(f"base / opp: {result.base_tkn_name} / {result.opp_tkn_name}")
print(f"base_weight: {result.base_weight}")
print(f"current_value: {result.current_value:.4f}")
print(f"hold_value: {result.hold_value:.4f}")
print(f"il_percentage: {result.il_percentage:.6f}")
print(f"net_pnl: {result.net_pnl:.4f}")
print(f"alpha: {result.alpha:.6f}")
print(f"diagnosis: {result.diagnosis}")
base / opp: ETH / DAI
base_weight: 0.5
current_value: 200000.0000
hold_value: 180000.0000
il_percentage: -0.006192
net_pnl: 20000.0000
alpha: 1.250000
diagnosis: net_positive
AnalyzeStableswapPosition
Purpose. Decompose a 2-asset Stableswap LP position. The flat-curve regime around peg means small price deviations can produce surprisingly large IL at high A.
Signature.
AnalyzeStableswapPosition().apply(
lp, lp_init_amt, entry_amounts, holding_period_days=None,
) -> StableswapPositionAnalysis
entry_amounts is a list [x0, x1] in the pool’s insertion order (covers single-sided deposits, not just balanced ones). Values are denominated at peg — tokens summed 1:1, stableswap’s natural numeraire.
Reachability. When the pool’s implied alpha is unreachable (high A, large |1−α|), the result returns None for il_percentage and net_pnl, with diagnosis = "unreachable_alpha". When the pool is at peg the result short-circuits to il_percentage = 0.0 and diagnosis = "at_peg".
The setup cell pushed this pool slightly off peg via a 5,000 USDC → DAI swap, so the analysis below reflects a real (small) IL number.
[6]:
from defipy import AnalyzeStableswapPosition
result = AnalyzeStableswapPosition().apply(
lp_sts,
lp_init_amt = 100.0,
entry_amounts = [100000.0, 100000.0],
holding_period_days = 30.0,
)
[7]:
print(f"token_names: {result.token_names}")
print(f"A: {result.A}")
print(f"alpha: {result.alpha:.6f}")
print(f"current_value: {result.current_value:.6f}")
print(f"hold_value: {result.hold_value:.4f}")
print(f"il_percentage: {result.il_percentage:.8f}")
print(f"net_pnl: {result.net_pnl:.6f}")
print(f"diagnosis: {result.diagnosis}")
token_names: ['USDC', 'DAI']
A: 10
alpha: 0.990934
current_value: 199966.455457
hold_value: 200000.0000
il_percentage: -0.00016772
net_pnl: -33.544543
diagnosis: il_dominant
Protocol coverage
MCP tool exposure
All three primitives in this category are surfaced as MCP tools in the curated set of 10:
AnalyzePositionAnalyzeBalancerPositionAnalyzeStableswapPosition
Position analysis is the highest-traffic agent question (“how is my position doing?”), so all three primitives — one per protocol family — are exposed directly rather than left to the LLM to compose.