PyBook 05: Balances to Holdings
What this notebook proves: "EVM balances → holdings. Etherscan V2 +
DefiLlama prices. Single-tenant, library-only. Done when: a known-rich
address returns a USD total within a few % of an incumbent."
Three collaborators, one report:
| seam |
contract |
this book |
| source |
balances(chain_id, address) -> [BalanceRecord] |
sources.evm.etherscan.EtherscanV2 |
| prices |
usd_prices([caip19]) -> {caip19: Money} |
prices.inquirer.Inquirer over DefiLlamaOracle |
| clock |
now_ms() -> int |
FrozenClock |
portfolio/ is transport-free: it never imports httpx, and
tests/style/test_layering.py enforces that. Everything below replays
tests/cassettes/phase1_vitalik.json, so this book needs no API key and
no network.
from pathlib import Path
def repo_root() -> Path:
"""The checkout this book lives in, wherever it is executed from."""
here = Path.cwd().resolve()
for candidate in (here, *here.parents):
if (candidate / "tests" / "cassettes").is_dir():
return candidate
raise RuntimeError("run this book from inside a checkout of auradefi")
CASSETTES = repo_root() / "tests" / "cassettes"
print("cassettes:", CASSETTES.relative_to(repo_root()))
cassettes: tests/cassettes
The source: Etherscan V2, one key, 50+ chains
EtherscanV2 takes an injected httpx.Client (that is the whole
reason a cassette can stand in for the internet) and issues three kinds of
GET against one /v2/api base URL:
action=balance: the native balance, as a wei string;
action=tokentx: paged transfer history, purely to discover which
ERC-20 contracts this address has ever touched;
action=tokenbalance: one call per discovered contract, in ascending
lowercased-contract order so a recording is deterministic.
Two pieces of hygiene are visible in the fixture: a mixed-case duplicate
of the USDC contract collapses after lowercasing (canonical
form), and a spam row whose tokenDecimal is "" is skipped
additively: a garbage row never crashes the scan (rule #9).
from auradefi.money.quantity import Quantity
from auradefi.sources.evm.etherscan import EtherscanV2
from auradefi.testing.cassettes import load
CHAIN = "eip155:1"
ADDRESS = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
def fresh_client():
"A new replay client: one cassette serves etherscan AND llama."
return load(CASSETTES / "phase1_vitalik.json").client()
records = EtherscanV2(fresh_client(), api_key="TESTKEY").balances(CHAIN, ADDRESS)
# Four discovery rows in the cassette; three survive: the mixed-case USDC
# duplicate dedupes, the tokenDecimal="" spam row is skipped.
assert [record.symbol for record in records] == ["ETH", "DAI", "USDC"]
assert records[0].quantity == Quantity(4878123456789012345678, 18)
assert records[1].quantity == Quantity(255000000000000000000000, 18)
assert records[2].quantity == Quantity(1250000750000, 6)
assert records[0].contract_address is None # native coin
assert records[2].caip19 == "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
for record in records:
print(f"{record.symbol:>5} raw={record.quantity.raw:<25} -> {record.quantity}")
ETH raw=4878123456789012345678 -> 4878.123456789012345678
DAI raw=255000000000000000000000 -> 255000
USDC raw=1250000750000 -> 1250000.75
The prices seam: an oracle is anything with usd_prices
Inquirer composes oracles first-wins: it validates every CAIP-19 up
front (a bad id raises CaipParseError before any HTTP), deduplicates,
then asks each oracle only for what is still unpriced. The package ships one
oracle, DefiLlama, so the "first" and the "only" coincide today; that is
recorded honestly in the README coverage table.
prices/inquirer.py never imports the concrete oracle: the seam is
structural (Protocol), which is what lets a host drop in its own price
feed without touching auradefi.
from decimal import Decimal
from auradefi.errors import CaipParseError
from auradefi.money.fiat import Money
from auradefi.prices.inquirer import Inquirer
from auradefi.prices.oracles.defillama import DefiLlamaOracle
ETH = "eip155:1/slip44:60"
DAI = "eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f"
USDC = "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
prices = Inquirer([DefiLlamaOracle(fresh_client())])
quoted = prices.usd_prices([ETH, DAI, USDC])
assert quoted == {
ETH: Money(Decimal("3584.17"), "USD"),
DAI: Money(Decimal("0.99985"), "USD"),
USDC: Money(Decimal("0.999839"), "USD"),
}
print({asset.split("/")[-1]: str(price) for asset, price in quoted.items()})
# An unparseable id is refused BEFORE any oracle is consulted.
try:
prices.usd_prices(["not-a-caip19"])
except CaipParseError as exc:
print("refused pre-flight:", exc)
{'slip44:60': '3584.17 USD', 'erc20:0x6b175474e89094c44da98b954eedeac495271d0f': '0.99985 USD', 'erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': '0.999839 USD'}
refused pre-flight: not a CAIP-19 (need exactly one '/'): 'not-a-caip19'
Assembly: exact Decimal, no rounding anywhere
HoldingsService.holdings is pinned (rule #5, and
tests/golden/test_phase1_holdings.py asserts these very numbers):
- one
source.balances call;
- exactly one
prices.usd_prices call, ids in record order;
value = quantity × price, multiplied context-free. The integer
coefficients are multiplied and the exponents added, so a 78-digit raw
survives intact where a Decimal context multiply would silently round
to 28 significant digits (rule #1);
HoldingsReport.assemble(...) echoes the caller's address and chain id
verbatim and sums the priced values exactly.
from decimal import Decimal
from auradefi.clock import FrozenClock
from auradefi.portfolio.holdings import HoldingsService
AS_OF_MS = 1_754_000_000_000
client = fresh_client() # ONE client for both hosts, as a host would wire it
service = HoldingsService(
EtherscanV2(client, api_key="TESTKEY"),
Inquirer([DefiLlamaOracle(client)]),
clock=FrozenClock(AS_OF_MS),
)
report = service.holdings(CHAIN, ADDRESS)
assert report.holdings[0].value.amount == Decimal("17484023.75011947437900871726")
assert report.holdings[1].value.amount == Decimal("254961.75")
assert report.holdings[2].value.amount == Decimal("1249799.49987925")
assert report.total_value == Money(Decimal("18988784.99999872437900871726"), "USD")
assert report.unpriced == ()
assert (report.address, report.chain_id, report.as_of_ms) == (ADDRESS, CHAIN, AS_OF_MS)
for holding in report.holdings:
print(f"{holding.symbol:>5} {str(holding.quantity):>26} @ {str(holding.price):>12} = {holding.value}")
print(f"{'TOTAL':>5} {'':>26} {'':>12} = {report.total_value}")
ETH 4878.123456789012345678 @ 3584.17 USD = 17484023.75011947437900871726 USD
DAI 255000 @ 0.99985 USD = 254961.75000000000000000000000 USD
USDC 1250000.75 @ 0.999839 USD = 1249799.499879250000 USD
TOTAL = 18988784.99999872437900871726000 USD
The gate, stated as arithmetic
The target is a total "within a few % of an incumbent". The incumbent
reference for this address at this recording is 19,000,000 USD; the
delta is asserted rather than eyeballed.
incumbent = Decimal("19000000")
delta = abs(report.total_value.amount - incumbent) / incumbent
assert delta < Decimal("0.05")
print(f"total {report.total_value.amount} vs incumbent {incumbent} -> delta {delta * 100:.4f}%")
# And the reason the total is trustworthy: it is not a float.
float_total = sum(
float(holding.quantity.as_decimal()) * float(holding.price.amount)
for holding in report.holdings
)
assert Decimal(str(float_total)) != report.total_value.amount
print("float would have said:", repr(float_total), "(truncated at 17 digits)")
print("exact answer says: ", report.total_value.amount.normalize())
total 18988784.99999872437900871726000 vs incumbent 19000000 -> delta 0.0590%
float would have said: 18988784.999998722 (truncated at 17 digits)
exact answer says: 18988784.99999872437900871726
Rule #2 in one assertion: every quantity crossing a boundary is the pinned
four-field object whose raw is a string. float rides along as a
convenience for charting and is explicitly lossy. The exact value is
raw/numeric, and the notebook proves the float has already drifted.
import json
from auradefi.money.decimal_json import money_to_wire, quantity_to_wire
wire = quantity_to_wire(report.holdings[0].quantity)
assert wire["raw"] == "4878123456789012345678"
assert isinstance(wire["raw"], str)
assert wire["numeric"] == "4878.123456789012345678"
assert isinstance(wire["float"], float)
assert Decimal(str(wire["float"])) != Decimal(wire["numeric"]) # lossy on purpose
print(json.dumps(wire, indent=2))
print(json.dumps(money_to_wire(report.total_value)))
{
"raw": "4878123456789012345678",
"decimals": 18,
"numeric": "4878.123456789012345678",
"float": 4878.123456789012
}
{"amount": "18988784.99999872437900871726000", "currency": "USD"}
Incompleteness is first class, never silent
An asset the oracle cannot price is kept as a holding with
price=None, value=None and its id listed in report.unpriced. The
consumer can see exactly what the total omits (the data_quality
spirit). A half-priced holding, a price with no value, or the reverse,
is refused at construction, because that is how a portfolio silently
under-reports.
from auradefi.errors import ValidationError
from auradefi.portfolio.models import Holding, HoldingsReport
SHIB = "eip155:1/erc20:0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce"
partial = HoldingsReport.assemble(
ADDRESS,
CHAIN,
[
report.holdings[1], # DAI, priced
Holding(caip19=SHIB, symbol="SHIB", quantity=Quantity(10**24, 18), price=None, value=None),
],
AS_OF_MS,
)
assert partial.unpriced == (SHIB,)
assert partial.total_value == Money(Decimal("254961.75"), "USD") # the unpriced row is NOT a zero-guess
print("total:", partial.total_value, "| unpriced:", partial.unpriced)
try:
Holding(caip19=SHIB, symbol="SHIB", quantity=Quantity(1, 18), price=Money(Decimal("1"), "USD"), value=None)
except ValidationError as exc:
print("refused:", exc)
total: 254961.75000000000000000000000 USD | unpriced: ('eip155:1/erc20:0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce',)
refused: holding 'eip155:1/erc20:0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce': price and value must both be set or both be None, never one without the other
What this book did not show
Honest coverage (rule #10):
- one price oracle (DefiLlama), there is no fallback feed yet, so an
unpriced asset stays unpriced;
- no multicall, token balances cost one request each;
- the source is Etherscan V2 only; Bitcoin and Solana have their own
sources (PyBook 10), and no other EVM data provider is wired;
- prices are current only, there is no historical price service, so
the accounting book (PyBook 11) takes its marks from the caller.
Next: 06_tenancy: the same holdings, but for
somebody else's users.