auradefi 0.1.1
PyPI GitHub

PyBook 08: Positions

What this notebook proves: "the adapter protocol + a Uniswap-v2, an Aave, and a liquid-staking adapter; drill-down to underlying assets; synthetic-Holdings projection. Done when: the projection invariant holds."

A balance says you hold 850 LP tokens. A position says what those LP tokens are: a share of a USDC/WETH pool, worth its pro-rata slice of the reserves. The job splits in two, and the split is the whole design:

Adapters return raw integers only, no prices, no USD. Pricing is a separate pass, so a portfolio re-prices without a single chain read. Nothing in this book touches the network: the chain is a dict-backed ContractReader, frozen at Ethereum block 20,450,000.

from auradefi.positions.protocol import (
    ContractDescriptor,
    ContractSet,
    DiscoveryContext,
    PositionAdapter,
    ResolveContext,
)

BLOCK = 20_450_000
CHAIN = "eip155:1"
ADDRESS = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"


class DictReader:
    "The only chain-read seam: (address, fn, args) -> value. No I/O."

    def __init__(self, responses):
        self._responses = dict(responses)
        self.calls = []

    def call(self, address: str, fn: str, args: tuple = ()) -> object:
        self.calls.append((address.lower(), fn, args))
        return self._responses[(address.lower(), fn, args)]


# `PositionAdapter` is a runtime-checkable Protocol: an adapter is anything
# with an id, a chain set, discover() and resolve().
from auradefi.positions.adapters.amm.uniswap_v2 import UniswapV2Adapter

assert isinstance(UniswapV2Adapter(), PositionAdapter)
assert isinstance(DictReader({}), type(DictReader({})))
print("adapter protocol:", sorted(m for m in dir(PositionAdapter) if not m.startswith("_")))
adapter protocol: ['discover', 'resolve']

A Uniswap v2 LP position: pro rata, in integers

The maths is one line and it must be exact:

your share of reserve_i = balanceOf(you) * reserve_i // totalSupply

Integer floor division, never a float. The fixture is the USDC/WETH pair at block 20,450,000: you hold 850,000,000,000,000 of 850,000,000,000,000,000 LP units: 0.1% of the pool.

Position and group ids are sha256 over pinned preimages (docs/internal/DECISIONS.md), so they are stable across processes, machines and releases: pos_e463a531f5d6a400 is that pair position forever.

from auradefi.money.quantity import Quantity
from auradefi.positions.models import MetaType, PositionKind, PositionType, ProtocolModule

USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
WETH = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
V2_PAIR = "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc"

reader = DictReader({
    (V2_PAIR, "balanceOf", (ADDRESS,)): 850_000_000_000_000,
    (V2_PAIR, "totalSupply", ()): 850_000_000_000_000_000,
    (V2_PAIR, "getReserves", ()): (52_000_000_000_000, 14_500_000_000_000_000_000_000, 1_722_470_000),
    (V2_PAIR, "token0", ()): USDC,
    (V2_PAIR, "token1", ()): WETH,
    (USDC, "decimals", ()): 6,
    (WETH, "decimals", ()): 18,
})
descriptor = ContractDescriptor(
    adapter_id="uniswap-v2", chain_id=CHAIN, address=V2_PAIR, category="amm-pair",
    underlyings=(f"{CHAIN}/erc20:{USDC}", f"{CHAIN}/erc20:{WETH}"),
)
ctx = ResolveContext(chain_id=CHAIN, address=ADDRESS, reader=reader, block_number=BLOCK)
(lp,) = UniswapV2Adapter().resolve(ctx, ContractSet.of(descriptor))

assert lp.id == "pos_e463a531f5d6a400"
assert lp.group_id == "grp_b351d79d77bc24eb"
assert lp.kind is PositionKind.APP_TOKEN            # the LP token IS the position
assert lp.position_type is PositionType.DEPOSIT
assert lp.protocol_module is ProtocolModule.LIQUIDITY_POOL
assert [(u.meta_type, u.quantity) for u in lp.underlyings] == [
    (MetaType.SUPPLIED, Quantity(52_000_000_000, 6)),                 # 52,000 USDC
    (MetaType.SUPPLIED, Quantity(14_500_000_000_000_000_000, 18)),    # 14.5 WETH
]
# RAW only: no price, no value, nothing to go stale.
assert all(u.price is None and u.value is None for u in lp.underlyings)
assert lp.value is None

for underlying in lp.underlyings:
    print(f"{underlying.meta_type.value:>8}  {str(underlying.quantity):>10}  {underlying.asset_id}")
supplied       52000  eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
supplied        14.5  eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2

An Aave position: two rows, one risk unit

The worked example: supply 10 ETH, borrow 5,000 USDC. That is two positions sharing one group_id, a supply APP_TOKEN and a borrow CONTRACT_POSITION, because liquidation applies to the pair, not to either leg. group_info (health factor, LTV) is attached once.

Note what the borrow does not do: the raw quantity stays positive. The sign lives in meta_type=BORROWED. The negative number appears exactly once, at the projection boundary, which is the next section.

from decimal import Decimal

from auradefi.positions.adapters.lending.aave import AaveV3Adapter, Market

POOL = "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2"
AWETH, DEBT_WETH = "0x4d5f47fa6a74757f35c14fd3a6ef8e3c9bc514e8", "0xea51d7853eefb32b6ee06b1c12e6dcca88be0ffe"
AUSDC, DEBT_USDC = "0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c", "0x72e95b8931767c79ba4eee721354d6e99a61d004"
ETH_ID, USDC_ID = "eip155:1/slip44:60", f"{CHAIN}/erc20:{USDC}"
USER = "0x00000000000000000000000000000000000a11ce"


class MainnetAaveV3(AaveV3Adapter):
    markets = (Market(AWETH, DEBT_WETH, ETH_ID, 18), Market(AUSDC, DEBT_USDC, USDC_ID, 6))


aave_reader = DictReader({
    (AWETH, "balanceOf", (USER,)): 10_000_000_000_000_000_000,   # 10 ETH supplied
    (DEBT_WETH, "balanceOf", (USER,)): 0,
    (AUSDC, "balanceOf", (USER,)): 0,
    (DEBT_USDC, "balanceOf", (USER,)): 5_000_000_000,            # 5,000 USDC borrowed
    (POOL, "getUserAccountData", (USER,)): (
        3_584_250_000_000, 500_000_000_000, 2_367_400_000_000, 8250, 8000, 5_812_500_000_000_000_000,
    ),
})
adapter = MainnetAaveV3()
contracts = adapter.discover(DiscoveryContext(chain_id=CHAIN, reader=aave_reader))
supply, borrow = adapter.resolve(
    ResolveContext(chain_id=CHAIN, address=USER, reader=aave_reader, block_number=BLOCK), contracts
)

assert (supply.id, borrow.id) == ("pos_baff12a5eafb77f6", "pos_1bbfb302ddabf62b")
assert supply.group_id == borrow.group_id == "grp_0f89caffe413b09f"   # ONE risk unit
assert supply.position_type is PositionType.DEPOSIT
assert borrow.position_type is PositionType.LOAN
assert borrow.kind is PositionKind.CONTRACT_POSITION                  # no receipt token to hold
assert supply.underlyings[0].meta_type is MetaType.SUPPLIED
assert borrow.underlyings[0].meta_type is MetaType.BORROWED
assert borrow.underlyings[0].quantity.raw > 0                         # sign lives in meta_type
assert supply.group_info.health_factor == Decimal("5.8125")
assert supply.group_info.ltv == Decimal("0.8")
# Zero balances are dropped: 4 markets probed, 2 positions emitted.
assert len([c for c in aave_reader.calls if c[1] == "balanceOf"]) == 4

print(f"supply  {supply.underlyings[0].quantity} ETH   ({supply.position_type.value})")
print(f"borrow  {borrow.underlyings[0].quantity} USDC ({borrow.position_type.value}, meta={borrow.underlyings[0].meta_type.value})")
print(f"group   {supply.group_id}  health_factor={supply.group_info.health_factor}  ltv={supply.group_info.ltv}")
supply  10 ETH   (deposit)
borrow  5000 USDC (loan, meta=borrowed)
group   grp_0f89caffe413b09f  health_factor=5.812500000000000000  ltv=0.8000

Drill: price the raw, once

drill(positions, prices) walks every underlying, applies the price map, and returns the signed triple:

Multiplication is context-free exact Decimal (rounding: none: docs/internal/DECISIONS.md), so drilling is deterministic at any magnitude.

from auradefi.money.fiat import Money
from auradefi.positions.drill import drill, project_to_synthetic_holdings

prices = {
    ETH_ID: Money(Decimal("3584.17"), "USD"),
    USDC_ID: Money(Decimal("0.999839"), "USD"),
}
drilled = drill([supply, borrow], prices)

assert drilled.gross_assets.amount == Decimal("35841.70")     # 10 x 3584.17
assert drilled.total_debt.amount == Decimal("4999.195")       # 5000 x 0.999839
assert drilled.net_worth.amount == Decimal("30842.505")
assert len(drilled.groups) == 1
assert drilled.groups[0].total_value.amount == drilled.net_worth.amount
print(f"gross {drilled.gross_assets}\ndebt  {drilled.total_debt}\nnet   {drilled.net_worth}")
gross 35841.70000000000000000000 USD
debt  4999.195000000000 USD
net   30842.50500000000000000000 USD

The projection invariant

The rule, verbatim:

"an Aave position supplying 10 ETH and borrowing 5,000 USDC emits two synthetic Holdings. +10 ETH and a negative-quantity USDC Holding. A Plaid-only client sums institution_value and gets the right net worth."

That negative quantity is the Plaid extension that makes the naive sum correct. Get the sign convention wrong and nothing errors. The net worth is just silently wrong, which is the named casualty. So the invariant is asserted by exact Decimal equality, and asserted again after repricing to prove it is structural rather than a lucky pair of numbers.

holdings = project_to_synthetic_holdings(drilled)
by_asset = {holding.asset_id: holding for holding in holdings}

assert len(holdings) == 2
assert by_asset[ETH_ID].quantity == Decimal("10")
assert by_asset[USDC_ID].quantity == Decimal("-5000")             # strictly negative
assert by_asset[USDC_ID].institution_price.amount > 0             # the PRICE never goes negative
assert by_asset[USDC_ID].institution_value.amount == Decimal("-4999.195000")

# What a Plaid-only client does: sum institution_value and trust it.
naive_total = sum((h.institution_value.amount for h in holdings), Decimal("0"))
assert naive_total == drilled.net_worth.amount == Decimal("30842.505")

for holding in holdings:
    print(f"{str(holding.quantity):>7}  @ {str(holding.institution_price):>13}  = {holding.institution_value}")
print(f"{'sum':>7}    {'':>13}    {naive_total} USD == net worth {drilled.net_worth.amount}")
10.000000000000000000  @   3584.17 USD  = 35841.70000000000000000000 USD
-5000.000000  @  0.999839 USD  = -4999.195000000000 USD
    sum                     30842.50500000000000000000 USD == net worth 30842.50500000000000000000
# Same RAW positions, fresh ETH price, ZERO chain reads.
reads_before = len(aave_reader.calls)
repriced = drill([supply, borrow], {ETH_ID: Money(Decimal("3600"), "USD"), USDC_ID: prices[USDC_ID]})
rebuilt = sum((h.institution_value.amount for h in project_to_synthetic_holdings(repriced)), Decimal("0"))

assert repriced.net_worth.amount == Decimal("31000.805")
assert rebuilt == repriced.net_worth.amount
assert len(aave_reader.calls) == reads_before  # not one extra call
print(f"re-priced at 3600: net worth {repriced.net_worth}: {len(aave_reader.calls) - reads_before} chain reads")
re-priced at 3600: net worth 31000.805000000000000000 USD: 0 chain reads

Failure is per-adapter, never per-portfolio

run_discovery / resolve_all isolate each adapter: one adapter throwing does not blank the portfolio, it produces a named failure alongside the positions that did resolve. A DeFi aggregator that returns nothing because one protocol changed an ABI is worse than useless.

from auradefi.positions.registry import AdapterRegistry
from auradefi.positions.resolve import resolve_all


class BrokenAdapter:
    id = "broken"
    chains = frozenset({CHAIN})

    def discover(self, ctx):
        return ContractSet.empty()

    def resolve(self, ctx, contracts):
        raise RuntimeError("upstream ABI changed")


registry = AdapterRegistry()
registry.register(MainnetAaveV3())
registry.register(BrokenAdapter())
assert [a.id for a in registry.for_chain(CHAIN)] == ["aave-v3", "broken"]

outcome = resolve_all(
    registry.adapters(),
    ResolveContext(chain_id=CHAIN, address=USER, reader=aave_reader, block_number=BLOCK),
    {"aave-v3": contracts, "broken": ContractSet.empty()},
)
assert [p.id for p in outcome.positions] == [supply.id, borrow.id]
assert [(f.adapter_id, "ABI" in f.error) for f in outcome.failures] == [("broken", True)]
print("positions:", len(outcome.positions), "| failures:", outcome.failures)
positions: 2 | failures: (AdapterFailure(adapter_id='broken', error="RuntimeError('upstream ABI changed')"),)

Honest edges: read this before trusting a number

Next: 09_embedding: a host wiring the whole thing into its own process.