Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
414 changes: 414 additions & 0 deletions cli/commands/yield_cmd.py

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions cli/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 2 additions & 0 deletions cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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():
Expand Down
11 changes: 11 additions & 0 deletions yields/__init__.py
Original file line number Diff line number Diff line change
@@ -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`.
"""
152 changes: 152 additions & 0 deletions yields/aggregator.py
Original file line number Diff line number Diff line change
@@ -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
Loading