Source code for orion_finance_sdk_py.costs.estimator

"""Estimate execution cost via mainnet adapters vs oracle fair value."""

from __future__ import annotations

import math
from decimal import ROUND_DOWN, Decimal
from typing import Literal

from web3 import Web3
from web3.exceptions import ContractLogicError

from orion_finance_sdk_py.contracts import (
    LiquidityOrchestrator,
    OrionConfig,
    PriceAdapterRegistry,
    load_contract_abi,
)
from orion_finance_sdk_py.costs.state_override import probe_erc20_overrides
from orion_finance_sdk_py.costs.types import ExecutionCost
from orion_finance_sdk_py.orion_config_env import MAINNET_CHAIN_ID
from orion_finance_sdk_py.utils import checksum_address, to_base_units

Side = Literal["buy", "sell"]


def looks_like_address(symbol: str) -> bool:
    """Return True if ``symbol`` looks like a 20-byte hex address."""
    return symbol.startswith("0x") and len(symbol) == 42


def _quantize_human_size(size: float, token_decimals: int) -> Decimal:
    """Floor ``size`` to ``token_decimals`` so float noise does not reject conversion."""
    quantum = Decimal(1).scaleb(-int(token_decimals))
    return Decimal(str(abs(size))).quantize(quantum, rounding=ROUND_DOWN)


def _require_mainnet(config: OrionConfig) -> None:
    if int(config.chain_id) != MAINNET_CHAIN_ID:
        raise ValueError(
            "Execution cost estimation requires mainnet Orion deployment "
            f"(active chain_id={config.chain_id}). Set CHAIN=mainnet and "
            "MAINNET_RPC_URL / MAINNET_ORION_CONFIG_ADDRESS."
        )


def _resolve_asset(config: OrionConfig, symbol: str) -> tuple[str, str]:
    """Return ``(label, checksummed_address)`` for a ticker or address."""
    raw = symbol.strip()
    if looks_like_address(raw):
        addr = checksum_address(raw)
        whitelist = [checksum_address(a) for a in config.whitelisted_assets]
        if addr not in whitelist:
            raise ValueError(
                f"Asset {addr} is not in the mainnet whitelisted investment universe."
            )
        names = list(config.whitelisted_asset_names)
        label = raw
        for i, a in enumerate(whitelist):
            if a == addr and i < len(names) and names[i]:
                label = names[i]
                break
        return label, addr

    needle = raw.upper()
    names = [n.strip() for n in config.whitelisted_asset_names]
    assets = [checksum_address(a) for a in config.whitelisted_assets]
    for name, addr in zip(names, assets, strict=False):
        if name and name.upper() == needle:
            return name, addr
    raise ValueError(
        f"Unknown symbol {symbol!r}. Pass a whitelisted ticker or mainnet address."
    )


def fair_underlying_amount(
    *,
    shares: int,
    price: int,
    price_adapter_decimals: int,
    token_decimals: int,
    underlying_decimals: int,
) -> int:
    """Oracle mark of ``shares`` in underlying base units (PIT TVL scaling)."""
    if shares < 0 or price < 0:
        raise ValueError("shares and price must be non-negative")
    price_scale = 10**price_adapter_decimals
    token_scale = 10**token_decimals
    underlying_scale = 10**underlying_decimals
    return (int(shares) * int(price) * underlying_scale) // (price_scale * token_scale)


def cost_pct(execution: int, fair: int, *, side: Side = "buy") -> float:
    """Positive means worse than oracle for the given ``side``."""
    if fair <= 0:
        raise ValueError("fair_underlying must be positive to compute cost_pct")
    if side == "buy":
        return (execution - fair) / fair
    if side == "sell":
        return (fair - execution) / fair
    raise ValueError("side must be 'buy' or 'sell'")


def _normalize_side(side: str) -> Side:
    s = str(side).strip().lower()
    if s == "buy":
        return "buy"
    if s == "sell":
        return "sell"
    raise ValueError("side must be 'buy' or 'sell'")


[docs] class ExecutionCostEstimator: """Manager-facing execution cost estimator. Compares mainnet adapter quotes to price-adapter oracle fair value. Buys use ``previewBuy``; sells simulate ``sell`` with ERC-20 state overrides. Requires ``CHAIN=mainnet``, ``MAINNET_RPC_URL``, and ``MAINNET_ORION_CONFIG_ADDRESS``. """ def __init__(self) -> None: """Bind OrionConfig / LO / registry on the active (mainnet) chain.""" self.config = OrionConfig() _require_mainnet(self.config) self.lo = LiquidityOrchestrator() self.registry = PriceAdapterRegistry() self.w3: Web3 = self.config.w3
[docs] def get_cost( self, symbol: str, size: float, *, side: Side | str = "buy", netting_eta: float = 0.0, block: int | None = None, shares: int | None = None, ) -> ExecutionCost: """Estimate execution cost in human asset units. Args: symbol: Whitelisted ticker or mainnet asset address. size: Positive human units of the risk asset. Ignored when ``shares`` is set. The protocol **underlying** (numeraire) is a no-op: ``cost_pct`` is always ``0``. side: ``"buy"`` (default) or ``"sell"``. netting_eta: Fraction of the nominal size that is internally netted. The adapter quote uses ``(1 - eta) * size``; ``cost_pct`` is that of the residual swap, not scaled by ``(1 - eta)``. block: Optional historical block for eth_call / getPrice. shares: Optional raw ERC-20 units for the residual swap; overrides ``swap_size`` when provided. """ side_n = _normalize_side(side) size_f = float(size) if not math.isfinite(size_f) or size_f <= 0: raise ValueError("size must be a positive finite number") if not 0.0 <= float(netting_eta) <= 1.0: raise ValueError("netting_eta must be in [0, 1]") swap_size = (1.0 - float(netting_eta)) * size_f label, asset = _resolve_asset(self.config, symbol) token_decimals = int(self.config.token_decimals(asset)) if swap_size == 0 and shares is None: return ExecutionCost( symbol=label, asset=asset, size=size_f, netting_eta=float(netting_eta), swap_size=0.0, shares=0, cost_pct=0.0, execution_underlying=0, fair_underlying=0, price=0, execution_adapter="", block=block, side=side_n, ) if shares is None: human_q = _quantize_human_size(swap_size, token_decimals) if human_q <= 0: shares_i = 0 swap_size = 0.0 else: shares_i = to_base_units(human_q, token_decimals) swap_size = float(human_q) else: if isinstance(shares, bool) or not isinstance(shares, int): raise TypeError("shares must be an int of raw token units") shares_i = shares if shares_i <= 0: raise ValueError("shares must be positive") swap_size = shares_i / (10**token_decimals) if shares_i <= 0: return ExecutionCost( symbol=label, asset=asset, size=size_f, netting_eta=float(netting_eta), swap_size=float(swap_size), shares=0, cost_pct=0.0, execution_underlying=0, fair_underlying=0, price=0, execution_adapter="", block=block, side=side_n, ) underlying = checksum_address(self.config.underlying_asset) # Numeraire: trading underlying for underlying is a no-op onchain. if asset.lower() == underlying.lower(): price = int(self.registry.get_price(asset, block=block)) return ExecutionCost( symbol=label, asset=asset, size=size_f, netting_eta=float(netting_eta), swap_size=float(swap_size), shares=shares_i, cost_pct=0.0, execution_underlying=shares_i, fair_underlying=shares_i, price=price, execution_adapter="", block=block, side=side_n, ) adapter = self.lo.execution_adapter_of(asset, block=block) price = int(self.registry.get_price(asset, block=block)) underlying_decimals = int(self.config.token_decimals(underlying)) fair = fair_underlying_amount( shares=shares_i, price=price, price_adapter_decimals=int(self.registry.price_adapter_decimals), token_decimals=token_decimals, underlying_decimals=underlying_decimals, ) if fair <= 0: raise ValueError( f"Oracle fair value is zero for {label} ({asset}); cannot compute cost." ) if side_n == "buy": execution = self._preview_buy(adapter, asset, shares_i, block=block) else: execution = self._simulate_sell(adapter, asset, shares_i, block=block) return ExecutionCost( symbol=label, asset=asset, size=size_f, netting_eta=float(netting_eta), swap_size=float(swap_size), shares=shares_i, cost_pct=cost_pct(int(execution), int(fair), side=side_n), execution_underlying=int(execution), fair_underlying=int(fair), price=price, execution_adapter=adapter, block=block, side=side_n, )
def _adapter_contract(self, adapter: str): return self.w3.eth.contract( address=checksum_address(adapter), abi=load_contract_abi("IExecutionAdapter"), ) def _preview_buy( self, adapter: str, asset: str, shares: int, *, block: int | None, ) -> int: contract = self._adapter_contract(adapter) fn = contract.functions.previewBuy(checksum_address(asset), int(shares)) try: if block is None: return int(fn.call()) return int(fn.call(block_identifier=block)) except ContractLogicError as exc: raise RuntimeError( f"previewBuy reverted for asset {asset} on adapter {adapter}: {exc}" ) from exc def _simulate_sell( self, adapter: str, asset: str, shares: int, *, block: int | None, ) -> int: """eth_call ``sell`` with ERC-20 balance/allowance state overrides. Live sells are invoked by the LiquidityOrchestrator, so the call uses ``from=LO`` and overrides LO's asset balance and allowance to the adapter. """ lo_addr = checksum_address(self.config.liquidity_orchestrator) adapter_c = checksum_address(adapter) asset_c = checksum_address(asset) overrides = probe_erc20_overrides( self.w3, asset_c, holder=lo_addr, spender=adapter_c, amount=int(shares), block=block, ) contract = self._adapter_contract(adapter_c) fn = contract.functions.sell(asset_c, int(shares)) try: return int( fn.call( {"from": lo_addr}, block_identifier=block if block is not None else "latest", state_override=overrides, ) ) except Exception as exc: raise RuntimeError( f"sell simulation reverted for asset {asset_c} on adapter " f"{adapter_c}: {exc}" ) from exc
_DEFAULT_ESTIMATOR: ExecutionCostEstimator | None = None
[docs] def get_cost( symbol: str, size: float, *, side: Side | str = "buy", netting_eta: float = 0.0, block: int | None = None, shares: int | None = None, ) -> ExecutionCost: """Module-level wrapper around a process-default :class:`ExecutionCostEstimator`.""" global _DEFAULT_ESTIMATOR if _DEFAULT_ESTIMATOR is None: _DEFAULT_ESTIMATOR = ExecutionCostEstimator() return _DEFAULT_ESTIMATOR.get_cost( symbol, size, side=side, netting_eta=netting_eta, block=block, shares=shares, )