diff --git a/cli/commands/yield_cmd.py b/cli/commands/yield_cmd.py new file mode 100644 index 0000000..5702009 --- /dev/null +++ b/cli/commands/yield_cmd.py @@ -0,0 +1,414 @@ +"""hl yield — EVM yield discovery, ranking, and optimization. + +Surfaces the `yields/` package as a user- and agent-callable CLI group: + + hl yield scan [--chain ethereum|base|all] [--min-tvl] [--kind] [--source] + hl yield rank scan + net-APY ranking under OptimizerConstraints + hl yield optimize --budget N greedy capital allocation (pure, no wallet) + +This is EVM on-chain yield (Ethereum + Base) — separate from Hyperliquid. + +Discovery / risk / allocation are delegated to `yields.aggregator`, +`yields.risk`, and `yields.optimizer`. All three subcommands here are +READ-ONLY — no wallet, no signing, no transactions. + +The execution surface from the source repo (`hl yield position` / `hl yield +route` / `hl yield rebalance`) is intentionally NOT ported yet: it needs the +EVM execution substrate (`common.evm.*` and `trading.dex.*`) which agent-cli +does not yet have. Once that substrate lands, those state-changing commands and +the on-chain adapters can be added as a follow-up — the read-only layer here is +already wired to light them up (see `yields.aggregator`). + +Every `yields.*` import is lazy inside a command body so the harness +`discover_tools()` registry walk and `hl --help` stay fast and cycle-free — +same discipline as the other `cli/commands/*` sub-apps. +""" +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path +from typing import Optional + +import typer + +yield_app = typer.Typer( + name="yield", + help="EVM yield — scan, rank, and optimize Ethereum + Base yield opportunities", + no_args_is_help=True, +) + + +def _boot_cli() -> None: + """Project-root + logging setup, mirroring the other CLI sub-apps.""" + project_root = str(Path(__file__).resolve().parent.parent.parent) + if project_root not in sys.path: + sys.path.insert(0, project_root) + logging.basicConfig( + level=logging.WARNING, + format="%(asctime)s %(name)-14s %(levelname)-5s %(message)s", + datefmt="%H:%M:%S", + ) + + +def _resolve_chains(chain: str): + """Map the `--chain` option onto a tuple of `yields.models.Chain`.""" + from yields.models import Chain + + c = (chain or "all").strip().lower() + if c in ("all", ""): + return (Chain.ethereum, Chain.base) + try: + return (Chain(c),) + except ValueError: + typer.echo( + f"Error: unknown chain '{chain}'. Use ethereum, base, or all.", + err=True, + ) + raise typer.Exit(2) + + +def _parse_kind(kind: Optional[str]): + """Map an optional `--kind` string onto a `YieldKind`, or None.""" + if not kind: + return None + from yields.models import YieldKind + + try: + return YieldKind(kind.strip().lower()) + except ValueError: + typer.echo( + f"Error: unknown kind '{kind}'. Use lending, staking, vault, or lp.", + err=True, + ) + raise typer.Exit(2) + + +def _build_constraints( + *, + min_net_apy: float, + max_risk: float, + max_protocol_pct: float | None = None, + max_positions: int | None = None, + min_ticket: float | None = None, + gas_cost: float, + holding_days: float, + risk_lambda: float, + exclude: list[str] | None, + kind=None, +): + """Assemble an `OptimizerConstraints` from CLI options. + + Only the fields explicitly supported by a given command are passed; the + rest fall back to `OptimizerConstraints`' conservative defaults. + """ + from yields.optimizer import OptimizerConstraints + + kwargs: dict = { + "min_net_apy": min_net_apy, + "max_risk_score": max_risk, + "gas_cost_usd": gas_cost, + "holding_period_days": holding_days, + "risk_lambda": risk_lambda, + "excluded_protocols": tuple(exclude or ()), + } + if max_protocol_pct is not None: + kwargs["max_protocol_pct"] = max_protocol_pct + if max_positions is not None: + kwargs["max_positions"] = max_positions + if min_ticket is not None: + kwargs["min_ticket_usd"] = min_ticket + if kind is not None: + kwargs["allowed_kinds"] = (kind,) + return OptimizerConstraints(**kwargs) + + +def _filter_opps(opportunities, *, min_tvl: float, kind, source: str): + """Apply the `scan`-style post-filters (min TVL, kind, source tier).""" + from yields.models import SourceTier + + out = list(opportunities) + if min_tvl > 0: + out = [o for o in out if o.tvl_usd >= min_tvl] + if kind is not None: + out = [o for o in out if o.kind == kind] + src = (source or "all").strip().lower() + if src == "onchain": + out = [o for o in out if o.source_tier == SourceTier.onchain] + elif src == "defillama": + out = [o for o in out if o.source_tier == SourceTier.defillama] + return out + + +def _opp_json(opp) -> dict: + """A compact, stable JSON shape for one opportunity (used by `--json`).""" + return { + "id": opp.id, + "protocol": opp.protocol, + "chain": opp.chain.value, + "kind": opp.kind.value, + "apy_base": opp.apy_base, + "apy_reward": opp.apy_reward, + "apy_total": opp.apy_total, + "tvl_usd": opp.tvl_usd, + "risk_score": opp.risk_score, + "source_tier": opp.source_tier.value, + "has_onchain_adapter": opp.has_onchain_adapter, + "pool_address": opp.pool_address, + "underlying": [t.symbol for t in opp.underlying], + } + + +# ─── scan ──────────────────────────────────────────────────────────────────── + + +@yield_app.command("scan") +def scan_cmd( + chain: str = typer.Option( + "all", "--chain", help="Chain to scan: ethereum, base, or all" + ), + min_tvl: float = typer.Option( + 0.0, "--min-tvl", help="Drop opportunities below this TVL (USD)" + ), + kind: Optional[str] = typer.Option( + None, "--kind", help="Filter by kind: lending, staking, vault, lp" + ), + source: str = typer.Option( + "all", "--source", help="Discovery tier: all, defillama, onchain" + ), + json_out: bool = typer.Option( + False, "--json/--text", help="Emit JSON instead of a table" + ), +): + """Scan Ethereum + Base for yield opportunities across every discovery source. + + Runs the two-tier aggregator (DeFiLlama discovery + the curated on-chain + adapters, when present), merges duplicate rows, risk-scores each, and + renders the result ordered by gross APY. Read-only — no wallet, no signing. + """ + _boot_cli() + + from yields import aggregator + from cli.display import yield_table + + chains = _resolve_chains(chain) + parsed_kind = _parse_kind(kind) + + opps = aggregator.aggregate(chains) + opps = _filter_opps(opps, min_tvl=min_tvl, kind=parsed_kind, source=source) + + if json_out: + typer.echo(json.dumps([_opp_json(o) for o in opps], indent=2)) + return + + if not opps: + typer.echo("No yield opportunities matched the filters.") + return + typer.echo(yield_table(opps)) + + +# ─── aggregate (alias of scan) ─────────────────────────────────────────────── + + +@yield_app.command("aggregate") +def aggregate_cmd( + chain: str = typer.Option( + "all", "--chain", help="Chain to scan: ethereum, base, or all" + ), + min_tvl: float = typer.Option( + 0.0, "--min-tvl", help="Drop opportunities below this TVL (USD)" + ), + kind: Optional[str] = typer.Option( + None, "--kind", help="Filter by kind: lending, staking, vault, lp" + ), + source: str = typer.Option( + "all", "--source", help="Discovery tier: all, defillama, onchain" + ), + json_out: bool = typer.Option( + False, "--json/--text", help="Emit JSON instead of a table" + ), +): + """Aggregate Ethereum + Base yield opportunities (alias of `scan`). + + Identical to `scan`: runs the two-tier aggregator, merges duplicate rows, + risk-scores each, and renders ordered by gross APY. Read-only. + """ + scan_cmd( + chain=chain, min_tvl=min_tvl, kind=kind, source=source, json_out=json_out + ) + + +# ─── rank ──────────────────────────────────────────────────────────────────── + + +@yield_app.command("rank") +def rank_cmd( + chain: str = typer.Option( + "all", "--chain", help="Chain to scan: ethereum, base, or all" + ), + min_tvl: float = typer.Option( + 0.0, "--min-tvl", help="Drop opportunities below this TVL (USD)" + ), + kind: Optional[str] = typer.Option( + None, "--kind", help="Filter by kind: lending, staking, vault, lp" + ), + source: str = typer.Option( + "all", "--source", help="Discovery tier: all, defillama, onchain" + ), + min_net_apy: float = typer.Option( + 0.0, "--min-net-apy", help="Drop opportunities below this net APY (fraction)" + ), + max_risk: float = typer.Option( + 1.0, "--max-risk", help="Drop opportunities above this risk score (0..1)" + ), + gas_cost: float = typer.Option( + 15.0, "--gas-cost", help="Amortized entry+exit gas per position (USD)" + ), + holding_days: float = typer.Option( + 30.0, "--holding-days", help="Holding period for gas amortization (days)" + ), + risk_lambda: float = typer.Option( + 0.15, "--risk-lambda", help="APY penalty per unit of risk score" + ), + notional: float = typer.Option( + 10_000.0, "--notional", help="Ticket size used to amortize gas in the ranking" + ), + exclude: list[str] = typer.Option( + [], "--exclude", help="Protocol slug to exclude (repeatable)" + ), + json_out: bool = typer.Option( + False, "--json/--text", help="Emit JSON instead of a table" + ), +): + """Scan, then rank yield opportunities by risk- and gas-adjusted net APY. + + `net_apy = apy_base + apy_reward - gas_amortized - risk_lambda*risk`. The + ranking is the pure `yields.optimizer.rank()` — deterministic, no wallet. + """ + _boot_cli() + + from yields import aggregator + from yields.optimizer import rank + from cli.display import yield_table + + chains = _resolve_chains(chain) + parsed_kind = _parse_kind(kind) + cons = _build_constraints( + min_net_apy=min_net_apy, + max_risk=max_risk, + gas_cost=gas_cost, + holding_days=holding_days, + risk_lambda=risk_lambda, + exclude=exclude, + kind=parsed_kind, + ) + + opps = aggregator.aggregate(chains) + opps = _filter_opps(opps, min_tvl=min_tvl, kind=parsed_kind, source=source) + ranked = rank(opps, cons, notional_usd=notional) + + if json_out: + payload = [ + {**_opp_json(opp), "net_apy": value} for opp, value in ranked + ] + typer.echo(json.dumps(payload, indent=2)) + return + + if not ranked: + typer.echo("No yield opportunities passed the ranking filters.") + return + ordered = [opp for opp, _ in ranked] + net_apy_by_id = {opp.id: value for opp, value in ranked} + typer.echo(yield_table(ordered, net_apy_by_id=net_apy_by_id)) + + +# ─── optimize ──────────────────────────────────────────────────────────────── + + +@yield_app.command("optimize") +def optimize_cmd( + budget: float = typer.Option( + ..., "--budget", help="Total capital to allocate (USD) — required" + ), + chain: str = typer.Option( + "all", "--chain", help="Chain to scan: ethereum, base, or all" + ), + asset: str = typer.Option( + "USDC", "--asset", help="The asset being allocated (display label)" + ), + min_tvl: float = typer.Option( + 0.0, "--min-tvl", help="Drop opportunities below this TVL (USD)" + ), + kind: Optional[str] = typer.Option( + None, "--kind", help="Filter by kind: lending, staking, vault, lp" + ), + source: str = typer.Option( + "all", "--source", help="Discovery tier: all, defillama, onchain" + ), + max_risk: float = typer.Option( + 1.0, "--max-risk", help="Drop opportunities above this risk score (0..1)" + ), + max_protocol_pct: float = typer.Option( + 0.40, "--max-protocol-pct", help="Per-protocol cap as a fraction of budget" + ), + max_positions: int = typer.Option( + 6, "--max-positions", help="Maximum number of positions in the plan" + ), + min_net_apy: float = typer.Option( + 0.0, "--min-net-apy", help="Drop opportunities below this net APY (fraction)" + ), + min_ticket: float = typer.Option( + 500.0, "--min-ticket", help="Minimum allocation per position (USD)" + ), + gas_cost: float = typer.Option( + 15.0, "--gas-cost", help="Amortized entry+exit gas per position (USD)" + ), + holding_days: float = typer.Option( + 30.0, "--holding-days", help="Holding period for gas amortization (days)" + ), + risk_lambda: float = typer.Option( + 0.15, "--risk-lambda", help="APY penalty per unit of risk score" + ), + exclude: list[str] = typer.Option( + [], "--exclude", help="Protocol slug to exclude (repeatable)" + ), + json_out: bool = typer.Option( + False, "--json/--text", help="Emit JSON instead of a table" + ), +): + """Greedily allocate a budget across yield opportunities under constraints. + + Walks the net-APY ranking and fills the highest first, subject to a + per-protocol concentration cap, a position-count cap, and a minimum ticket. + Pure — no wallet, no signing. Prints the `AllocationPlan`. + """ + _boot_cli() + + from yields import aggregator + from yields.optimizer import optimize + from cli.display import allocation_plan_block + + chains = _resolve_chains(chain) + parsed_kind = _parse_kind(kind) + cons = _build_constraints( + min_net_apy=min_net_apy, + max_risk=max_risk, + max_protocol_pct=max_protocol_pct, + max_positions=max_positions, + min_ticket=min_ticket, + gas_cost=gas_cost, + holding_days=holding_days, + risk_lambda=risk_lambda, + exclude=exclude, + kind=parsed_kind, + ) + + opps = aggregator.aggregate(chains) + opps = _filter_opps(opps, min_tvl=min_tvl, kind=parsed_kind, source=source) + plan = optimize(opps, budget, cons, asset=asset) + + if json_out: + typer.echo(plan.model_dump_json(indent=2)) + return + typer.echo(allocation_plan_block(plan)) diff --git a/cli/display.py b/cli/display.py index e908374..52234fa 100644 --- a/cli/display.py +++ b/cli/display.py @@ -183,3 +183,124 @@ def shutdown_summary( f"PnL: {pnl_c}${_sign(round(total_pnl, 2))}{RESET}\n" f"Runtime: {int(elapsed_s)}s" ) + + +# ─── EVM yield (hl yield) ──────────────────────────────────────────────────── + + +def _tvl_short(tvl_usd: float) -> str: + """Compact TVL: $1.2B / $340M / $5.0M / $0 (unknown).""" + if tvl_usd <= 0: + return f"{DIM}—{RESET}" + if tvl_usd >= 1e9: + return f"${tvl_usd / 1e9:.1f}B" + if tvl_usd >= 1e6: + return f"${tvl_usd / 1e6:.0f}M" + if tvl_usd >= 1e3: + return f"${tvl_usd / 1e3:.0f}K" + return f"${tvl_usd:,.0f}" + + +def _risk_color(risk: float) -> str: + """Risk score (0..1, lower safer) -> a colour band.""" + if risk <= 0.25: + return GREEN + if risk <= 0.50: + return YELLOW + return RED + + +def yield_table(opportunities: List[Any], *, net_apy_by_id: Optional[Dict[str, float]] = None) -> str: + """Format yield opportunities for `hl yield scan` / `rank`. + + One row per opportunity: protocol, chain, kind, base/reward APY, TVL, risk + score, and a routable marker (an on-chain adapter exists for it). When + ``net_apy_by_id`` is supplied (the `rank` path), a net-APY column is added. + """ + show_net = net_apy_by_id is not None + header = ( + f" {'Protocol':<16} {'Chain':<9} {'Kind':<9} " + f"{'Base APY':>9} {'Reward':>9} {'TVL':>9} {'Risk':>6}" + ) + if show_net: + header += f" {'Net APY':>9}" + header += " Route" + + lines = [ + f"{BOLD}=== EVM Yield — {len(opportunities)} opportunities ==={RESET}", + header, + " " + "-" * (len(header) + 4), + ] + for opp in opportunities: + risk = float(getattr(opp, "risk_score", 0.0) or 0.0) + risk_c = _risk_color(risk) + routable = bool(getattr(opp, "has_onchain_adapter", False)) + route_mark = f"{GREEN}yes{RESET}" if routable else f"{DIM}no{RESET}" + row = ( + f" {CYAN}{opp.protocol:<16}{RESET} {opp.chain.value:<9} " + f"{opp.kind.value:<9} " + f"{opp.apy_base * 100:>8.2f}% {opp.apy_reward * 100:>8.2f}% " + f"{_tvl_short(opp.tvl_usd):>9} {risk_c}{risk:>6.2f}{RESET}" + ) + if show_net: + net = net_apy_by_id.get(opp.id, 0.0) + net_c = GREEN if net > 0 else RED + row += f" {net_c}{net * 100:>8.2f}%{RESET}" + row += f" {route_mark}" + lines.append(row) + + lines.append("") + lines.append( + f"{DIM}APYs are fractions of notional / year. Risk 0..1 (lower safer). " + f"Route=yes means an on-chain adapter can execute it.{RESET}" + ) + return "\n".join(lines) + + +def allocation_plan_block(plan: Any) -> str: + """Format an `AllocationPlan` for `hl yield optimize`. + + Shows the per-opportunity entries, the blended net APY / risk, the + unallocated remainder, and the optimizer's human-readable notes. + """ + lines = [ + f"{BOLD}=== Yield allocation — ${plan.budget_usd:,.0f} {plan.asset} ==={RESET}", + ] + if not plan.entries: + lines.append(f"{DIM}No allocation produced.{RESET}") + for note in plan.notes: + lines.append(f" {YELLOW}• {note}{RESET}") + return "\n".join(lines) + + lines.append( + f" {'Protocol':<16} {'Chain':<9} {'Amount':>14} " + f"{'Net APY':>10} {'Risk':>7}" + ) + lines.append(" " + "-" * 60) + for e in plan.entries: + risk_c = _risk_color(e.risk_score) + net_c = GREEN if e.expected_net_apy > 0 else RED + lines.append( + f" {CYAN}{e.protocol:<16}{RESET} {e.chain.value:<9} " + f"${e.amount_usd:>13,.0f} " + f"{net_c}{e.expected_net_apy * 100:>9.2f}%{RESET} " + f"{risk_c}{e.risk_score:>7.2f}{RESET}" + ) + + allocated = sum(e.amount_usd for e in plan.entries) + blended_c = GREEN if plan.blended_net_apy > 0 else RED + lines.extend([ + " " + "-" * 60, + f" {'Allocated':<16} {'':<9} ${allocated:>13,.0f}", + f" {'Unallocated':<16} {'':<9} " + f"{DIM}${plan.unallocated_usd:>13,.0f}{RESET}", + "", + f" Blended net APY: {blended_c}{plan.blended_net_apy * 100:.2f}%{RESET}", + f" Blended risk: {_risk_color(plan.blended_risk)}{plan.blended_risk:.2f}{RESET}", + ]) + if plan.notes: + lines.append("") + lines.append(f"{BOLD}Notes:{RESET}") + for note in plan.notes: + lines.append(f" {YELLOW}• {note}{RESET}") + return "\n".join(lines) diff --git a/cli/main.py b/cli/main.py index 6253f07..f4a1f71 100644 --- a/cli/main.py +++ b/cli/main.py @@ -35,6 +35,7 @@ from cli.commands.skills import skills_app from cli.commands.journal import journal_app from cli.commands.keys import keys_app +from cli.commands.yield_cmd import yield_app app.command("run", help="Start autonomous trading with a strategy")(run_cmd) app.command("status", help="Show positions, PnL, and risk state")(status_cmd) @@ -53,6 +54,7 @@ app.add_typer(skills_app, name="skills", help="Skill discovery and registry") app.add_typer(journal_app, name="journal", help="Trade journal — structured position records with reasoning") app.add_typer(keys_app, name="keys", help="Unified key management across backends") +app.add_typer(yield_app, name="yield", help="EVM yield — scan, rank, optimize Ethereum + Base yield (read-only)") def main(): diff --git a/yields/__init__.py b/yields/__init__.py new file mode 100644 index 0000000..b7e758a --- /dev/null +++ b/yields/__init__.py @@ -0,0 +1,11 @@ +"""yields — EVM yield aggregation, optimization, and routing for nunchi-cli. + +Two-tier discovery: the DeFiLlama API (broad, read-only) plus curated on-chain +adapters (executable). A pure optimizer ranks and allocates; a router builds, +simulates, and executes deposit/withdraw routes. Exposed via the `nunchi yield` +CLI group and auto-discovered by the Hermes / OpenClaw harnesses. + +The package is named `yields` (plural) deliberately — `yield` is a Python +keyword, so `import yield` would be a SyntaxError. Import submodules directly, +e.g. `from yields.models import YieldOpportunity`. +""" diff --git a/yields/aggregator.py b/yields/aggregator.py new file mode 100644 index 0000000..728dcbc --- /dev/null +++ b/yields/aggregator.py @@ -0,0 +1,152 @@ +"""yields.aggregator — pull every source, normalize, dedup, risk-score. + +Two-tier discovery: the broad DeFiLlama source plus the curated on-chain +adapters. The aggregator merges rows that describe the same pool: + +* on-chain data wins the execution-critical fields (it is authoritative and + reads the current block) — `pool_address`, `receipt_token`, `apy_base`, + `has_onchain_adapter`; +* DeFiLlama wins `tvl_usd` (it aggregates global TVL better) and `apy_reward` + (incentive APYs are hard to read on-chain). + +Dedup is on a STRICT composite key — never a fuzzy name match, never across +chains. A row that cannot be confidently keyed is kept un-merged: a duplicate +display row is a far smaller harm than a wrong merge that mis-routes a deposit. + +This module DOES do I/O (it calls the sources). The pure decision modules are +`yields.risk` and `yields.optimizer`. + +NOTE (agent-cli port): the on-chain (Tier 2) adapters need the EVM execution +substrate (`common.evm.*`, `trading.dex.*`) which is not yet present in +agent-cli, so the import below is optional. When the substrate lands, the +``yields.sources.onchain`` package becomes importable and the on-chain adapters +light up automatically with no further change here. Until then the aggregator +runs the DeFiLlama (Tier 1) read-only source only. +""" +from __future__ import annotations + +import logging +from typing import Optional, Sequence + +from yields.models import Chain, SourceTier, YieldOpportunity +from yields.risk import DEFAULT_RISK_CONFIG, RiskConfig, score_opportunity +from yields.sources.base import YieldSource +from yields.sources.defillama import DefiLlamaSource + +try: # on-chain adapters require the EVM execution substrate (see module docstring) + from yields.sources.onchain import ONCHAIN_ADAPTERS +except ImportError: # substrate absent in this build — Tier 1 (DeFiLlama) only + ONCHAIN_ADAPTERS: list[type[YieldSource]] = [] + +_log = logging.getLogger(__name__) + +# DeFiLlama project slugs that denote a protocol modeled here under a different +# canonical slug. Best-effort — an un-normalized slug merely yields a duplicate +# display row, never a wrong merge. +_PROTOCOL_ALIASES: dict[str, str] = { + "aave": "aave-v3", + "aave-v2": "aave-v3", + "compound": "compound-v3", + "makerdao": "sky", + "spark": "sky", + "sky-lending": "sky", + "lido-steth": "lido", +} + +_DEFAULT_TVL_FLOOR_USD = 100_000.0 + + +def normalize_protocol(slug: str) -> str: + """Collapse a source's protocol slug onto this repo's canonical slug.""" + s = (slug or "").strip().lower() + return _PROTOCOL_ALIASES.get(s, s) + + +def default_sources() -> list[YieldSource]: + """The standard source set — DeFiLlama plus every on-chain adapter.""" + sources: list[YieldSource] = [DefiLlamaSource()] + sources.extend(adapter_cls() for adapter_cls in ONCHAIN_ADAPTERS) + return sources + + +def collect( + chains: Sequence[Chain], + *, + sources: Optional[Sequence[YieldSource]] = None, +) -> list[YieldOpportunity]: + """Run every source's `discover()` and return the flat, un-merged list. + + Each source is already defensive (returns `[]` on its own failure); the + extra try/except here is belt-and-suspenders so one bad source can never + break a scan. + """ + srcs = list(sources) if sources is not None else default_sources() + out: list[YieldOpportunity] = [] + for src in srcs: + try: + out.extend(src.discover(chains)) + except Exception as exc: # noqa: BLE001 - a source must never break a scan + _log.warning("yield source %s failed: %s", getattr(src, "name", src), exc) + return out + + +def _merge_key(opp: YieldOpportunity) -> tuple: + """Strict composite identity for dedup: chain + canonical protocol + + underlying symbol set + kind. Never crosses chains; never a fuzzy match.""" + symbols = frozenset( + (t.symbol or "").strip().upper() for t in opp.underlying if t.symbol + ) + return (opp.chain.value, normalize_protocol(opp.protocol), symbols, opp.kind.value) + + +def _merge_pair(a: YieldOpportunity, b: YieldOpportunity) -> YieldOpportunity: + """Merge two opportunities for the same pool. + + The on-chain row (if either is on-chain) is authoritative for execution; + the other contributes TVL and reward APY. + """ + onchain, other = a, b + if a.source_tier != SourceTier.onchain and b.source_tier == SourceTier.onchain: + onchain, other = b, a + + merged = onchain.model_copy(deep=True) + if other.tvl_usd > 0: # DeFiLlama aggregates TVL better + merged.tvl_usd = other.tvl_usd + if onchain.source_tier == SourceTier.onchain and onchain.apy_reward == 0.0: + merged.apy_reward = other.apy_reward # on-chain rarely reads incentives + merged.raw = {**other.raw, **onchain.raw, "merged_from": [a.id, b.id]} + return merged + + +def aggregate( + chains: Sequence[Chain], + *, + sources: Optional[Sequence[YieldSource]] = None, + risk_cfg: RiskConfig = DEFAULT_RISK_CONFIG, + tvl_floor_usd: float = _DEFAULT_TVL_FLOOR_USD, +) -> list[YieldOpportunity]: + """Collect, drop dust, dedup/merge, risk-score; return sorted by gross APY.""" + raw = collect(chains, sources=sources) + merged: dict[tuple, YieldOpportunity] = {} + unkeyable: list[YieldOpportunity] = [] + + for opp in raw: + # drop dust pools — a tiny TVL with a huge APY distorts ranking. + # tvl_usd == 0 means "unknown" (on-chain adapters leave it 0), not "tiny". + if opp.tvl_usd and opp.tvl_usd < tvl_floor_usd: + continue + key = _merge_key(opp) + if not key[2]: # no underlying symbols — cannot be safely keyed + unkeyable.append(opp) + continue + if key in merged: + merged[key] = _merge_pair(merged[key], opp) + _log.debug("merged duplicate yield row: %s", key) + else: + merged[key] = opp + + result = list(merged.values()) + unkeyable + for opp in result: + opp.risk_score = score_opportunity(opp, risk_cfg) + result.sort(key=lambda o: o.apy_total, reverse=True) + return result diff --git a/yields/models.py b/yields/models.py new file mode 100644 index 0000000..51e4f63 --- /dev/null +++ b/yields/models.py @@ -0,0 +1,157 @@ +"""Data models for the yields package — pydantic v2 DTOs. + +Conventions: +- token amounts in raw integer base units (wei-like) are ``int`` — no float drift; +- USD values and APYs are ``float`` (fractions: 0.043 == 4.3%); +- these models carry data only — no network or chain I/O — so the pure optimizer + and `risk.py` that consume them stay trivially portable. +""" +from __future__ import annotations + +from enum import Enum +from typing import Any, Optional, Sequence + +from pydantic import BaseModel, Field + + +class Chain(str, Enum): + ethereum = "ethereum" + base = "base" + + +class YieldKind(str, Enum): + lending = "lending" # supply to a money market (Aave, Compound, Moonwell) + staking = "staking" # liquid staking (Lido) + vault = "vault" # ERC-4626 / savings vault (Sky sDAI / sUSDS) + lp = "lp" # AMM liquidity position + other = "other" + + +class SourceTier(str, Enum): + defillama = "defillama" # broad discovery, read-only + onchain = "onchain" # backed by an executable on-chain adapter + + +def canonical_id( + chain: str, + protocol: str, + underlying_addresses: Sequence[str], + pool_address: Optional[str], + kind: str, +) -> str: + """Deterministic dedup key for a YieldOpportunity. + + The same pool seen from two sources resolves to the same id. Keyed on + chain + normalized protocol slug + the sorted underlying token addresses + + (the pool address if known, else the kind). Pass ``chain``/``kind`` as the + enum *values* (strings). + """ + under = ",".join(sorted((a or "").lower() for a in underlying_addresses if a)) + tail = (pool_address or "").lower() or (kind or "") + return f"{chain}:{protocol}:{under}:{tail}" + + +class TokenRef(BaseModel): + """A reference to an ERC20 token. ``address``/``decimals`` may be absent on + DeFiLlama-only rows (the API gives symbols, not addresses).""" + + symbol: str + chain: Chain + address: Optional[str] = None # checksummed when known + decimals: Optional[int] = None + + +class YieldOpportunity(BaseModel): + """One yield opportunity, normalized across discovery sources.""" + + id: str # canonical_id(...) — the dedup key + protocol: str # normalized slug: "aave-v3", "lido", ... + chain: Chain + kind: YieldKind + pool_address: Optional[str] = None # the deposit-target contract (None for DeFiLlama-only) + underlying: list[TokenRef] = Field(default_factory=list) + receipt_token: Optional[TokenRef] = None # aToken / wstETH / sDAI / mToken + apy_base: float = 0.0 # organic supply/staking APY (fraction) + apy_reward: float = 0.0 # incentive APY (fraction; volatile) + tvl_usd: float = 0.0 + source_tier: SourceTier + has_onchain_adapter: bool = False # True => routable for execution + risk_score: float = 0.0 # 0..1 (lower = safer); filled by risk.py in Phase 3 + fetched_at_ms: int = 0 # when this row's data was sourced + raw: dict[str, Any] = Field(default_factory=dict) # provenance / original payload + + @property + def apy_total(self) -> float: + """Gross APY before cost and risk adjustment.""" + return self.apy_base + self.apy_reward + + +class Position(BaseModel): + """A wallet's open position in a yield opportunity.""" + + opportunity_id: str + protocol: str + chain: Chain + wallet: str + receipt_token: Optional[TokenRef] = None + receipt_balance: int = 0 # raw base units of the receipt token + underlying_value_usd: float = 0.0 + accrued_reward_usd: float = 0.0 + + +class RouteStep(BaseModel): + """One unsigned step of an execution route. Built by sources / the router; + the calldata is handed to ``common.evm.TxExecutor`` — never sent here.""" + + kind: str # "approve" | "swap" | "deposit" | "withdraw" + chain: Chain + target: str # contract address the calldata is sent to + description: str = "" + calldata: str = "0x" # 0x-hex + value_wei: int = 0 # native value attached to the call + gas_estimate: Optional[int] = None + simulated_ok: Optional[bool] = None + sim_error: Optional[str] = None + + +class RouteResult(BaseModel): + """The router's output — an assembled (and possibly executed) route. + + Carries the ordered ``RouteStep``s plus the lifecycle flags the CLI / a + harness reads: whether the route was simulated, whether it was broadcast, + the resulting on-chain tx hashes, and any error. ``ok=False`` with a set + ``error`` marks a non-executable route (e.g. a DeFiLlama-only opportunity + with no on-chain adapter).""" + + opportunity_id: str + action: str # "deposit" | "withdraw" + steps: list[RouteStep] = Field(default_factory=list) + simulated: bool = False # preview() ran a dry-run over the steps + broadcast: bool = False # the steps were sent on-chain + tx_hashes: list[str] = Field(default_factory=list) + ok: bool = True + error: Optional[str] = None + + +class AllocationEntry(BaseModel): + """One line of an allocation plan — capital assigned to one opportunity.""" + + opportunity_id: str + protocol: str + chain: Chain + amount_usd: float + expected_net_apy: float # APY after gas amortization + risk haircut + risk_score: float + + +class AllocationPlan(BaseModel): + """The optimizer's output — how to spread a budget across opportunities.""" + + budget_usd: float + asset: str = "USDC" + entries: list[AllocationEntry] = Field(default_factory=list) + unallocated_usd: float = 0.0 + blended_net_apy: float = 0.0 + blended_risk: float = 0.0 + constraints: dict[str, Any] = Field(default_factory=dict) + notes: list[str] = Field(default_factory=list) # human-readable: which constraints bound diff --git a/yields/optimizer.py b/yields/optimizer.py new file mode 100644 index 0000000..8f511d1 --- /dev/null +++ b/yields/optimizer.py @@ -0,0 +1,213 @@ +"""yields.optimizer — pure ranking and capital allocation. + +PURE module: inputs to outputs, no network / chain / clock / env. It imports +only the models and the stdlib, so `/propagate-cli-to-fi` can port it to +TypeScript as a near-mechanical transliteration. + +Net APY of a position — the objective the optimizer maximizes:: + + net_apy = apy_base + apy_reward - gas_amortized - risk_haircut + + gas_amortized = (gas_cost_usd / ticket_usd) * (365 / holding_period_days) + risk_haircut = risk_lambda * risk_score + +Gas amortization makes a thin ticket spread over many pools unattractive (gas +eats it); the risk haircut discounts risky pools inside the ranking objective, +not merely as a post-filter. + +`optimize()` is greedy-with-constraints: rank by net APY, then fill the highest +first, subject to a per-protocol concentration cap, a position-count cap, and a +minimum ticket. v1 is deliberately greedy and explainable; a convex / +mean-variance upgrade is noted in the plan as v1.1. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from yields.models import ( + AllocationEntry, + AllocationPlan, + Chain, + YieldKind, + YieldOpportunity, +) + +_DEFAULT_NOTIONAL_USD = 10_000.0 + + +@dataclass(frozen=True) +class OptimizerConstraints: + """Knobs for `rank()` / `optimize()`. All have conservative defaults.""" + + min_net_apy: float = 0.0 + max_risk_score: float = 1.0 + max_protocol_pct: float = 0.40 # cap per protocol, as a fraction of budget + max_positions: int = 6 + min_ticket_usd: float = 500.0 # do not dust-allocate — gas would eat it + allowed_chains: tuple[Chain, ...] = (Chain.ethereum, Chain.base) + allowed_kinds: tuple[YieldKind, ...] | None = None + excluded_protocols: tuple[str, ...] = () + gas_cost_usd: float = 15.0 # amortized entry + exit gas per position + holding_period_days: float = 30.0 + risk_lambda: float = 0.15 # APY penalty per unit of risk score + # (risk 1.0 -> -15% APY; risk 0.3 -> -4.5%). Raise it for more risk-aversion, + # or use max_risk_score to hard-exclude. + + +DEFAULT_CONSTRAINTS = OptimizerConstraints() + + +def gas_amortized_apy(ticket_usd: float, cons: OptimizerConstraints) -> float: + """Annualized gas drag for a position of ``ticket_usd``.""" + if ticket_usd <= 0: + return float("inf") + return (cons.gas_cost_usd / ticket_usd) * (365.0 / cons.holding_period_days) + + +def net_apy( + opp: YieldOpportunity, ticket_usd: float, cons: OptimizerConstraints +) -> float: + """Risk- and cost-adjusted APY for holding ``opp`` at ``ticket_usd``.""" + gross = opp.apy_base + opp.apy_reward + return gross - gas_amortized_apy(ticket_usd, cons) - cons.risk_lambda * opp.risk_score + + +def _passes_filters(opp: YieldOpportunity, cons: OptimizerConstraints) -> bool: + if opp.chain not in cons.allowed_chains: + return False + if cons.allowed_kinds is not None and opp.kind not in cons.allowed_kinds: + return False + if opp.protocol.lower() in {p.lower() for p in cons.excluded_protocols}: + return False + if opp.risk_score > cons.max_risk_score: + return False + return True + + +def rank( + opportunities: list[YieldOpportunity], + cons: OptimizerConstraints = DEFAULT_CONSTRAINTS, + *, + notional_usd: float = _DEFAULT_NOTIONAL_USD, +) -> list[tuple[YieldOpportunity, float]]: + """Filter, then sort opportunities by net APY (descending). + + ``notional_usd`` sizes the gas-amortization term so the ordering is + comparable; `optimize()` passes budget / max_positions. + """ + scored: list[tuple[YieldOpportunity, float]] = [] + for opp in opportunities: + if not _passes_filters(opp, cons): + continue + value = net_apy(opp, notional_usd, cons) + if value < cons.min_net_apy: + continue + scored.append((opp, value)) + # sort by net APY desc, then id for a deterministic tie-break + scored.sort(key=lambda pair: (-pair[1], pair[0].id)) + return scored + + +def optimize( + opportunities: list[YieldOpportunity], + budget_usd: float, + cons: OptimizerConstraints = DEFAULT_CONSTRAINTS, + *, + asset: str = "USDC", +) -> AllocationPlan: + """Greedy-with-constraints allocation of ``budget_usd`` across opportunities. + + Walks the net-APY ranking, giving each opportunity as much as the + per-protocol cap and remaining budget allow. With the default 40% cap a + full allocation needs >=3 protocols — diversification by construction. + """ + notes: list[str] = [] + if budget_usd <= 0: + return AllocationPlan( + budget_usd=budget_usd, asset=asset, + constraints=_constraints_dict(cons), notes=["budget must be positive"], + ) + + notional = budget_usd / max(cons.max_positions, 1) + ranked = rank(opportunities, cons, notional_usd=notional) + if not ranked: + return AllocationPlan( + budget_usd=budget_usd, asset=asset, unallocated_usd=budget_usd, + constraints=_constraints_dict(cons), + notes=["no opportunity passed the filters / minimum net APY"], + ) + + protocol_cap = cons.max_protocol_pct * budget_usd + protocol_used: dict[str, float] = {} + remaining = budget_usd + entries: list[AllocationEntry] = [] + capped: set[str] = set() + + for opp, _ in ranked: + if len(entries) >= cons.max_positions: + notes.append(f"stopped at the {cons.max_positions}-position cap") + break + if remaining < cons.min_ticket_usd: + break + proto = opp.protocol.lower() + room = protocol_cap - protocol_used.get(proto, 0.0) + if room < cons.min_ticket_usd: + continue # this protocol is already at its cap + ticket = min(remaining, room) + if remaining > room: + capped.add(proto) # the per-protocol cap (not the budget) bound this + entries.append( + AllocationEntry( + opportunity_id=opp.id, + protocol=opp.protocol, + chain=opp.chain, + amount_usd=ticket, + expected_net_apy=net_apy(opp, ticket, cons), + risk_score=opp.risk_score, + ) + ) + protocol_used[proto] = protocol_used.get(proto, 0.0) + ticket + remaining -= ticket + + for proto in sorted(capped): + notes.append( + f"{proto} capped at {cons.max_protocol_pct:.0%} of budget " + f"(${protocol_cap:,.0f})" + ) + if remaining > cons.min_ticket_usd: + notes.append( + f"${remaining:,.0f} unallocated — ran out of eligible opportunities" + ) + + allocated = sum(e.amount_usd for e in entries) + blended_apy = ( + sum(e.amount_usd * e.expected_net_apy for e in entries) / allocated + if allocated > 0 else 0.0 + ) + blended_risk = ( + sum(e.amount_usd * e.risk_score for e in entries) / allocated + if allocated > 0 else 0.0 + ) + return AllocationPlan( + budget_usd=budget_usd, + asset=asset, + entries=entries, + unallocated_usd=remaining, + blended_net_apy=blended_apy, + blended_risk=blended_risk, + constraints=_constraints_dict(cons), + notes=notes, + ) + + +def _constraints_dict(cons: OptimizerConstraints) -> dict: + return { + "min_net_apy": cons.min_net_apy, + "max_risk_score": cons.max_risk_score, + "max_protocol_pct": cons.max_protocol_pct, + "max_positions": cons.max_positions, + "min_ticket_usd": cons.min_ticket_usd, + "gas_cost_usd": cons.gas_cost_usd, + "holding_period_days": cons.holding_period_days, + "risk_lambda": cons.risk_lambda, + } diff --git a/yields/risk.py b/yields/risk.py new file mode 100644 index 0000000..8ed30d6 --- /dev/null +++ b/yields/risk.py @@ -0,0 +1,135 @@ +"""yields.risk — transparent, pure risk scoring for yield opportunities. + +A score in [0, 1], lower = safer. It is a weighted sum of five sub-scores, +every weight and threshold living in ``RiskConfig`` so the model is inspectable +and tunable:: + + risk = w_tvl*r_tvl + w_protocol*r_protocol + w_reward*r_reward + + w_peg*r_peg + w_chain*r_chain (weights sum to 1.0) + +Sub-scores (each in [0, 1]): + r_tvl depth — clamp(1 - log10(tvl/floor)/decades): a $1M pool -> 1.0, + a $1B pool -> 0.0. A shallow pool is easier to drain / distort. + r_protocol smart-contract maturity, from the curated PROTOCOL_TIERS table. + r_reward incentive fragility — reward_apy / total_apy (token emissions end). + r_peg stablecoin de-peg risk of the underlying, from STABLE_PEG_RISK. + r_chain chain risk — 0 for Ethereum L1, a small prior for an L2. + +Anti-gameability: every input is either measured on-chain (TVL) or read from a +table this repo controls — never self-reported by the pool. Only a maintainer +editing a table can change a protocol's safety rating. + +This module is PURE — no network, no chain, no clock, no env. It imports only +the models and the stdlib, so it ports cleanly to frontend-integration. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +from yields.models import YieldOpportunity + +# Smart-contract maturity tiers — lower = safer. Keyed by normalized protocol +# slug. Unlisted protocols fall back to UNKNOWN_PROTOCOL_RISK (conservative). +PROTOCOL_TIERS: dict[str, float] = { + # bluechip — multi-year track record, heavily audited, deep TVL + "aave-v3": 0.08, + "lido": 0.10, + "sky": 0.12, + "compound-v3": 0.12, + "morpho-blue": 0.18, + # established — audited and in production, but younger or more complex + "moonwell": 0.32, + "curve": 0.28, + "convex": 0.32, + "aerodrome": 0.34, + "pendle": 0.40, + "ethena": 0.42, +} +UNKNOWN_PROTOCOL_RISK = 0.70 + +# De-peg risk of stable underlyings. Non-stable assets (ETH, WETH, ...) carry +# 0.0 here — their price volatility is the depositor's chosen exposure, not a +# risk of the opportunity itself. +STABLE_PEG_RISK: dict[str, float] = { + "usdc": 0.06, "usdt": 0.08, "dai": 0.08, "usdbc": 0.08, + "usds": 0.10, "pyusd": 0.10, "crvusd": 0.18, "gho": 0.18, + "frax": 0.20, "susde": 0.22, "usde": 0.22, +} + + +@dataclass(frozen=True) +class RiskConfig: + """Weights and thresholds for the risk score. The five weights sum to 1.0.""" + + w_tvl: float = 0.30 + w_protocol: float = 0.35 + w_reward: float = 0.15 + w_peg: float = 0.12 + w_chain: float = 0.08 + tvl_floor_usd: float = 1_000_000.0 # TVL at/below this scores r_tvl = 1.0 + tvl_decades: float = 3.0 # decades of TVL above the floor -> r_tvl = 0.0 + chain_risk: dict = field(default_factory=lambda: {"ethereum": 0.0, "base": 0.12}) + + def validate(self) -> None: + total = self.w_tvl + self.w_protocol + self.w_reward + self.w_peg + self.w_chain + if abs(total - 1.0) > 1e-9: + raise ValueError(f"RiskConfig weights must sum to 1.0, got {total}") + + +DEFAULT_RISK_CONFIG = RiskConfig() + + +def _clamp01(x: float) -> float: + return max(0.0, min(1.0, x)) + + +def r_tvl(tvl_usd: float, cfg: RiskConfig) -> float: + """Depth sub-score. A shallow pool -> 1.0 (risky); a deep pool -> 0.0.""" + if tvl_usd <= 0: + return 1.0 + decades_above_floor = math.log10(max(tvl_usd, 1.0) / cfg.tvl_floor_usd) + return _clamp01(1.0 - decades_above_floor / cfg.tvl_decades) + + +def r_protocol(protocol: str) -> float: + """Smart-contract maturity sub-score from the curated tier table.""" + return PROTOCOL_TIERS.get((protocol or "").strip().lower(), UNKNOWN_PROTOCOL_RISK) + + +def r_reward(opp: YieldOpportunity) -> float: + """Incentive fragility — the share of APY that is token emissions.""" + total = opp.apy_base + opp.apy_reward + if total <= 0: + return 0.0 + return _clamp01(opp.apy_reward / total) + + +def r_peg(opp: YieldOpportunity) -> float: + """Max de-peg risk across the opportunity's stable underlyings.""" + risks = [STABLE_PEG_RISK.get((t.symbol or "").lower(), 0.0) for t in opp.underlying] + return max(risks) if risks else 0.0 + + +def r_chain(opp: YieldOpportunity, cfg: RiskConfig) -> float: + """Chain-level risk prior (L2 sequencer / bridge surface).""" + return cfg.chain_risk.get(opp.chain.value, 0.10) + + +def breakdown(opp: YieldOpportunity, cfg: RiskConfig = DEFAULT_RISK_CONFIG) -> dict[str, float]: + """The five WEIGHTED components — they sum to the score. For transparency + (e.g. a `nunchi yield` "why is this risky" view).""" + return { + "tvl": cfg.w_tvl * r_tvl(opp.tvl_usd, cfg), + "protocol": cfg.w_protocol * r_protocol(opp.protocol), + "reward": cfg.w_reward * r_reward(opp), + "peg": cfg.w_peg * r_peg(opp), + "chain": cfg.w_chain * r_chain(opp, cfg), + } + + +def score_opportunity( + opp: YieldOpportunity, cfg: RiskConfig = DEFAULT_RISK_CONFIG +) -> float: + """Risk score in [0, 1] — lower is safer. Pure: deterministic, no I/O.""" + return _clamp01(sum(breakdown(opp, cfg).values())) diff --git a/yields/sources/__init__.py b/yields/sources/__init__.py new file mode 100644 index 0000000..c675758 --- /dev/null +++ b/yields/sources/__init__.py @@ -0,0 +1,6 @@ +"""yields.sources — yield-opportunity discovery. + +`defillama` provides broad read-only discovery across ~all Ethereum + Base +pools. `onchain/` holds the curated adapters that additionally read live +APY/TVL and a wallet's positions, and build executable deposit/withdraw routes. +""" diff --git a/yields/sources/base.py b/yields/sources/base.py new file mode 100644 index 0000000..db100d1 --- /dev/null +++ b/yields/sources/base.py @@ -0,0 +1,95 @@ +"""The `YieldSource` abstract base — the contract every discovery source obeys. + +A source's `discover()` MUST be defensive: it catches its own network/parse +errors and returns whatever it could (possibly an empty list) — it never +raises to the aggregator, so one flaky source cannot break a scan. + +Read-only sources (DeFiLlama) implement only `discover()`. On-chain adapters +additionally implement `get_position` / `build_deposit` / `build_withdraw` and +report `supports() == True` for the opportunities they can route. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import ClassVar, Optional, Sequence + +from yields.models import Chain, Position, RouteStep, SourceTier, YieldOpportunity + + +class NotSupported(RuntimeError): + """Raised when a YieldSource is asked for an operation it does not implement.""" + + +class YieldSource(ABC): + """Base class for every yield-discovery source.""" + + #: discovery tier — set by each concrete source + tier: ClassVar[SourceTier] + #: short stable identifier, e.g. "defillama" or "aave-v3" + name: ClassVar[str] + + @abstractmethod + def discover(self, chains: Sequence[Chain]) -> list[YieldOpportunity]: + """Return opportunities on ``chains``. + + Implementations MUST catch their own network and parse errors and + return a (possibly empty, possibly partial) list — never raise. + """ + + def supports(self, opp: YieldOpportunity) -> bool: + """True when this source can route (deposit/withdraw) ``opp``. + + Read-only sources leave this False; on-chain adapters override it. + """ + return False + + # --- execution surface — on-chain adapters override these --------------- + def get_position(self, wallet: str, opp: YieldOpportunity) -> Optional[Position]: + raise NotSupported(f"{self.name} does not implement get_position") + + def build_deposit( + self, opp: YieldOpportunity, amount: int, wallet: str + ) -> list[RouteStep]: + """Build the unsigned deposit route for ``amount`` (raw base units of + the opportunity's underlying).""" + raise NotSupported(f"{self.name} does not implement build_deposit") + + def build_withdraw( + self, opp: YieldOpportunity, position: Position, wallet: str + ) -> list[RouteStep]: + """Build the unsigned withdraw route for an open ``position``.""" + raise NotSupported(f"{self.name} does not implement build_withdraw") + + # --- approval surface — adapters override only when non-standard -------- + def required_deposit_approvals( + self, opp: YieldOpportunity, amount: int + ) -> list[tuple[str, str, int]]: + """ERC20 approvals a deposit needs before ``build_deposit`` will succeed. + + Each tuple is ``(token_address, spender_address, amount)``. The default + covers the common case — supply/deposit a single ERC20 underlying to + the opportunity's ``pool_address`` — and returns ``[]`` when the + underlying token address or the pool address is unknown (a + DeFiLlama-only row, or a native-ETH deposit). Adapters whose deposit + approves a *different* token (Lido wraps stETH, not the ETH underlying) + override this. + """ + if ( + opp.underlying + and opp.underlying[0].address + and opp.pool_address + ): + return [(opp.underlying[0].address, opp.pool_address, int(amount))] + return [] + + def required_withdraw_approvals( + self, opp: YieldOpportunity, position: Position + ) -> list[tuple[str, str, int]]: + """ERC20 approvals a withdraw needs before ``build_withdraw`` succeeds. + + The default is ``[]`` — money-market and ERC-4626 withdrawals burn a + receipt token the protocol already controls, so no approval is needed. + Adapters that must approve a token to a withdrawal contract (Lido + approves wstETH to the WithdrawalQueue) override this. + """ + return [] diff --git a/yields/sources/defillama.py b/yields/sources/defillama.py new file mode 100644 index 0000000..c1deb7a --- /dev/null +++ b/yields/sources/defillama.py @@ -0,0 +1,323 @@ +"""DefiLlamaSource — broad, read-only yield discovery via the DeFiLlama API. + +DeFiLlama's ``yields.llama.fi/pools`` endpoint aggregates APY/TVL across +thousands of pools on every chain. This source pulls it, filters to the +requested chains, and maps each row to a :class:`YieldOpportunity`. It is the +Tier 1 discovery surface — broad coverage, but read-only: every opportunity it +emits has ``has_onchain_adapter=False`` and ``pool_address=None`` (DeFiLlama's +``pool`` field is its own UUID, not an EVM contract address — it is stashed in +``raw`` instead). + +Verified response shape (``yields.llama.fi/pools``, 2026-05-18, against the +DeFiLlama yield-server schema):: + + {"status": "success", "data": [ {pool row}, ... ]} + +A pool row carries: ``pool`` (string UUID), ``chain`` (capitalized name, e.g. +"Ethereum" / "Base"), ``project`` (protocol slug), ``symbol``, ``tvlUsd``, +``apyBase`` (nullable), ``apyReward`` (nullable), ``apy`` (nullable total), +``underlyingTokens`` (nullable address list), ``rewardTokens``, ``poolMeta``. + +The source is defensive end-to-end (per the ``YieldSource`` contract): +``discover()`` catches every network/parse error and returns a possibly empty +list — it never raises into the aggregator. A best-effort on-disk cache lets a +scan still return data briefly when the API is unreachable. +""" +from __future__ import annotations + +import json +import logging +import os +import time +from pathlib import Path +from typing import Any, ClassVar, Optional, Sequence + +import requests + +from yields.models import ( + Chain, + SourceTier, + TokenRef, + YieldKind, + YieldOpportunity, + canonical_id, +) +from yields.sources.base import YieldSource + +log = logging.getLogger(__name__) + +#: Default DeFiLlama yields endpoint. Overridable via ``NUNCHI_DEFILLAMA_URL``. +_DEFAULT_URL = "https://yields.llama.fi/pools" +_HTTP_TIMEOUT_S = 20 +#: Cache freshness window — a cached snapshot older than this is ignored. +_CACHE_TTL_S = 6 * 3600 +_CACHE_PATH = Path.home() / ".nunchi" / "yields-cache" / "defillama.json" + +# DeFiLlama capitalizes chain names; map our Chain enum onto those labels. +_CHAIN_LABEL: dict[Chain, str] = { + Chain.ethereum: "Ethereum", + Chain.base: "Base", +} + +# Heuristic project-slug -> YieldKind. DeFiLlama does not return a clean kind; +# unknown projects fall through to ``YieldKind.lending`` which is the dominant +# category on money-market-heavy chains and never wrong enough to mislead the +# optimizer (kind is informational, not load-bearing for routing). +_STAKING_HINTS = ("lido", "rocket-pool", "stakewise", "frax-ether", "stader") +_VAULT_HINTS = ("sky-", "makerdao", "spark", "yearn", "morpho", "erc4626") +_LP_HINTS = ("uniswap", "curve", "balancer", "aerodrome", "velodrome", "pancakeswap") + + +def _classify_kind(project: str, symbol: str) -> YieldKind: + """Best-effort YieldKind from a DeFiLlama project slug / symbol.""" + p = project.lower() + if any(h in p for h in _STAKING_HINTS): + return YieldKind.staking + if any(h in p for h in _VAULT_HINTS): + return YieldKind.vault + if any(h in p for h in _LP_HINTS): + return YieldKind.lp + # An LP symbol usually contains a separator ("USDC-WETH"); a single-asset + # money-market row does not. + if "-" in symbol and project: + return YieldKind.lp + return YieldKind.lending + + +def _normalize_protocol(project: str) -> str: + """Normalize a DeFiLlama project name to a stable lowercase slug. + + DeFiLlama already returns hyphenated slugs ("aave-v3", "lido"); this just + lowercases and trims so it dedups cleanly against on-chain adapter slugs. + """ + return (project or "").strip().lower() + + +class DefiLlamaSource(YieldSource): + """Tier 1 read-only discovery backed by the DeFiLlama yields API.""" + + tier: ClassVar[SourceTier] = SourceTier.defillama + name: ClassVar[str] = "defillama" + + def __init__(self, *, url: Optional[str] = None, use_cache: bool = True) -> None: + # Explicit arg wins; else the env override; else the default endpoint. + self.url = url or os.environ.get("NUNCHI_DEFILLAMA_URL", "").strip() or _DEFAULT_URL + self.use_cache = use_cache + + # --- discovery -------------------------------------------------------- + def discover(self, chains: Sequence[Chain]) -> list[YieldOpportunity]: + """Fetch DeFiLlama pools and map them onto the requested chains. + + Never raises: a network failure, non-200, or malformed body all + degrade to a (cached if available, else empty) result. + """ + wanted = {c for c in chains} + if not wanted: + return [] + labels = {_CHAIN_LABEL[c] for c in wanted if c in _CHAIN_LABEL} + + rows = self._fetch_rows() + if rows is None: + cached = self._read_cache() + if cached is None: + log.warning("defillama: no live data and no usable cache — returning []") + return [] + log.warning("defillama: live fetch failed — serving %d cached rows", len(cached)) + rows = cached + else: + self._write_cache(rows) + + fetched_ms = int(time.time() * 1000) + out: list[YieldOpportunity] = [] + for row in rows: + if not isinstance(row, dict): + continue + if str(row.get("chain", "")) not in labels: + continue + opp = self._parse_pool_row(row, fetched_ms) + if opp is not None: + out.append(opp) + log.info("defillama: %d opportunities across %s", len(out), sorted(labels)) + return out + + # --- HTTP ------------------------------------------------------------- + def _fetch_rows(self) -> Optional[list[Any]]: + """GET the pools endpoint; return the ``data`` list, or None on any + failure. All shape/transport assumptions are contained here.""" + try: + resp = requests.get( + self.url, + timeout=_HTTP_TIMEOUT_S, + headers={"Accept": "application/json"}, + ) + except requests.RequestException as exc: + log.warning("defillama: request to %s failed: %s", self.url, exc) + return None + if resp.status_code != 200: + log.warning("defillama: %s returned HTTP %s", self.url, resp.status_code) + return None + try: + body = resp.json() + except (ValueError, json.JSONDecodeError) as exc: + log.warning("defillama: response body was not valid JSON: %s", exc) + return None + # Documented shape is {"status": "success", "data": [...]}; tolerate a + # bare list too in case the endpoint shape ever changes. + if isinstance(body, dict): + data = body.get("data") + elif isinstance(body, list): + data = body + else: + data = None + if not isinstance(data, list): + log.warning("defillama: response had no 'data' list — got %s", type(body)) + return None + return data + + # --- row mapping ------------------------------------------------------ + def _parse_pool_row( + self, row: dict[str, Any], fetched_ms: int + ) -> Optional[YieldOpportunity]: + """Map one DeFiLlama pool row to a YieldOpportunity. + + Every field access is a defensive ``.get()``; a row that cannot be + mapped is skipped (logged at debug) rather than aborting the scan. + """ + try: + chain_label = str(row.get("chain", "")) + chain = _label_to_chain(chain_label) + if chain is None: + return None + + project = str(row.get("project", "")).strip() + protocol = _normalize_protocol(project) + if not protocol: + return None + symbol = str(row.get("symbol", "")).strip() + + apy_base = _as_apy(row.get("apyBase")) + apy_reward = _as_apy(row.get("apyReward")) + # When DeFiLlama gives only a total ``apy`` (no breakdown), treat it + # as base — the optimizer reads apy_total either way. + if apy_base == 0.0 and apy_reward == 0.0: + apy_base = _as_apy(row.get("apy")) + + tvl_usd = _as_float(row.get("tvlUsd")) + kind = _classify_kind(project, symbol) + + underlying = _build_underlying(row.get("underlyingTokens"), symbol, chain) + under_addrs = [t.address for t in underlying if t.address] + + # DeFiLlama's ``pool`` field is its own UUID, never an EVM address. + llama_pool_id = row.get("pool") + opp_id = canonical_id( + chain=chain.value, + protocol=protocol, + underlying_addresses=under_addrs, + pool_address=None, + kind=kind.value, + ) + return YieldOpportunity( + id=opp_id, + protocol=protocol, + chain=chain, + kind=kind, + pool_address=None, + underlying=underlying, + receipt_token=None, + apy_base=apy_base, + apy_reward=apy_reward, + tvl_usd=tvl_usd, + source_tier=SourceTier.defillama, + has_onchain_adapter=False, + fetched_at_ms=fetched_ms, + raw={ + "defillama_pool_id": llama_pool_id, + "project": project, + "symbol": symbol, + "poolMeta": row.get("poolMeta"), + "apy": row.get("apy"), + "apyBase": row.get("apyBase"), + "apyReward": row.get("apyReward"), + "stablecoin": row.get("stablecoin"), + "ilRisk": row.get("ilRisk"), + "exposure": row.get("exposure"), + }, + ) + except Exception as exc: # noqa: BLE001 - one bad row must not stop the scan + log.debug("defillama: skipping unparseable row (%s): %r", exc, row.get("pool")) + return None + + # --- on-disk cache (best effort) ------------------------------------- + def _write_cache(self, rows: list[Any]) -> None: + if not self.use_cache: + return + try: + _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = {"cached_at_ms": int(time.time() * 1000), "data": rows} + _CACHE_PATH.write_text(json.dumps(payload)) + except OSError as exc: # caching is a nicety — never fatal + log.debug("defillama: could not write cache: %s", exc) + + def _read_cache(self) -> Optional[list[Any]]: + if not self.use_cache or not _CACHE_PATH.is_file(): + return None + try: + payload = json.loads(_CACHE_PATH.read_text()) + except (OSError, ValueError) as exc: + log.debug("defillama: could not read cache: %s", exc) + return None + cached_at = payload.get("cached_at_ms", 0) if isinstance(payload, dict) else 0 + age_s = (int(time.time() * 1000) - int(cached_at)) / 1000.0 + if age_s > _CACHE_TTL_S: + log.debug("defillama: cache is stale (%.0fs old) — ignoring", age_s) + return None + data = payload.get("data") if isinstance(payload, dict) else None + return data if isinstance(data, list) else None + + +# --- module-level parse helpers (no shape assumption escapes this file) ----- +def _label_to_chain(label: str) -> Optional[Chain]: + """Map a DeFiLlama chain label back to our Chain enum (None if unsupported).""" + for chain, lbl in _CHAIN_LABEL.items(): + if lbl == label: + return chain + return None + + +def _as_float(value: Any) -> float: + """Coerce a possibly-null/str numeric to float; default 0.0.""" + if value is None: + return 0.0 + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _as_apy(value: Any) -> float: + """DeFiLlama reports APY in percent (4.3 == 4.3%); our models use the + fraction (0.043). Convert here. Null/garbage -> 0.0.""" + return _as_float(value) / 100.0 + + +def _build_underlying( + tokens: Any, symbol: str, chain: Chain +) -> list[TokenRef]: + """Build TokenRefs from DeFiLlama's ``underlyingTokens`` address list. + + DeFiLlama gives addresses without symbols/decimals; we attach the addresses + and leave symbol best-effort from the pool symbol. ``decimals`` stays None + (the optimizer never needs it for a DeFiLlama-only row).""" + if not isinstance(tokens, list) or not tokens: + # No address list — keep a symbol-only ref so the row is still usable. + sym = (symbol or "").strip() + return [TokenRef(symbol=sym, chain=chain)] if sym else [] + refs: list[TokenRef] = [] + sym_parts = [s for s in (symbol or "").replace("/", "-").split("-") if s] + for i, addr in enumerate(tokens): + if not isinstance(addr, str) or not addr.startswith("0x"): + continue + sym = sym_parts[i] if i < len(sym_parts) else "" + refs.append(TokenRef(symbol=sym, chain=chain, address=addr)) + return refs