Portfolio
Portfolio answers “how is the LP book doing as a whole?” — aggregating N positions across protocols into a single common-numeraire view.
One primitive: AggregatePortfolio. The category is intentionally small in v1 — once leaf primitives stabilize, more portfolio-level views land here.
All primitives in the Agentic Primitives section follow the same contract: stateless construction, computation at .apply(), typed dataclass return.
from defipy.twin import MockProvider, StateTwinBuilderfrom defipy.utils.data import PortfolioPosition
provider = MockProvider()builder = StateTwinBuilder()
lp_v2 = builder.build(provider.snapshot("eth_dai_v2"))lp_v3 = builder.build(provider.snapshot("eth_dai_v3"))lp_bal = builder.build(provider.snapshot("eth_dai_balancer_50_50"))AggregatePortfolio
Section titled “AggregatePortfolio”Purpose. Aggregate N LP positions (mixed V2/V3/Balancer/Stableswap) into a single portfolio-level view sharing a common first-token numeraire.
Signature.
AggregatePortfolio().apply(positions) -> PortfolioAnalysispositions is a list of PortfolioPosition objects. The numeraire is enforced as the shared first-token symbol across positions — mismatched first tokens raise ValueError. V2/V3/Balancer positions must set entry_x_amt and entry_y_amt; Stableswap positions must set entry_amounts (per-token list, pool insertion order).
In v1 Balancer/Stableswap contribute fee_income = 0.0 (no per-LP fee attribution in upstream). Stableswap unreachable-alpha positions contribute 0.0 to totals and append a note to shared_exposure_warnings.
from defipy import AggregatePortfolio
# Three ETH/DAI positions across three protocols, all entered when ETH was 80 DAI.positions = [ PortfolioPosition( lp = lp_v2, lp_init_amt = 10000.0, entry_x_amt = 1000.0, entry_y_amt = 80000.0, holding_period_days = 30.0, name = "V2 ETH/DAI", ), PortfolioPosition( lp = lp_v3, lp_init_amt = 10000.0, entry_x_amt = 1000.0, entry_y_amt = 80000.0, lwr_tick = -887220, upr_tick = 887220, holding_period_days = 30.0, name = "V3 ETH/DAI full-range", ), PortfolioPosition( lp = lp_bal, lp_init_amt = 100.0, entry_x_amt = 1000.0, entry_y_amt = 80000.0, holding_period_days = 30.0, name = "Balancer ETH/DAI 50/50", ),]
result = AggregatePortfolio().apply(positions)print(f"numeraire: {result.numeraire}")print(f"total_value: {result.total_value:.4f}")print(f"total_hold_value: {result.total_hold_value:.4f}")print(f"total_net_pnl: {result.total_net_pnl:.4f}")print(f"total_fees: {result.total_fees:.4f}")print(f"pnl_ranking: {result.pnl_ranking}")print(f"shared_warnings: {result.shared_exposure_warnings}")print()print("Per position:")for p in result.positions: print(f" {p.name} ({p.protocol}): pnl={p.net_pnl:.4f}, fees={p.fee_income:.4f}, " f"il={p.il_percentage:.6f}")Numeraire enforcement — mixing a USDC/DAI stableswap position into an ETH-numeraire portfolio raises ValueError:
lp_sts = builder.build(provider.snapshot("usdc_dai_stableswap_A10"))
mixed = positions + [ PortfolioPosition( lp = lp_sts, lp_init_amt = 100.0, entry_amounts = [100000.0, 100000.0], holding_period_days = 30.0, name = "Stableswap USDC/DAI", ),]
try: AggregatePortfolio().apply(mixed)except ValueError as e: print(f"ValueError: {e}")Protocol coverage
Section titled “Protocol coverage”| Protocol | Supported | Notes |
|---|---|---|
| Uniswap V2 | ✅ | Full IL + fee decomposition contributes |
| Uniswap V3 | ✅ | With lwr_tick / upr_tick for concentrated positions |
| Balancer | ✅ | 2-asset only; fee_income always 0 in v1 |
| Stableswap | ⚠️ | Unreachable-alpha positions contribute 0 and add a warning; numeraire must match (peg-token positions vs ETH-token positions can’t mix) |
MCP tool exposure
Section titled “MCP tool exposure”AggregatePortfolio is not in the curated 10. The MCP curation surfaces leaf primitives only — composition primitives like AggregatePortfolio are better assembled LLM-side from AnalyzePosition calls so the agent can decide which positions to include, what numeraire to use, and which sub-views to surface.